import { signInHref, signOutHref } from './nav.ts' import { useQuery, useStatus, usePowerSync } from '@powersync/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { agentPrompt } from '../../src/shared/agent-prompt.ts' import { MATERIALS, blockId, type MaterialKey } from '../../src/shared/world-rules.ts' import { BotIcon, PanelIcon, PersonIcon, PlaceIcon, RemoveIcon, SyncIcon } from './icons.tsx' import type { BlockRow, BuildRow, PlayerRow } from './powersync.ts' import { World, type Cell, type Mode } from './World.tsx' export type Session = { signedIn: boolean worldId: string powersyncUrl: string player?: { id: string; displayName: string; color: string } } export type Room = { slug: string; name: string; worldId: string; isOwner: boolean } const appBase = document.documentElement.dataset.appBase ?? '' function RoomName({ room, name }: { room: Room; name: string }) { const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(name) const [saving, setSaving] = useState(false) if (!room.isOwner) return {name} if (!editing) { return ( ) } async function save() { const next = draft.trim() if (!next || next === name) return setEditing(false) setSaving(true) await fetch(`${appBase}/api/rooms/${room.slug}`, { method: 'PATCH', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: next }), }).catch(() => null) setSaving(false) setEditing(false) } return (
{ e.preventDefault() void save() }} > setDraft(e.target.value)} onBlur={() => void save()} onKeyDown={(e) => e.key === 'Escape' && setEditing(false)} />
) } async function copyText(text: string) { try { await navigator.clipboard.writeText(text) return true } catch { const ta = document.createElement('textarea') ta.value = text ta.setAttribute('readonly', '') ta.style.position = 'fixed' ta.style.opacity = '0' document.body.appendChild(ta) ta.select() const ok = document.execCommand('copy') ta.remove() return ok } } function useCopied() { const [copied, setCopied] = useState(null) useEffect(() => { if (!copied) return const t = setTimeout(() => setCopied(null), 2200) return () => clearTimeout(t) }, [copied]) return [copied, setCopied] as const } function useNow(ms: number) { const [now, setNow] = useState(() => Date.now()) useEffect(() => { const t = setInterval(() => setNow(Date.now()), ms) return () => clearInterval(t) }, [ms]) return now } const parseTime = (s: string | null) => { if (!s) return 0 const iso = s.includes('T') ? s : s.replace(' ', 'T') return Date.parse(/[zZ]|[+-]\d\d(:?\d\d)?$/.test(iso) ? iso : iso + 'Z') } export function App({ session, initialRoom }: { session: Session; initialRoom: Room }) { const db = usePowerSync() const status = useStatus() const worldId = initialRoom.worldId const me = session.player ?? null const canEdit = session.signedIn && !!me const [mode, setMode] = useState('place') const [material, setMaterial] = useState('stone') const [panelOpen, setPanelOpen] = useState(() => window.matchMedia('(min-width: 900px)').matches) const now = useNow(5000) const { data: blocks } = useQuery( 'SELECT id, world_id, x, y, z, material, build_id, placed_by, seq FROM blocks WHERE world_id = ?', [worldId], ) const { data: players } = useQuery('SELECT * FROM players WHERE world_id = ? ORDER BY display_name', [worldId]) const { data: builds } = useQuery( 'SELECT * FROM builds WHERE world_id = ? ORDER BY created_at DESC LIMIT 20', [worldId], ) const { data: worldRows } = useQuery<{ id: string; name: string }>('SELECT id, name FROM worlds WHERE id = ?', [worldId]) const worldName = worldRows[0]?.name ?? initialRoom.name const roomUrl = `${location.origin}${appBase}/r/${initialRoom.slug}` const [copied, setCopied] = useCopied() const copyPrompt = async () => { const text = agentPrompt({ roomUrl, roomCode: initialRoom.slug, roomName: worldName, userName: me?.displayName ?? 'Me', }) ;(window as unknown as { __wbPrompt: string }).__wbPrompt = text if (await copyText(text)) setCopied('prompt') } const copyLink = async () => { if (await copyText(roomUrl)) setCopied('link') } const playerById = useMemo(() => new Map(players.map((p) => [p.id, p])), [players]) const online = players.filter((p) => { const seen = parseTime(p.last_seen) const building = builds.some((b) => b.author_id === p.id && (b.status === 'building' || b.status === 'planning')) return p.id === me?.id || building || now - seen < 120_000 }) const place = useCallback( async (cell: Cell) => { if (!me) return const id = blockId(worldId, cell.x, cell.y, cell.z) await db.writeTransaction(async (tx) => { const existing = await tx.getOptional('SELECT id FROM blocks WHERE id = ?', [id]) if (existing) { await tx.execute('UPDATE blocks SET material = ?, placed_by = ?, build_id = NULL WHERE id = ?', [material, me.id, id]) } else { await tx.execute( 'INSERT INTO blocks (id, world_id, x, y, z, material, placed_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, worldId, cell.x, cell.y, cell.z, material, me.id], ) } }) }, [db, me, worldId, material], ) const remove = useCallback( async (block: BlockRow) => { await db.execute('DELETE FROM blocks WHERE id = ?', [block.id]) }, [db], ) // Share my cursor (throttled) so others see where I'm pointing. const pendingCursor = useRef(undefined) useEffect(() => { if (!me) return let last = '' const t = setInterval(() => { const c = pendingCursor.current if (c === undefined) return const key = c ? `${c.x}:${c.y}:${c.z}` : 'none' if (key === last) return last = key void db.execute('UPDATE players SET cursor_x = ?, cursor_y = ?, cursor_z = ?, world_id = ? WHERE id = ?', [ c?.x ?? null, c?.y ?? null, c?.z ?? null, worldId, me.id, ]) }, 350) return () => clearInterval(t) }, [db, me, worldId]) const onHover = useCallback((cell: Cell | null) => { pendingCursor.current = cell }, []) const syncState: 'synced' | 'syncing' | 'offline' = !status.connected ? 'offline' : status.dataFlowStatus?.uploading || status.dataFlowStatus?.downloading || !status.hasSynced ? 'syncing' : 'synced' const syncLabel = { synced: 'Live', syncing: 'Syncing', offline: 'Offline' }[syncState] return (
World Builder
{canEdit ? ( ) : ( Sign in to build )}
{online.slice(0, 5).map((p) => ( {p.kind === 'agent' ? : initials(p.display_name)} ))} {online.length > 5 ? +{online.length - 5} : null}
{session.signedIn ? ( Sign out ) : null}
{!status.hasSynced ? (
{canEdit ? : null}
) } function initials(name: string) { return name .split(/\s+/) .map((w) => w[0]) .join('') .slice(0, 2) .toUpperCase() }