// Save Points: naming a place in the story, and going back to one. // // "Save Point" is the word throughout, and the words branch, head, node and // fork appear nowhere a player can read (`BROWSER-UX-SPEC.md` §23). The panel // is deliberately small — a form, a list, and two confirmations. It is not a // branch explorer, and the tree it sits over stays out of sight. // // Both confirmations exist to say what does *not* happen, because that is the // part a player cannot see and would otherwise assume the worst about: restore // keeps the later story, and deleting a Save Point deletes no story at all. import { useEffect, useState } from 'react' import { api } from '../../../api' function SavePointPanel({ advId, refreshKey, onRestored, onError }) { const [points, setPoints] = useState(null) const [failed, setFailed] = useState(null) const [name, setName] = useState('') const [note, setNote] = useState('') const [saving, setSaving] = useState(false) const [busyId, setBusyId] = useState(null) const [renaming, setRenaming] = useState(null) // { id, text } const [confirming, setConfirming] = useState(null) // { id, kind } const [tick, setTick] = useState(0) useEffect(() => { let cancelled = false setFailed(null) api.listCheckpoints(advId) .then((list) => { if (!cancelled) setPoints(list) }) .catch((err) => { if (!cancelled) setFailed(err.message) }) return () => { cancelled = true } }, [advId, refreshKey, tick]) // Answers whether it worked, so a caller can keep its editor open on a // refusal — a rename the server turned down must not take the typed name // with it. async function run(id, work) { setBusyId(id) try { await work() setTick((t) => t + 1) return true } catch (err) { onError(err.message) return false } finally { setBusyId(null) } } async function create(e) { e.preventDefault() if (!name.trim() || saving) return setSaving(true) try { await api.createCheckpoint(advId, name.trim(), note.trim()) setName('') setNote('') setTick((t) => t + 1) } catch (err) { onError(err.message) } finally { setSaving(false) } } const restore = (p) => run(p.id, async () => { onRestored(await api.restoreCheckpoint(advId, p.id)) setConfirming(null) }) const rename = async (p) => { if (await run(p.id, () => api.renameCheckpoint(advId, p.id, renaming.text))) { setRenaming(null) } } const remove = async (p) => { if (await run(p.id, () => api.deleteCheckpoint(advId, p.id))) setConfirming(null) } return (
setName(e.target.value)} /> setNote(e.target.value)} />

Saves the moment you are reading now. If you have stepped back, that is the moment it saves.

{failed &&
Couldn’t read the Save Points — {failed}
} {!failed && !points &&
Reading your Save Points…
} {!failed && points?.length === 0 && (

No Save Points yet. Name a moment you might want to come back to, then keep playing — going back to it later leaves everything you wrote after it in place.

)}
{(points || []).map((p) => { const isRenaming = renaming?.id === p.id const busy = busyId === p.id const confirm = confirming?.id === p.id ? confirming.kind : null return (
{isRenaming ? ( setRenaming({ ...renaming, text: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') rename(p) if (e.key === 'Escape') setRenaming(null) }} /> ) : ( {p.name} )}
Moment {p.turn} {/* Said plainly, without naming a branch: the story took a different turning after this point, and going back to it returns to the telling it was saved in. */} {!p.on_path && p.resolved && ' · on a path you left'} {!p.resolved && ' · this moment is no longer in the story'}
{p.note &&
{p.note}
} {confirm === 'restore' ? (
The story will return to this Save Point. Everything you wrote after it is kept — it just stops being where you are.
) : confirm === 'delete' ? (
Delete this Save Point? Deleting it does not delete any of the story — only the name you gave this moment.
) : (
{isRenaming ? ( <> ) : ( )}
)}
) })}
) } export { SavePointPanel }