Something went wrong. Try again.
An atproto Tilemap editor
Something went wrong. Try again.
9.9 kB · 257 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258import { useEffect, useRef, useState } from 'react'import { Copy, FilePlus2, Loader2, LogOut } from 'lucide-react'import { listRecords, type RepoRecord } from '../lib/atproto'import { EXTERNAL_IMAGE_TYPE, TILESET_NSID, type TilesetRecord } from '../lexicon/tileset'import { publishTileset } from '../lib/atprotoWrite'import { serializeTileset, saveActiveTileset } from '../store/persistence'import { useEditorStore } from '../store/editorStore'import { useAuthStore } from '../store/authStore'import HandleAutocomplete from './HandleAutocomplete'import { Avatar, AvatarFallback } from '@/components/ui/avatar'import { Button } from '@/components/ui/button'import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger,} from '@/components/ui/dialog'
type TilesetRepoRecord = RepoRecord<TilesetRecord>
/** "Save As": publish the active tileset to the user's atproto repo. Logged out, * it prompts for a handle and runs an OAuth popup login. Logged in, it lists the * user's existing tilesets so they can overwrite one, or create a new record. * Self-contained (owns its trigger button), mirroring {@link AtprotoBrowser}. */export default function SaveAsDialog() { const status = useAuthStore((s) => s.status) const did = useAuthStore((s) => s.did) const handle = useAuthStore((s) => s.handle) const agent = useAuthStore((s) => s.agent) const login = useAuthStore((s) => s.login) const logout = useAuthStore((s) => s.logout) const authError = useAuthStore((s) => s.error)
const tilesetName = useEditorStore((s) => s.tilesetName) const setSource = useEditorStore((s) => s.setSource)
const [open, setOpen] = useState(false) const [loginHandle, setLoginHandle] = useState('') const [loggingIn, setLoggingIn] = useState(false)
const [records, setRecords] = useState<TilesetRepoRecord[] | null>(null) const [listing, setListing] = useState(false) const [listError, setListError] = useState<string | null>(null)
/** rkey being written, '__new__' for a new record, or null when idle. */ const [publishing, setPublishing] = useState<string | null>(null) const [publishError, setPublishError] = useState<string | null>(null) const ctrlRef = useRef<AbortController | null>(null)
// Load the user's existing tilesets when the dialog is open and signed in. useEffect(() => { if (!open || status !== 'in' || !did) return const ctrl = new AbortController() ctrlRef.current = ctrl const run = async () => { setListing(true) setListError(null) setRecords(null) try { const res = await listRecords<TilesetRecord>(did, TILESET_NSID, ctrl.signal) if (!ctrl.signal.aborted) setRecords(res.records) } catch (err) { if (!ctrl.signal.aborted) setListError(err instanceof Error ? err.message : 'Could not list your tilesets.') } finally { if (!ctrl.signal.aborted) setListing(false) } } void run() return () => ctrl.abort() }, [open, status, did])
const onOpenChange = (next: boolean) => { setOpen(next) if (!next) { ctrlRef.current?.abort() setLoginHandle('') setRecords(null) setListError(null) setPublishError(null) setPublishing(null) } }
const doLogin = async (h: string) => { if (!h.trim()) return setLoggingIn(true) try { await login(h) } catch { // error surfaced via authError } finally { setLoggingIn(false) } }
/** Publish the active tileset: `rkey` overwrites, undefined creates a new record. */ const saveAs = async (rkey?: string) => { if (!agent || !did) return setPublishing(rkey ?? '__new__') setPublishError(null) try { const ts = serializeTileset() const res = await publishTileset({ agent, did, ts, rkey }) setSource({ did, rkey: res.rkey }) await saveActiveTileset() onOpenChange(false) } catch (err) { setPublishError(err instanceof Error ? err.message : 'Could not save the tileset.') } finally { setPublishing(null) } }
const busy = publishing !== null
return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogTrigger asChild> <Button type="button" variant="outline" size="sm" className="flex-1"> <Copy data-icon="inline-start" /> Save As </Button> </DialogTrigger> <DialogContent className="max-w-md"> <DialogHeader> <DialogTitle>Save to atproto</DialogTitle> <DialogDescription> Publish “{tilesetName || 'Untitled'}” to your repo as a new record, or overwrite one of your existing tilesets. </DialogDescription> </DialogHeader>
{status === 'loading' ? ( <p className="flex items-center gap-2 text-sm text-muted-foreground"> <Loader2 className="size-4 animate-spin" /> Checking your session… </p> ) : status !== 'in' ? ( // ---- logged out: handle + login ---- <div className="flex flex-col gap-2"> <p className="text-sm text-muted-foreground"> Log in with your atproto account to save. A window will open to your PDS. </p> <div className="flex gap-2"> <HandleAutocomplete autoFocus className="flex-1" disabled={loggingIn} value={loginHandle} onChange={setLoginHandle} onCommit={doLogin} /> <Button type="button" disabled={!loginHandle.trim() || loggingIn} onClick={() => doLogin(loginHandle)}> {loggingIn ? <Loader2 className="animate-spin" /> : 'Log in'} </Button> </div> {authError && <p className="text-xs text-destructive">{authError}</p>} </div> ) : ( // ---- logged in: identity + targets ---- <div className="flex flex-col gap-3"> <div className="flex items-center gap-2"> <Avatar className="size-7"> <AvatarFallback>{(handle || did || '?').slice(0, 1)}</AvatarFallback> </Avatar> <span className="min-w-0 flex-1 truncate text-sm">@{handle || did}</span> <Button type="button" variant="ghost" size="sm" className="text-muted-foreground" disabled={busy} onClick={() => void logout()} > <LogOut data-icon="inline-start" /> Sign out </Button> </div>
<Button type="button" className="w-full justify-start" disabled={busy} onClick={() => void saveAs()} > {publishing === '__new__' ? ( <Loader2 data-icon="inline-start" className="animate-spin" /> ) : ( <FilePlus2 data-icon="inline-start" /> )} Save as a new tileset </Button>
<div className="flex flex-col gap-1"> <span className="text-xs font-medium text-muted-foreground">Or overwrite</span> {listing ? ( <p className="flex items-center gap-2 text-sm text-muted-foreground"> <Loader2 className="size-4 animate-spin" /> Loading your tilesets… </p> ) : listError ? ( <p className="text-xs text-destructive">{listError}</p> ) : records && records.length === 0 ? ( <p className="text-sm text-muted-foreground">No tilesets in your repo yet.</p> ) : ( records && ( <ul className="flex max-h-64 list-none flex-col gap-1 overflow-y-auto p-0"> {records.map((rec) => { const name = (typeof rec.value?.name === 'string' && rec.value.name.trim()) || '(untitled)' const external = (rec.value?.image as { $type?: string } | undefined)?.$type === EXTERNAL_IMAGE_TYPE return ( <li key={rec.rkey} className="flex items-center gap-2 rounded-md border border-transparent px-1.5 py-1 hover:bg-accent" > <div className="min-w-0 flex-1"> <span className="block truncate text-sm">{name}</span> <span className="block truncate text-[0.7rem] text-muted-foreground"> {rec.rkey} {external && ' · external image'} </span> </div> <Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => void saveAs(rec.rkey)} > {publishing === rec.rkey ? ( <Loader2 className="animate-spin" /> ) : ( 'Overwrite' )} </Button> </li> ) })} </ul> ) )} </div>
{publishError && <p className="text-xs text-destructive">{publishError}</p>} </div> )} </DialogContent> </Dialog> )}