Something went wrong. Try again.
An atproto Tilemap editor
Something went wrong. Try again.
12 kB · 335 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336import { useEffect, useState } from 'react'import { ChevronDown, LayoutGrid, Map as MapIcon, Plus, SlidersHorizontal, X,} from 'lucide-react'import { useEditorStore } from '../store/editorStore'import { useMapStore } from '../store/mapStore'import { useAuthStore } from '../store/authStore'import { useUiStore, type EditorKind } from '../store/uiStore'import { deleteTileset, listTilesets, loadTilesetIntoEditor, newTileset, subscribeLibrary, type TilesetIndexEntry,} from '../store/persistence'import { deleteMapLocal, listMaps, loadMapIntoEditor, subscribeMapLibrary, type MapIndexEntry,} from '../store/mapPersistence'import { type DocKind } from '../lib/docTypes'import { TOOL } from '../lib/editorMeta'import AtprotoBrowser from './AtprotoBrowser'import LoginDialog from './LoginDialog'import CollapsibleSection from './CollapsibleSection'import { Button } from '@/components/ui/button'import { Input } from '@/components/ui/input'import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,} from '@/components/ui/dropdown-menu'import { cn } from '@/lib/utils'
const ROW = 'flex items-center gap-2 rounded-md border border-transparent px-1.5 py-1 hover:bg-accent'
const LIST_SCROLL = 'flex max-h-56 list-none flex-col gap-1 overflow-y-auto p-0'
/** The left sidebar: a tool indicator, two collapsible/scrollable document * lists (tilesets, maps), and the New / Load actions. Picking, renaming, * creating, or loading a document drives the active editor. Both tilesets and * maps persist to (and restore from) the IndexedDB library; the active doc shows * as "unsaved"/"working" until its first autosave. (The world editor is not * surfaced here yet — no library listing or New option.) */export default function Library() { const editorKind = useUiStore((s) => s.editorKind) const setEditorKind = useUiStore((s) => s.setEditorKind) const setSidebarView = useUiStore((s) => s.setSidebarView)
/** Open a document in its editor and surface that editor's tools. */ const openInEditor = (kind: EditorKind) => { setEditorKind(kind) setSidebarView('tools') }
const activeId = useEditorStore((s) => s.id) const activeName = useEditorStore((s) => s.tilesetName)
const mapName = useMapStore((s) => s.name) const setMapName = useMapStore((s) => s.setName) const activeMapId = useMapStore((s) => s.stableId)
const status = useAuthStore((s) => s.status)
const [entries, setEntries] = useState<TilesetIndexEntry[]>([]) const [mapEntries, setMapEntries] = useState<MapIndexEntry[]>([]) /** the kind queued by "New", pending login + creation (null when idle). */ const [pendingNew, setPendingNew] = useState<DocKind | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { let alive = true const refresh = () => listTilesets().then((list) => alive && setEntries(list)) refresh() const unsub = subscribeLibrary(refresh) return () => { alive = false unsub() } }, [])
useEffect(() => { let alive = true const refresh = () => listMaps().then((list) => alive && setMapEntries(list)) refresh() const unsub = subscribeMapLibrary(refresh) return () => { alive = false unsub() } }, [])
// "New" requires login. Queuing a kind opens the login dialog when signed out; // once status flips to 'in' (or already is) this creates the document. A new // tileset is minted into the user's repo with a verified-free TID so Save is // enabled immediately (see newTileset); maps stay local until map persistence. useEffect(() => { if (status !== 'in' || pendingNew == null) return const kind = pendingNew void (async () => { try { if (kind === 'tileset') { const { agent, did } = useAuthStore.getState() if (!agent || !did) return await newTileset(agent, did) } else if (kind === 'map') { useMapStore.getState().reset() } useUiStore.getState().setEditorKind(kind) useUiStore.getState().setSidebarView('tools') } catch (err) { setError(err instanceof Error ? err.message : 'Could not create the document.') } finally { setPendingNew(null) } })() }, [status, pendingNew])
const onNew = (kind: DocKind) => { setError(null) setPendingNew(kind) }
const onDeleteTileset = async (entry: TilesetIndexEntry) => { if (!window.confirm(`Delete tileset "${entry.name}"? This can't be undone.`)) return await deleteTileset(entry.id) }
const onDeleteMap = async (entry: MapIndexEntry) => { if (!window.confirm(`Delete map "${entry.name}"? This can't be undone.`)) return await deleteMapLocal(entry.id) }
const tilesetActive = editorKind === 'tileset' const mapActive = editorKind === 'map' const unsavedActive = !entries.some((e) => e.id === activeId) // the active map isn't in the library until it's been edited (autosaved). const mapUnsaved = !mapEntries.some((e) => e.id === activeMapId)
const Indicator = TOOL[editorKind].icon
return ( <div className="flex flex-col gap-3"> <div className="flex items-center justify-between gap-2"> <div className="flex min-w-0 items-center gap-2"> <Indicator className="size-4 shrink-0 text-muted-foreground" /> <h1 className="truncate text-base font-semibold tracking-tight"> {TOOL[editorKind].label} </h1> </div> <Button type="button" variant="ghost" size="sm" className="shrink-0 text-muted-foreground" onClick={() => setSidebarView('tools')} > <SlidersHorizontal data-icon="inline-start" /> Tools </Button> </div>
{/* ---- Tilesets ---- */} <CollapsibleSection title="Tilesets"> <ul className={LIST_SCROLL}> {entries.map((entry) => { const active = entry.id === activeId return ( <li key={entry.id} className={cn(ROW, active && tilesetActive && 'border-ring bg-accent')} > <button type="button" className="flex-1 truncate text-left text-sm" onClick={() => { // Load fully before flipping the editor: openInEditor sets // editorKind synchronously, and the URL sync reads editorKind // + source together — flipping first would briefly pair the // new kind with the old tileset's source and push a stale URL. void (async () => { if (!active) await loadTilesetIntoEditor(entry.id) openInEditor('tileset') })() }} > {(active ? activeName : entry.name) || 'Untitled'} </button> <Button type="button" variant="ghost" size="icon-sm" title="Delete tileset" className="shrink-0 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation() onDeleteTileset(entry) }} > <X /> </Button> </li> ) })}
{/* The active tileset isn't in the library until it's been edited+saved. */} {unsavedActive && ( <li className={cn(ROW, tilesetActive && 'border-ring bg-accent')}> <button type="button" className="flex-1 truncate text-left text-sm" onClick={() => openInEditor('tileset')} > {activeName || 'Untitled'} </button> <span className="shrink-0 text-[0.7rem] text-muted-foreground">unsaved</span> </li> )} </ul> </CollapsibleSection>
{/* ---- Maps ---- */} <CollapsibleSection title="Maps"> <ul className={LIST_SCROLL}> {mapEntries.map((entry) => { const active = entry.id === activeMapId return ( <li key={entry.id} className={cn(ROW, active && mapActive && 'border-ring bg-accent')}> {active ? ( // the active map renames in place; its name autosaves to the index. <Input className="h-7 flex-1 text-sm" value={mapName} placeholder="Untitled map" onFocus={() => setEditorKind('map')} onChange={(e) => setMapName(e.target.value)} /> ) : ( <button type="button" className="flex-1 truncate text-left text-sm" onClick={() => { void (async () => { await loadMapIntoEditor(entry.id) openInEditor('map') })() }} > {entry.name || 'Untitled map'} </button> )} <Button type="button" variant="ghost" size="icon-sm" title="Delete map" className="shrink-0 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation() onDeleteMap(entry) }} > <X /> </Button> </li> ) })}
{/* The active map isn't in the library until it's been edited+saved. */} {mapUnsaved && ( <li className={cn(ROW, mapActive && 'border-ring bg-accent')}> <Input className="h-7 flex-1 text-sm" value={mapName} placeholder="Untitled map" onFocus={() => setEditorKind('map')} onChange={(e) => setMapName(e.target.value)} /> <span className="shrink-0 text-[0.7rem] text-muted-foreground">working</span> </li> )} </ul> </CollapsibleSection>
{/* ---- New / Load ---- */} <div className="flex gap-2"> <DropdownMenu> <DropdownMenuTrigger asChild> <Button type="button" variant="outline" size="sm" className="flex-1"> <Plus data-icon="inline-start" /> New <ChevronDown data-icon="inline-end" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start"> <DropdownMenuItem onSelect={() => onNew('tileset')}> <LayoutGrid /> Tileset </DropdownMenuItem> <DropdownMenuItem onSelect={() => onNew('map')}> <MapIcon /> Map </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu>
<AtprotoBrowser /> </div>
{error && ( <p className="text-xs text-destructive" title={error}> {error} </p> )}
<LoginDialog open={pendingNew != null && status === 'out'} onOpenChange={(next) => { if (!next) setPendingNew(null) }} reason="Log in to create a new document in your repo." /> </div> )}