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 (
)
}
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 (
{!status.hasSynced ? (
) : null}
{!canEdit ? : null}
{copied === 'prompt' ? (
Copied! Paste it into Claude, ChatGPT or Grok, then ask for a castle.
) : null}
{canEdit ? : null}
)
}
function initials(name: string) {
return name
.split(/\s+/)
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase()
}
|