diff --git a/apps/docs/docs/.vitepress/config.mts b/apps/docs/docs/.vitepress/config.mts index 690b28f..d969723 100644 --- a/apps/docs/docs/.vitepress/config.mts +++ b/apps/docs/docs/.vitepress/config.mts @@ -90,6 +90,10 @@ export default defineConfig({ items: [ { text: "Overview & features", link: "/web-app/overview" }, { text: "Pages & routing", link: "/web-app/pages-and-routing" }, + { + text: "Lessons on this device", + link: "/web-app/local-lessons", + }, { text: "Server rendering", link: "/web-app/server-rendering" }, { text: "Question blocks", link: "/web-app/question-blocks" }, { text: "AI text suggestions", link: "/web-app/ai-text-suggestions" }, diff --git a/apps/docs/docs/monorepo/lesson-images.md b/apps/docs/docs/monorepo/lesson-images.md index 45df3c5..1f3225a 100644 --- a/apps/docs/docs/monorepo/lesson-images.md +++ b/apps/docs/docs/monorepo/lesson-images.md @@ -54,7 +54,8 @@ curl -X POST https:///admin/migrate-images \ -d '{"cursor": 0, "limit": 25}' ``` -Local drafts migrate automatically on first load (old `localStorage` doc → IndexedDB). +Local lessons migrate automatically on first load (old `localStorage` doc → +IndexedDB → the [lesson library](/web-app/local-lessons)). Readers tolerate legacy base64 throughout, so the backfill can run any time after deploy. Deploy order: deploy the Worker (so `/images` exists) → ship the web build → run the backfill. diff --git a/apps/docs/docs/monorepo/version-history.md b/apps/docs/docs/monorepo/version-history.md index d107ac3..5fc7e16 100644 --- a/apps/docs/docs/monorepo/version-history.md +++ b/apps/docs/docs/monorepo/version-history.md @@ -119,6 +119,31 @@ The history view asks two questions about a selected version, because they have different answers the moment anything has happened since: _what changed here_ (history) and _difference from now_ (the decision you are about to make). +## One repository per lesson + +The browser holds **a repository per lesson**, not one for "the editor": + +``` +/lessons//.git bare — no working tree, no index +``` + +`repoId` is the lesson's hub id once it has one, and otherwise its id in this +device's [lesson library](/web-app/local-lessons) — which is what lets the editor +hold as many lessons as you make, each with a history of its own, and switch +between them by switching repositories. `repoIdFor(lessonId, localId)` is the one +place that decides. + +The id changes exactly once in a lesson's life: the first time it is saved to the +cloud, `adoptDraftRepo` copies `/lessons/` to `/lessons/` and +drops the original. The copy is a legitimate clone — git objects are immutable and +content-addressed, so every commit keeps its oid — and it is what stops an hour of +history built before publishing from being stranded under an id nothing points at +any more. From then on the repository follows the _lesson_: opening it on another +machine clones that history down rather than starting a new one. + +A repository is only ever read through `repoCtx(repoId)`, so nothing below this +line knows or cares which of the two kinds of id it was given. + ## More than one branch A lesson's repository holds a branch per **variation** — an alternative version of @@ -128,8 +153,9 @@ become. Which one is being edited is `HEAD`, a symbolic ref, exactly as in git. That is not just tidiness: `HEAD` lives inside the gitdir, so it survives the two places a -repository is copied wholesale — publishing a draft (`adoptDraftRepo`) and forking -a lesson locally (`copyRepo`) — neither of which knows branches exist. +repository is copied wholesale — publishing a local lesson (`adoptDraftRepo`) and +copying one into another (`copyRepo`, behind both "fork into a new lesson" and +"duplicate") — neither of which knows branches exist. Everything on this page is per branch as a result. A commit moves whatever `HEAD` points at; the history view reads the branch being edited unless given a ref; the @@ -359,10 +385,10 @@ than the bundler's env, which is what lets it sit on this side of the line. Browser-bound (`@spelling-creator/core/browser/git/*`) — framework-agnostic, but needs a real browser: -| Module | Purpose | -| ------ | ------------------------------------------------------------ | -| `fs` | LightningFS — the IndexedDB filesystem the repos live on. | -| `sync` | Fork (clone), merge, push, and both sides of a pull request. | +| Module | Purpose | +| ------ | ----------------------------------------------------------------------------------- | +| `fs` | LightningFS — the IndexedDB filesystem the repos live on, one directory per lesson. | +| `sync` | Fork (clone), merge, push, and both sides of a pull request. | Server-side (`apps/mcp/src/git.js`) — the fork-and-propose flow for an AI assistant, which is `browser/git/sync`'s two outbound steps built on `memfs` @@ -393,8 +419,8 @@ Worker: `apps/api/src/routes/git.js` and `apps/api/src/routes/pulls.js`, with th trusted-collaborator check in `apps/api/src/lib/lesson.js` (`isTrustedCollaborator`). -Repositories are **bare** — no working tree, no index. The editor's document -lives in React state and IndexedDB, so checked-out files would be dead weight; +Repositories are **bare** — no working tree, no index. The editor's documents +live in React state and IndexedDB, so checked-out files would be dead weight; everything goes straight through plumbing (`writeBlob` → `writeTree` → `writeCommit` → `writeRef`). diff --git a/apps/docs/docs/web-app/local-lessons.md b/apps/docs/docs/web-app/local-lessons.md new file mode 100644 index 0000000..2948b9a --- /dev/null +++ b/apps/docs/docs/web-app/local-lessons.md @@ -0,0 +1,117 @@ +--- +title: Lessons on this device +--- + +# Lessons on this device + +The editor holds **as many lessons as you make**. They live in this browser, in +IndexedDB, and you switch between them from the **Lessons** button in the editor's +top bar (or **On this device** in the sidebar, which opens the same panel). + +Nothing you are working on is ever replaced. That is the whole point of the +feature, and it is worth saying plainly, because it used to be the opposite: the +editor kept exactly **one** working document, so opening a lesson from the hub, +forking one, or importing a Word file all overwrote whatever was on screen — and +each of those flows needed a "Replace your current work?" dialog to warn you +first. Those dialogs are gone, because there is nothing left to replace. + +## The panel + +```text +Lessons on this device +───────────────────────────────────────────────────────── + Volcanoes ✓ ⋯ + 3 sections · 24 blocks · edited just now Published + + Volcanoes (copy) ⋯ + 3 sections · 24 blocks · edited 2 minutes ago + + Year 4 spellings ⋯ + 1 section · 6 blocks · edited yesterday Cloud draft +───────────────────────────────────────────────────────── + + New lesson Close +``` + +Clicking a row switches to it. The badge on the right says where else that lesson +exists — **Published** on the hub, or a private **Cloud draft**; a lesson with no +badge is on this device only. The `⋯` menu holds the three things you can do to a +lesson you are not currently in: + +| Action | What it does | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| **Rename** | Retitles it. Same as editing the title at the top of the editor — the title _is_ the lesson's name. | +| **Duplicate** | A full copy, including its [version history](/monorepo/version-history), unattached to the hub, titled "… (copy)". | +| **Delete from this device** | Removes the lesson, its document and its history. Asks twice, and cannot be undone. | + +## Where each lesson lives + +A lesson is three things. The first two are keyed by its id in this device's +library; the third is keyed by whichever id its repository currently answers to: + +| What | Where | +| ------------------- | ----------------------------------------------------------------------------------------------- | +| Its metadata | The `lessons` store, under the local id — title, block counts, hub attachment, last-edited time | +| Its document | The `lessonDocs` store, under that same local id | +| Its version history | A git repository of its own, at `/lessons//.git` in LightningFS | + +The split between the first two is what keeps the list cheap: showing you a +dozen titles reads a dozen small records, not a dozen whole lessons with their +images. + +`repoId` is that local id too, right up until the lesson is saved to the cloud — +at which point the repository moves under the hub's id for it and follows the +lesson to your other devices, while its metadata and document stay where they +are. `repoIdFor(lessonId, localId)` is the one place that decides. See +[Version history](/monorepo/version-history) for what that repository holds. + +## What each flow does now + +| You do this | What happens | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **New lesson** (sidebar) | Adds an empty lesson and opens it. Pressing it while already in an untouched one stays put. | +| **Edit** on one of your hub lessons | Opens the copy this device already has, exactly as you left it — never a second copy of the same lesson, and never overwritten by the cloud's. If the two differ, it tells you. | +| **Fork** a lesson from the hub | A new lesson, cloned with the original's history, titled "… (copy)". | +| **Fork into a new lesson** (in the editor) | The same, from the lesson you're in — which stays in the list, still attached to its hub row. | +| **Import** a Word or JSON file | A new lesson, with a history that starts at the import. | +| **Save to cloud** on a device-only lesson | Attaches it to the hub lesson it creates, and takes its history up with it. | + +## What this does not do + +These lessons are **local**. Nothing here syncs: another browser, another device +or another profile has its own library, and clearing your browsing data clears +it. Saving a lesson to the cloud — published or as a private draft — is what puts +a copy somewhere else, and is the only thing that does. The panel says so at the +bottom, for the same reason. + +Deleting a lesson that has been saved to the cloud removes only the local copy. +The hub keeps the lesson and its published history, and opening it for editing +again clones that history back down. + +Because the copy here is never replaced, it can drift from the cloud one — edits +made on this device and not saved, or edits saved from another. Saving to the +cloud is what settles that: the push refuses to overwrite a lesson that has moved +on since, and offers the same block-by-block merge everything else here uses. + +## Where this lives in the code + +| File | What it holds | +| ------------------------------------------- | -------------------------------------------------------------------------- | +| `packages/core/src/browser/storage.js` | The library API — list, get, create, save, delete, and the two migrations | +| `packages/core/src/browser/imageStore.js` | The IndexedDB stores themselves (`lessons`, `lessonDocs`, `images`, `app`) | +| `apps/web/src/components/LessonsDialog.jsx` | The panel above | +| `apps/web/src/pages/EditorPage.jsx` | Opening, creating, duplicating, deleting — and saving before it leaves one | + +## Upgrading from the single-document editor + +Two migrations run in order the first time the editor loads, and both are +idempotent: + +1. `migrateLocalStorage()` — the pre-IndexedDB draft (a `localStorage` document + with base64 images) moves into IndexedDB, images becoming binary blobs. +2. `migrateToLibrary()` — that single document becomes the library's first + lesson, keeping its title, its hub attachment and its fork origin. + +The migrated lesson is given the id `draft`, which is not arbitrary: `draft` is +the name the old working lesson's repository already has on disk, and a local +lesson's id _is_ its repo id, so the whole timeline carries across without a +single git object being copied. Lessons made after it get ordinary random ids. diff --git a/apps/docs/docs/web-app/overview.md b/apps/docs/docs/web-app/overview.md index 4f74eae..d89bf78 100644 --- a/apps/docs/docs/web-app/overview.md +++ b/apps/docs/docs/web-app/overview.md @@ -107,6 +107,11 @@ PDF printing. and merges your proposal (see [Pull requests](./pull-requests.md)). - **Auto-save** - your work is kept in IndexedDB between reloads (images as binary blobs, so large drafts aren't capped by `localStorage`'s ~5 MB quota). +- **As many lessons as you make** - the editor holds a whole library of them + locally, each with its own document and version history, and switches between + them from the **Lessons** button. Opening a lesson from the hub, forking one or + importing a document adds to that library rather than replacing what you were + working on (see [Lessons on this device](./local-lessons.md)). - **Installable, and works offline** - the app can be installed to a Home Screen or dock and opens in its own window; a service worker precaches the shell, so the editor, version history and DOCX/PDF export all keep working with no diff --git a/apps/docs/docs/web-app/pages-and-routing.md b/apps/docs/docs/web-app/pages-and-routing.md index 37abc06..85b426e 100644 --- a/apps/docs/docs/web-app/pages-and-routing.md +++ b/apps/docs/docs/web-app/pages-and-routing.md @@ -97,7 +97,9 @@ whether 256px of the screen is currently a sidebar or not. | -------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/` | **Home** | Landing page. Signed out: a marketing splash (animated floating words + feature blurbs). Signed in: a dashboard (latest-lessons feed, your activity, activity from people you follow, notifications). | | `/editor` | **Editor** | The lesson builder. Three panes — section outline, document, live preview — each appearing once the page column has room for it. | +| `/editor/lessons` | **Editor** | The [lessons this device holds](./local-lessons.md), over the editor — switch between them, copy, rename or delete one. | | `/editor/history` | **Editor** | The version-history panel, over the editor. | +| `/editor/variations` | **Editor** | The [variations](./lesson-variations.md) panel, over the editor. | | `/editor/collaborate` | **Editor** | The [live-collaboration](./live-collaboration.md) panel, over the editor. | | `/hub` | **Lesson hub** | Public gallery of published lessons (plus your own drafts), with search. | | `/hub/:id` | **Lesson → Lesson** | The lesson itself, with an "About" rail: author, ages, section count, fork lineage, and the print / Word / fork actions. | @@ -142,15 +144,21 @@ Two things deliberately did **not** become tabs: ## Query-string deep links -Two query strings deep-link into the editor rather than being routes of their -own: `?join=` opens the [live-collaboration](./live-collaboration.md) -panel on that invite, and `?pull=&lesson=` opens a -[proposed change](./pull-requests.md) for review once the lesson it names has -loaded — the lesson id is part of the link precisely so the review waits for the -right one, rather than acting on whatever the editor already had open. Both are -consumed once and then simply sit in the URL. Opening an editor panel preserves -them, so navigating to `/editor/collaborate` never drops the invite that sent -you there. +Four query strings deep-link into the editor rather than being routes of their +own: + +| Link | What it does | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `?join=` | Opens the [live-collaboration](./live-collaboration.md) panel on that invite. | +| `?pull=&lesson=` | Opens a [proposed change](./pull-requests.md) for review once the lesson it names has loaded — the lesson id is part of the link precisely so the review waits for the right one, rather than acting on whatever the editor already had open. | +| `?local=` | Switches to one of the [lessons on this device](./local-lessons.md). | +| `?new=1` | Starts a new lesson — what the sidebar's **New lesson** button links to, since plain `/editor` resumes whichever lesson you last had open. | + +The first two are consumed once and then simply sit in the URL; the last two are +stripped from it as they are read, because they are instructions rather than +state and a reload should not carry them out twice. Opening an editor panel +preserves the query string, so navigating to `/editor/collaborate` never drops +the invite that sent you there. ## Offline and the service worker diff --git a/apps/docs/docs/web-app/project-structure.md b/apps/docs/docs/web-app/project-structure.md index 3c740ba..2d0ccd5 100644 --- a/apps/docs/docs/web-app/project-structure.md +++ b/apps/docs/docs/web-app/project-structure.md @@ -12,7 +12,7 @@ src/ styles/globals.css Tailwind v4 + shadcn/ui design tokens (light/dark palettes, glass-surface shadows/blur), plus the `mb-safe` utility (see mobile-layout.md) locales/en/*.json one JSON file per i18next namespace (see internationalization.md) pages/ - EditorPage.jsx the lesson builder (toolbar, section list, + button, publish, collaborate) + EditorPage.jsx the lesson builder (toolbar, section list, + button, publish, collaborate) — and the owner of which of this device's lessons is open HubPage.jsx public gallery of published lessons + client-side search ProfilePage.jsx a user's public profile: bio + their published lessons LoginPage.jsx magic-link sign-in / account status @@ -60,6 +60,8 @@ src/ CollabCursors.jsx floating coloured carets showing collaborators' selections CollabChat.jsx in-session chat: a floating corner panel on desktop, a bottom sheet on mobile HistoryDialog.jsx the lesson's version timeline: what each commit changed, per block, + restore + LessonsDialog.jsx the lessons this device holds: switch, copy, rename, delete, start another (see local-lessons.md) + VariationsDialog.jsx a lesson's variations — the branches of its repository, as an author sees them (see lesson-variations.md) MergeDialog.jsx settle a merge — a fork's original, or a pull request being reviewed (mine / theirs / keep both) ProposeChangesDialog.jsx open a pull request against the lesson this fork came from (see pull-requests.md) PullRequestsSection.jsx proposed changes on a lesson's page, with review/merge and close for whoever may @@ -192,10 +194,10 @@ the MCP server cannot reach it by accident: ``` @spelling-creator/core/browser/ - imageStore IndexedDB storage for the working lesson + its images + imageStore the IndexedDB stores themselves: the lesson library, its documents, image blobs, editor flags imageRef binary image-ref model (a block references its bytes) imageFile read a File to bytes, measure it, opportunistically re-encode to WEBP - storage IndexedDB auto-save for the working lesson (+ the one-time localStorage migration) + storage the lesson library: every lesson this device holds, which one is open, and the two migrations into it docxExport build the .docx (text, images, questions) docxImport best-effort import of a .docx back into the lesson model pdfExport docx -> html (mammoth) -> pdf (html2pdf.js) — the only non-Word use of the Word pipeline diff --git a/apps/docs/docs/web-app/pwa-and-offline.md b/apps/docs/docs/web-app/pwa-and-offline.md index dbf2580..2eff3a8 100644 --- a/apps/docs/docs/web-app/pwa-and-offline.md +++ b/apps/docs/docs/web-app/pwa-and-offline.md @@ -9,8 +9,9 @@ Home Screen or a desktop dock, it opens in its own window with no browser chrome, and the editor keeps working with no network at all. Offline support is mostly something the app already had. Lessons live in -IndexedDB — the working document, its images as binary blobs, and the whole git -repository behind version history (see +IndexedDB — every lesson this device holds, their images as binary blobs, and a +git repository per lesson behind version history (see +[Lessons on this device](./local-lessons.md), [Version history](/monorepo/version-history) and [Lesson images](/monorepo/lesson-images)). What was missing was the other half: without a service worker the browser still has to fetch `index.html` and the JS @@ -19,14 +20,15 @@ the built shell closes that gap. ## What works offline, and what doesn't -| Works offline | Needs the network | -| ---------------------------------------------------------- | ---------------------------------------- | -| Opening the app at any client-side route | The lesson hub, profiles, comments | -| Writing, editing, reordering, deleting sections and blocks | Publishing / saving to the cloud | -| Images already in the local image store | Image search (Pixabay, Wikimedia) | -| Version history: commits, browsing, restoring | AI text / question / lesson-idea dialogs | -| DOCX export and PDF printing | Live collaboration | -| Lesson images seen before (cached by hash) | Sign-in, Save to Google Docs | +| Works offline | Needs the network | +| ---------------------------------------------------------- | ----------------------------------------- | +| Opening the app at any client-side route | The lesson hub, profiles, comments | +| Switching between the lessons on this device | Publishing a fork's changes as a proposal | +| Writing, editing, reordering, deleting sections and blocks | Publishing / saving to the cloud | +| Images already in the local image store | Image search (Pixabay, Wikimedia) | +| Version history: commits, browsing, restoring | AI text / question / lesson-idea dialogs | +| DOCX export and PDF printing | Live collaboration | +| Lesson images seen before (cached by hash) | Sign-in, Save to Google Docs | Everything in the right-hand column already degrades with a clear message when the feature is unconfigured (see [Getting started](./getting-started.md)); with diff --git a/apps/web/src/components/LessonsDialog.jsx b/apps/web/src/components/LessonsDialog.jsx new file mode 100644 index 0000000..727208e --- /dev/null +++ b/apps/web/src/components/LessonsDialog.jsx @@ -0,0 +1,369 @@ +// The lessons this device is holding, and the way between them. +// +// The editor used to have exactly one working document, so "open a lesson" and +// "throw away what you were doing" were the same act — hence the old "Replace +// your current work?" warning. IndexedDB has no reason to hold one lesson rather +// than fifty (see core/browser/storage.js), so it holds as many as are made and +// this is the list of them: switch, copy, rename, delete, start another. +// +// Every row is a whole lesson — its document, its images and its git repository +// — so the only destructive action here is deleting one, and that asks twice, +// the same way VariationsDialog does. Everything else is reversible by clicking +// a different row. + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + CheckIcon, + CloudIcon, + CloudUploadIcon, + CopyIcon, + PencilIcon, + PlusIcon, + Trash2Icon, + MoreHorizontalIcon, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./ui/dialog.jsx"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu.jsx"; +import { Alert, AlertDescription } from "./ui/alert.jsx"; +import { Badge } from "./ui/badge.jsx"; +import { Button } from "./ui/button.jsx"; +import { Input } from "./ui/input.jsx"; +import { ListRowsSkeleton } from "./Skeletons.jsx"; +import { cn } from "../lib/utils.js"; +import { timeAgo } from "./HistoryDialog.jsx"; + +/** + * @param {boolean} props.open Whether the panel is showing (it is a + * route — /editor/lessons — not local state). + * @param {Function} props.onClose Leave the panel, returning to the editor. + * @param {object} props.lessons The library, newest first — or null while + * it is being read (which draws a skeleton). + * @param {string} props.currentId The lesson open in the editor. + * @param {Function} props.onRefresh Re-read the library. + * @param {Function} props.onOpen Switch the editor to a lesson. + * @param {Function} props.onCreate Start a new, empty lesson. + * @param {Function} props.onDuplicate Copy a lesson into a new one. + * @param {Function} props.onDelete Remove a lesson from this device. + * @param {Function} props.onRename Retitle a lesson. + */ +export default function LessonsDialog({ + open, + onClose, + lessons, + currentId, + onRefresh, + onOpen, + onCreate, + onDuplicate, + onDelete, + onRename, +}) { + const { t } = useTranslation("editorTools"); + + // The lesson being retitled, and the title being typed. One at a time — two + // open name fields in a list is a puzzle rather than a feature. + const [naming, setNaming] = useState(null); + const [name, setName] = useState(""); + // The lesson awaiting a second press before it goes. Deleting one takes its + // document, its local images and its whole version history with it, and there + // is no undo for that here. + const [confirming, setConfirming] = useState(null); + const [busy, setBusy] = useState(null); + // Every action here optimistically closes its own bit of UI — the name field, + // the delete confirmation — before the write it triggered has finished. When + // one fails, that leaves a list that looks changed and isn't, so the failure is + // shown rather than swallowed (as VariationsDialog does with its own). + const [error, setError] = useState(null); + // Set by the two menu actions that replace this row's menu trigger with a + // control of their own — see the closing-focus note on DropdownMenuContent. + const replacesTriggerRef = useRef(false); + + // Re-read on open: the list is a snapshot, and this one may have been left + // open in another tab, or added to by a fork since it was last looked at. + useEffect(() => { + if (!open) return; + setNaming(null); + setName(""); + setConfirming(null); + setError(null); + onRefresh().catch((err) => setError(err?.message || String(err))); + }, [open, onRefresh]); + + const run = useCallback(async (id, work) => { + setBusy(id); + setError(null); + try { + await work(); + } catch (err) { + setError(err?.message || String(err)); + } finally { + setBusy(null); + } + }, []); + + const submitName = useCallback(async () => { + const title = name.trim(); + const id = naming; + setNaming(null); + setName(""); + if (!id || !title) return; + await run(id, () => onRename(id, title)); + }, [name, naming, onRename, run]); + + const handleOpen = useCallback( + async (id) => { + if (id === currentId) { + onClose(); + return; + } + await run(id, () => onOpen(id)); + onClose(); + }, + [currentId, onClose, onOpen, run], + ); + + return ( + !next && onClose()}> + + + {t("lessonsDialog.title")} + + {t("lessonsDialog.description")} + + + +
+ {lessons === null ? ( + + ) : ( +
    + {lessons.map((lesson) => { + const isCurrent = lesson.id === currentId; + const title = lesson.title || t("lessonsDialog.untitled"); + return ( +
  • + {naming === lesson.id ? ( + setName(e.target.value)} + onBlur={submitName} + onKeyDown={(e) => { + if (e.key === "Enter") submitName(); + if (e.key === "Escape") setNaming(null); + }} + aria-label={t("lessonsDialog.titleLabel")} + className="h-9" + /> + ) : ( + <> + + + {/* Where this lesson lives besides here. A lesson with + no badge exists on this device only — which is worth + knowing before clearing your browser data. */} + {lesson.lessonId && ( + + {lesson.published === false ? ( + + ) : ( + + )} + {lesson.published === false + ? t("lessonsDialog.cloudDraft") + : t("lessonsDialog.published")} + + )} + + {confirming === lesson.id ? ( +
    + + +
    + ) : ( + + + + + {/* Rename and Delete unmount this menu's trigger — + one swaps the row for a name field, the other for + a confirmation — so Radix's closing focus would + land on an element that no longer exists, dropping + the keyboard user out of the list to the body. + Decline it for those two and let the control that + replaced the trigger take focus itself. + Duplicate leaves the trigger where it is, so + Radix's own restoration is what's wanted there. */} + { + if (!replacesTriggerRef.current) return; + replacesTriggerRef.current = false; + e.preventDefault(); + }} + > + { + replacesTriggerRef.current = true; + setName(lesson.title || ""); + setNaming(lesson.id); + }} + > + + {t("lessonsDialog.rename")} + + + run(lesson.id, () => onDuplicate(lesson.id)) + } + > + + {t("lessonsDialog.duplicate")} + + { + replacesTriggerRef.current = true; + setConfirming(lesson.id); + }} + > + + {t("lessonsDialog.delete")} + + + + )} + + )} +
  • + ); + })} +
+ )} +
+ + {/* Deleting a lesson takes its history with it, and none of this is + backed up anywhere unless the lesson has been saved to the cloud. + Say so once, here, rather than in each row. */} + {error && ( + + {error} + + )} + +

+ {t("lessonsDialog.storageNote")} +

+ + + + + +
+
+ ); +} diff --git a/apps/web/src/components/layout/AppSidebar.jsx b/apps/web/src/components/layout/AppSidebar.jsx index cb59728..cb3a2ef 100644 --- a/apps/web/src/components/layout/AppSidebar.jsx +++ b/apps/web/src/components/layout/AppSidebar.jsx @@ -30,6 +30,7 @@ import { FileTextIcon, HouseIcon, IdCardIcon, + LibraryIcon, LogOutIcon, MoonIcon, PlusIcon, @@ -203,20 +204,38 @@ export default function AppSidebar() { {/* The one action rather than a destination, so it leads and carries the app's accent — this is a lesson-making tool - before it is a place to browse. */} + before it is a place to browse. + + `?new=1` rather than plain /editor, and it is the difference + between a button that means what it says and one that doesn't: + the editor holds a library of lessons now, so opening it + resumes whichever you last had open. This asks for another one. + (Pressing it while already in an empty lesson stays put rather + than stacking up untitled empties — see EditorPage.) */} - + {t("nav.newLesson")} + {/* Straight to the library panel, which is the list of what this + browser is holding. Deliberately a link to the panel rather + than the titles inline: the editor rewrites a lesson's title as + it is typed, and a copy of it in the sidebar would spend the + whole session one keystroke behind. */} + + { try { + noRepoRef.current = false; // Fetches the git chunk the first time the editor mounts (see load.js). const engine = await loadGitEngine(); const ctx = engine.repoCtx(repoId); - if (!(await engine.repoExists(repoId))) { - // A published lesson we don't have locally: bring its history down, so - // the timeline follows the lesson rather than the browser. - let cloned = false; + // Give this lesson a repository. A published one brings its history down + // first, so the timeline follows the lesson rather than the browser; + // anything else starts empty. + // + // fetchPack distinguishes the two failures that matter here, and this + // must not flatten them: it returns null when the lesson genuinely has no + // published history (a 404, or a pack with no tip), and *throws* when the + // hub couldn't be reached at all. Only the first means "start empty". If a + // dropped connection were read that way, the empty repository would take a + // baseline commit of the current document, and every later open would find + // a repository that exists with a readable head and leave it alone — the + // published timeline never cloned, and the local one diverged from the hub + // for good. So a throw propagates: setup fails, no repository is left + // behind, and the next mount tries the clone again. + const createRepo = async () => { if (editingId) { - const pack = await fetchPack(editingId).catch(() => null); + const pack = await fetchPack(editingId); if (pack) { await engine.cloneFromPack({ ...ctx, ...pack }); - cloned = true; + return; + } + } + await engine.ensureRepo(ctx); + }; + + if (!(await engine.repoExists(repoId))) { + await createRepo(); + } else { + // A repository we already hold, which we are about to read the whole + // timeline out of — so check first that we actually can. + // + // A browser closed (or reloaded) in the middle of a commit can leave + // the branch pointing at a commit whose object never made it to disk: + // LightningFS persists file contents and its directory index + // separately, so a torn write is possible however careful the code + // above it is. Every later read then throws on the same missing + // object, and the lesson is stuck with no history at all rather than a + // shortened one. Starting the repository again costs the old timeline, + // which was already unreadable; the *document* is never at stake, + // since it lives in the library, not in here. + // + // Rebuilt through the same createRepo as a lesson we never had, which + // is what makes a published lesson whole again rather than merely + // working: its history is on the hub, so the replacement is a clone of + // it. An empty repository would look established enough for every + // later open to leave alone, stranding the published timeline for good. + const existingHead = await engine.headOid(ctx).catch(() => null); + if (existingHead) { + const readable = await engine + .readDocAt({ ...ctx, oid: existingHead }) + .then(() => true) + .catch(() => false); + if (!readable) { + await engine.deleteRepo(repoId); + await createRepo(); } } - if (!cloned) await engine.ensureRepo(ctx); } // Seed a baseline so the first real edit has something to diff against. @@ -133,6 +192,10 @@ export function useLessonGit({ doc, editingId, identity, enabled = true }) { setReady(true); } catch (err) { console.error("[lesson-git] setup failed", err); + // Nothing usable was left on disk, and commitNow must not put anything + // there either: a stray object store would be indistinguishable from a + // real repository next time, and the clone would never be retried. + noRepoRef.current = true; if (!cancelled) { setError(err.message || "Version history is unavailable."); setReady(false); @@ -143,39 +206,18 @@ export function useLessonGit({ doc, editingId, identity, enabled = true }) { return () => { cancelled = true; }; - }, [repoId, editingId, enabled, generation, run]); - - /** Re-open the repository — after a fork has swapped a new one into place. */ - const reload = useCallback(() => { - dirtySince.current = null; - setGeneration((n) => n + 1); - }, []); - - /** - * Throw the current repository away and start a fresh one. Used when the doc is - * replaced by something with no relationship to it (a Word/JSON import): that - * lesson's history is not this lesson's history, and grafting the two would be - * a lie about where the content came from. - */ - const discard = useCallback( - () => - run(async () => { - const engine = await loadGitEngine(); - await engine.deleteRepo(repoId); - dirtySince.current = null; - setLastCommit(null); - setPending(0); - setGeneration((n) => n + 1); - }), - [repoId, run], - ); + // Every one of the flows that swaps a repository out — a fork, an import, a + // duplicate — now does it by creating a *new* lesson, which changes `repoId` + // and re-runs this effect on its own. There is nothing left that replaces a + // repository under its own id, so there is no "reload" to ask for. + }, [repoId, editingId, enabled, run]); // ---- committing ---------------------------------------------------------- const commitNow = useCallback( () => run(async () => { const current = docRef.current; - if (!worthCommitting(current)) return null; + if (!worthCommitting(current) || noRepoRef.current) return null; try { const engine = await loadGitEngine(); @@ -452,16 +494,18 @@ export function useLessonGit({ doc, editingId, identity, enabled = true }) { const adoptDraft = useCallback( (lessonId) => run(async () => { - if (!lessonId || repoId !== DRAFT_REPO) return; + // Nothing to adopt once the repo is already the lesson's own — which is + // the case for every save after the first. + if (!lessonId || repoId === lessonId) return; const engine = await loadGitEngine(); await engine .commitDoc({ - ...engine.repoCtx(DRAFT_REPO), + ...engine.repoCtx(repoId), doc: docRef.current, author: identityRef.current, }) .catch(() => null); - await engine.adoptDraftRepo(lessonId); + await engine.adoptDraftRepo(lessonId, repoId); }), [repoId, run], ); @@ -487,8 +531,6 @@ export function useLessonGit({ doc, editingId, identity, enabled = true }) { diffAgainstCurrent, restore, adoptDraft, - reload, - discard, /** Queue work against this lesson's repo (used by the fork/merge flows). */ run, }; diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json index f54b43f..98421c0 100644 --- a/apps/web/src/locales/en/common.json +++ b/apps/web/src/locales/en/common.json @@ -25,6 +25,7 @@ "appTagline": "Make and share lessons", "home": "Home", "newLesson": "New lesson", + "onThisDevice": "On this device", "yourLessons": "Your lessons", "noLessonsYet": "No lessons yet.", "untitledLesson": "Untitled lesson", diff --git a/apps/web/src/locales/en/editor.json b/apps/web/src/locales/en/editor.json index d59fc75..7f29edc 100644 --- a/apps/web/src/locales/en/editor.json +++ b/apps/web/src/locales/en/editor.json @@ -3,6 +3,8 @@ "title": "Spelling Lesson Maker", "menuAriaLabel": "menu", "menuTooltip": "Menu", + "lessons": "Lessons", + "lessonsTooltip": "Every lesson on this device. Switch between them, copy one, or start another — nothing you’re working on is replaced.", "importWord": "Import Word document", "importJson": "Import JSON", "github": "GitHub", @@ -36,7 +38,7 @@ "editingPublishedTooltip": "You're editing a lesson you published. Saving to the cloud overwrites it in the hub. This status is saved until you update it or fork into a new lesson.", "editingDraftTooltip": "You're editing a draft backed up to the cloud. Only you can see it; saving updates the backup, or you can publish it to the hub. This status is saved until you change it or fork into a new lesson.", "forkButton": "Fork into a new lesson", - "forkTooltip": "Detach from this saved lesson and start a new one. The new lesson keeps this one’s history, so you can merge them later. Saving to the cloud will create a separate copy instead of overwriting the original.", + "forkTooltip": "Start a new lesson from this one and carry on in it. The new lesson keeps this one’s history, so it can be merged back later — and this one stays on your device.", "syncChecking": "Checking...", "syncWith": "Sync with {{name}}", "syncTooltip": "Bring in the changes made to {{name}} since you forked it. Edits to different blocks — or to different parts of the same block — merge automatically; anything genuinely clashing is put to you.", @@ -85,22 +87,11 @@ "title": "Preview", "close": "Close" }, - "overwriteDialog": { - "title": "Replace your current work?", - "thisLesson": "this lesson", - "forkBody": "Forking {{title}} will replace the lesson you’re working on now. Your in-progress work is auto-saved in this browser, and replacing it can’t be undone.", - "importBody": "Importing {{title}} will replace the lesson you’re working on now. Your in-progress work is auto-saved in this browser, and replacing it can’t be undone.", - "editBody": "Opening {{title}} for editing will replace the lesson you’re working on now. Your in-progress work is auto-saved in this browser, and replacing it can’t be undone.", - "keepMyWork": "Keep my work", - "replaceAndFork": "Replace and fork", - "replaceAndImport": "Replace and import", - "replaceAndEdit": "Replace and edit" - }, "wordImportWarning": { "title": "Import a Word document", "body1": "Importing a <0>.docx file is <1>best-effort and lossy. It works best with documents exported from this app; files written elsewhere may import poorly or not at all.", "body2": "For the import to work, the document must use <0>Heading 2 styles for its section headings. Images, colours, and exact formatting may be lost.", - "body3": "If the document isn’t structured as a lesson, it won’t be opened. Your current work is replaced only after you confirm.", + "body3": "If the document isn’t structured as a lesson, it won’t be opened. Either way the lesson you’re working on is left alone: an import opens as a new lesson of its own.", "cancel": "Cancel", "chooseFile": "Choose file" }, @@ -126,7 +117,8 @@ "publishedToHub": "Lesson published to the hub.", "draftSaved": "Draft saved to the cloud — only you can see it.", "couldNotSave": "Could not save: {{error}}", - "forkedNoUpstream": "Forked into a new lesson — saving to the cloud will create a separate copy.", + "libraryUnavailable": "This browser wouldn’t let us open the lessons stored on this device — another tab may be using an older version of the app. You can keep working, but nothing will be saved here until you reload.", + "forkedNoUpstream": "Forked into a new lesson — saving to the cloud will create a separate copy. The lesson you forked is still on this device.", "noSharedHistoryToMerge": "The original lesson has no shared history to merge — it may have been published before version history, or deleted.", "alreadyUpToDate": "Already up to date with the original.", "couldNotMerge": "Could not merge: {{error}}", @@ -147,6 +139,7 @@ "forkedWithoutHistory": "Forked into a new lesson — edit freely, then save it to the cloud as your own copy.", "loadedPublished": "Loaded your published lesson — edit and save to the cloud to update it.", "loadedDraft": "Loaded your draft — edit and save to the cloud, or publish it to the hub.", + "loadedLocalCopyDiffers": "Opened your copy of this lesson from this device — it isn’t identical to the one in the cloud. Saving to the cloud settles that, and asks you about anything that clashes.", "couldNotOpenLesson": "Could not open that lesson for {{action}}.", "variationBroughtIn": "“{{name}}” is now part of the main lesson.", "variationAlreadyIn": "“{{name}}” is already part of the main lesson.", diff --git a/apps/web/src/locales/en/editorTools.json b/apps/web/src/locales/en/editorTools.json index 2257a3b..d449157 100644 --- a/apps/web/src/locales/en/editorTools.json +++ b/apps/web/src/locales/en/editorTools.json @@ -168,5 +168,28 @@ "confirmDelete": "Delete “{{name}}”? Anything only in this variation goes with it.", "confirmDeleteYes": "Delete it", "nameLabel": "Name for this variation" + }, + "lessonsDialog": { + "title": "Lessons on this device", + "description": "Everything you’re working on, kept in this browser. Open one to switch to it — nothing is overwritten.", + "untitled": "Untitled lesson", + "sectionsCount_one": "{{count}} section", + "sectionsCount_other": "{{count}} sections", + "blocksCount_one": "{{count}} block", + "blocksCount_other": "{{count}} blocks", + "stats": "{{sections}} · {{blocks}}", + "edited": "edited {{time}}", + "published": "Published", + "cloudDraft": "Cloud draft", + "titleLabel": "Lesson title", + "rowActions": "actions for {{title}}", + "rename": "Rename", + "duplicate": "Duplicate", + "delete": "Delete from this device", + "confirmDelete": "Delete forever", + "keepIt": "Keep it", + "storageNote": "These lessons live in this browser. Deleting one takes its version history with it, and clearing your browsing data removes them all — save a lesson to the cloud to keep a copy off this device.", + "newLesson": "New lesson", + "close": "Close" } } diff --git a/apps/web/src/pages/EditorPage.jsx b/apps/web/src/pages/EditorPage.jsx index 668e796..7e630c6 100644 --- a/apps/web/src/pages/EditorPage.jsx +++ b/apps/web/src/pages/EditorPage.jsx @@ -23,6 +23,7 @@ import { GitMergeIcon, GitPullRequestIcon, HistoryIcon, + LibraryIcon, PlusIcon, PrinterIcon, SaveIcon, @@ -75,6 +76,7 @@ import FirstLessonWizard from "../components/FirstLessonWizard.jsx"; import AiLessonIdeaDialog from "../components/AiLessonIdeaDialog.jsx"; import HistoryDialog, { timeAgo } from "../components/HistoryDialog.jsx"; import VariationsDialog from "../components/VariationsDialog.jsx"; +import LessonsDialog from "../components/LessonsDialog.jsx"; import MergeDialog from "../components/MergeDialog.jsx"; import ProposeChangesDialog from "../components/ProposeChangesDialog.jsx"; // The preview dialog renders the working doc with the very same read-only @@ -90,23 +92,25 @@ import { toBranchName, } from "@spelling-creator/core/git/refs"; import { diffDocs } from "@spelling-creator/core/git/ops"; +import { repoIdFor } from "@spelling-creator/core/git/doc"; // The git engine (isomorphic-git + LightningFS) is loaded on demand rather than // imported directly, so it stays out of the bundle every homepage and hub visitor // downloads. loadGitEngine() memoises the import; by the time any of these flows // runs, useLessonGit has already fetched the chunk. import { loadGitEngine } from "../lib/git/load.js"; import { - loadDocument, - saveDocument, - loadEditingId, - saveEditingId, - loadEditingPublished, - saveEditingPublished, - loadForkedFrom, - saveForkedFrom, + listLessons, + getLesson, + createLesson, + saveLessonDoc, + saveLessonMeta, + deleteLesson, + getCurrentLessonId, + setCurrentLessonId, loadWizardSeen, saveWizardSeen, migrateLocalStorage, + migrateToLibrary, } from "@spelling-creator/core/browser/storage"; import { convertDocImages } from "@spelling-creator/core/browser/imageRef"; import { ensureImagesUploaded } from "@spelling-creator/core/imagesClient"; @@ -177,14 +181,6 @@ function createInitialDoc(t) { return { title: t("defaultDoc.title"), sections: [] }; } -// Whether a document holds work worth protecting from being clobbered. The -// starter doc has no sections; once the user has added one, replacing the doc -// (e.g. by opening a published lesson to edit) is destructive and warrants a -// warning. -function docHasContent(d) { - return Boolean(d && Array.isArray(d.sections) && d.sections.length > 0); -} - // Apply a finished block drag to the document: pull the dragged block out of the // section it came from and slot it into the section it was dropped on, before or // after the block the insertion line was showing. The two sections are often the @@ -250,20 +246,26 @@ export default function EditorPage() { // lossless round-trip of our own model. Its own hidden picker. const jsonInputRef = useRef(null); + // Which of this device's lessons is open. Every lesson in the library + // (core/browser/storage.js) has one of these ids, and it is also the name of + // the lesson's git repository until it is published and takes the hub's id + // instead — so `localId` is what makes switching lessons switch documents and + // histories together. `localLessons` is the library itself, read for the + // lessons panel and refreshed whenever one is added or removed; null until + // it has been read once. + const [localId, setLocalId] = useState(null); + const [localLessons, setLocalLessons] = useState(null); + // Hub-editing state. `editingId` is the id of a published lesson currently // loaded for editing (so "Publish" becomes "Update"); null when authoring a - // fresh lesson. It's persisted to localStorage (see effect below) so the - // status survives reloads and tab closes until the user overwrites it (by - // opening another published lesson) or forks into a new lesson. - // `pendingEdit` holds a fetched lesson awaiting the user's confirmation to - // overwrite their in-progress work; `editLoading` covers the fetch of the - // lesson to edit. + // fresh lesson. It's stored on the library record (see effect below) so the + // status survives reloads and tab closes until the user forks into a new + // lesson. `editLoading` covers the fetch of a lesson to edit. const [editingId, setEditingId] = useState(null); // Whether the lesson loaded for editing is published to the hub or a private // draft. Only meaningful when `editingId` is set; it tunes the "Save to cloud" // actions and the status chip. Persisted so it survives reloads. const [editingPublished, setEditingPublished] = useState(true); - const [pendingEdit, setPendingEdit] = useState(null); // { id, title, doc, published } | null const [editLoading, setEditLoading] = useState(false); // Version control. The lesson is kept in a real git repository in the browser, @@ -331,6 +333,7 @@ export default function EditorPage() { const historyOpen = panel === "history"; const collabOpen = panel === "collaborate"; const variationsOpen = panel === "variations"; + const lessonsOpen = panel === "lessons"; // // Opening pushes; closing *replaces*. Both pushing would leave the history as // [/editor, /editor/history, /editor], so Back from a panel you had just @@ -408,42 +411,99 @@ export default function EditorPage() { // version control never commits the empty starter doc over a real draft's // history before that draft has loaded. const [hydrated, setHydrated] = useState(false); - const git = useLessonGit({ doc, editingId, identity, enabled: hydrated }); + const git = useLessonGit({ + doc, + editingId, + localId, + identity, + enabled: hydrated, + }); // First-lesson wizard. Auto-shows once for newcomers (tracked by a // localStorage flag); dismissing it sets the flag so it won't reappear. The // help button reopens it on demand without touching the flag. const [wizardOpen, setWizardOpen] = useState(false); + // The document as it was last written to storage. Compared by identity, so + // opening a lesson doesn't immediately save the very document it just read + // (which would restamp its "edited" time and reorder the library for a lesson + // nobody has touched). Every edit makes a new object, so anything the user + // actually does compares unequal. + const savedDocRef = useRef(null); + + // Take a lesson out of the library and into the editor. The whole of the + // editor's per-lesson state changes together — document, hub attachment, + // publish status, fork origin — and `localId` changing swaps the git + // repository under useLessonGit as well. + const adoptRecord = useCallback((record) => { + const next = record.doc || { title: "", sections: [] }; + setLocalId(record.id); + setDoc(next); + savedDocRef.current = next; + setEditingId(record.lessonId || null); + setEditingPublished(record.published !== false); + setForkedFrom(record.forkedFrom || null); + setCurrentLessonId(record.id); + }, []); + // Editor state lives in IndexedDB now (async), so we hydrate it on mount // rather than synchronously at useState time. `hydrated` gates the persistence - // effects below so they don't write the empty starter doc over a saved draft - // before it loads, and defers the hub edit/fork request until we know whether - // there's in-progress work to protect. migrateLocalStorage() first moves any - // pre-IndexedDB draft across (a one-time, idempotent no-op afterwards). + // effects below so they don't write the empty starter doc over a saved lesson + // before it loads, and defers the hub edit/fork request until the library is + // there to put the lesson into. The two migrations run first, in order: the + // pre-IndexedDB draft moves into IndexedDB, then the single working document + // becomes the library's first lesson. Both are idempotent no-ops afterwards. + // Guarded by a ref rather than by a cancellation flag, and the difference + // matters here. This effect *writes*: a device with an empty library has its + // first lesson made for it, so there is always one open. StrictMode invokes + // the effect twice in development, and two runs racing to discover an empty + // library would each create a lesson and leave an untitled twin behind — while + // the usual "cancelled" cleanup would abandon the first run's work after the + // second had already been told not to start. A ref survives the double-invoke + // (the instance is reused), so exactly one run happens and it finishes; a real + // remount gets a fresh ref, and hydrates again as it should. + const hydrateRef = useRef(false); useEffect(() => { - let cancelled = false; + if (hydrateRef.current) return; + hydrateRef.current = true; (async () => { - await migrateLocalStorage(); - const [savedDoc, savedEditingId, savedPublished, savedFork, seen] = - await Promise.all([ - loadDocument(), - loadEditingId(), - loadEditingPublished(), - loadForkedFrom(), + try { + await migrateLocalStorage(); + await migrateToLibrary(); + const [currentId, seen] = await Promise.all([ + getCurrentLessonId(), loadWizardSeen(), ]); - if (cancelled) return; - if (savedDoc) setDoc(savedDoc); - if (savedEditingId) setEditingId(savedEditingId); - if (savedFork) setForkedFrom(savedFork); - setEditingPublished(savedPublished); - if (!seen) setWizardOpen(true); - setHydrated(true); + // A device with a library but no current lesson (its last one was + // deleted in another tab) opens the most recent. + let record = currentId ? await getLesson(currentId) : null; + if (!record) { + const [newest] = await listLessons(); + record = newest ? await getLesson(newest.id) : null; + } + if (!record) record = await createLesson({ doc: createInitialDoc(t) }); + adoptRecord(record); + if (!seen) setWizardOpen(true); + } catch (err) { + // Storage we can't reach at all: private mode, an exhausted quota, or — + // reachable for the first time in this version — a v1 → v2 upgrade + // blocked by another tab still holding the old connection open. The + // editor is still a perfectly good editor without a library, so say so + // and carry on in memory rather than leaving the page on its skeleton + // for ever: `hydrated` gates every persistence effect *and* the section + // list, and the one-shot guard above means nothing would retry. + console.error("[lessons] could not open this device's library", err); + notify({ + severity: "error", + message: t("messages.libraryUnavailable"), + }); + } finally { + setHydrated(true); + } })(); - return () => { - cancelled = true; - }; + // Mount-only: `t` and adoptRecord are stable enough that re-hydrating on a + // language change would only throw away in-progress work. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // The lesson this one was forked from: its name (for the sync and propose @@ -514,43 +574,249 @@ export default function EditorPage() { editingIdRef.current = editingId; }, [editingId]); - // Persist the working doc to IndexedDB, debounced: typing into a large lesson + // Persist the open lesson's document, debounced: typing into a large lesson // shouldn't rewrite the whole document on every keystroke (the synchronous // write janks low-end machines). We save ~600ms after edits pause, and flush a // pending save on unmount so the last keystrokes aren't lost. + // + // The pending save carries the lesson id it belongs to, not just the document: + // switching lessons changes both at once, and a save that outlived its lesson + // would write one lesson's text into another's record. const pendingSaveRef = useRef(null); useEffect(() => { - if (!hydrated) return; - pendingSaveRef.current = doc; - const id = setTimeout(() => { + if (!hydrated || !localId) return; + // Just opened, and unedited since — nothing has changed to write. + if (doc === savedDocRef.current) return; + pendingSaveRef.current = { id: localId, doc }; + const timer = setTimeout(() => { pendingSaveRef.current = null; - saveDocument(doc); + savedDocRef.current = doc; + saveLessonDoc(localId, doc); }, 600); - return () => clearTimeout(id); - }, [doc, hydrated]); + return () => clearTimeout(timer); + }, [doc, localId, hydrated]); useEffect( () => () => { - if (pendingSaveRef.current) saveDocument(pendingSaveRef.current); + const pending = pendingSaveRef.current; + if (pending) saveLessonDoc(pending.id, pending.doc); }, [], ); - // Persist the editing-published status so it survives reloads/tab closes. + // Persist where this lesson lives besides here: the hub lesson it's attached + // to, whether that lesson is published or a private draft, and the lesson it + // was forked from (so the link home survives a reload and the fork can still + // be synced with its original days later). All three belong to the library + // record rather than the document, so they travel with the lesson when you + // switch to another and back. useEffect(() => { - if (hydrated) saveEditingId(editingId); - }, [editingId, hydrated]); + if (!hydrated || !localId) return; + saveLessonMeta(localId, { + lessonId: editingId, + published: editingId ? editingPublished : true, + forkedFrom, + }); + }, [localId, editingId, editingPublished, forkedFrom, hydrated]); - // Persist whether the edited lesson is published or a draft. Clear it when no - // lesson is attached, so a fresh document defaults back to "publish". - useEffect(() => { - if (hydrated) saveEditingPublished(editingId ? editingPublished : null); - }, [editingId, editingPublished, hydrated]); + // ---- the library --------------------------------------------------------- + // + // Everything below moves the editor between lessons. There is one rule they + // all obey: whatever is on screen is written down *before* it is replaced. + // That is the whole reason none of these has to ask permission first — the + // editor used to hold a single working document, so opening anything meant + // destroying what you had, and three flows (edit, fork, import) each needed a + // "Replace your current work?" dialog to guard it. A lesson you leave is a + // lesson still in the list. + + const refreshLocalLessons = useCallback(async () => { + setLocalLessons(await listLessons()); + }, []); + + // commitNow keeps a stable identity (it is keyed to the repository), unlike + // the `git` object, which is rebuilt every render. + const commitNow = git.commitNow; + + // Get the open lesson fully onto disk: the debounced document save, then a + // version-control checkpoint. Both are skipped when there's nothing new — + // committing an unchanged document is already a no-op (see repo.js). + const flushCurrentLesson = useCallback(async () => { + if (!localId) return; + pendingSaveRef.current = null; + const current = docRef.current; + if (current !== savedDocRef.current) { + savedDocRef.current = current; + await saveLessonDoc(localId, current); + } + await commitNow(); + }, [commitNow, localId]); + + // Which open request is the live one. Opening a lesson saves and commits the + // one being left before it reads the next, so it is several awaits long, and + // two of them in flight can finish in either order — a slower first click + // would otherwise land last and put the editor in a lesson the user has + // already moved on from. Only the newest request may adopt anything. + const openRequestRef = useRef(0); + const openLocalLesson = useCallback( + async (id) => { + if (!id || id === localId) return; + const request = ++openRequestRef.current; + await flushCurrentLesson(); + const record = await getLesson(id); + if (request !== openRequestRef.current) return; + if (!record) { + // Deleted in another tab, most likely. Re-read rather than insist. + await refreshLocalLessons(); + return; + } + adoptRecord(record); + }, + [adoptRecord, flushCurrentLesson, localId, refreshLocalLessons], + ); + + const startNewLesson = useCallback(async () => { + // Already in an untouched lesson? That *is* the new lesson. Making another + // would leave a trail of untitled empties behind every time someone pressed + // the button twice. "Untouched" means no sections, no hub lesson behind it, + // and the title still exactly as the editor wrote it — a lesson somebody has + // named is one they have started, however empty it still looks. + const current = docRef.current; + const untouched = + !editingIdRef.current && + (current?.sections?.length ?? 0) === 0 && + (!current?.title || current.title === t("defaultDoc.title")); + if (untouched) return null; + const request = ++openRequestRef.current; + await flushCurrentLesson(); + const record = await createLesson({ doc: createInitialDoc(t) }); + if (request !== openRequestRef.current) return record; + adoptRecord(record); + await refreshLocalLessons(); + return record; + }, [adoptRecord, flushCurrentLesson, refreshLocalLessons, t]); + + const duplicateLocalLesson = useCallback( + async (id) => { + if (id === localId) await flushCurrentLesson(); + const source = await getLesson(id); + if (!source) return null; + + const doc = { + ...(source.doc || createInitialDoc(t)), + title: t("labels.copyOf", { + title: source.doc?.title || t("labels.untitledLesson"), + }), + }; + // Unattached on purpose: a copy is a lesson of its own, so saving it to + // the cloud creates a separate one rather than overwriting what it was + // copied from — while remembering what that was, so the two can still be + // merged later. + const record = await createLesson({ + doc, + forkedFrom: source.lessonId || source.forkedFrom || null, + }); + try { + // A real clone of the repository, not just of the text: the copy keeps + // the original's history and shares its commit oids. + const engine = await loadGitEngine(); + await engine.forkLocalRepo( + repoIdFor(source.lessonId, source.id), + record.id, + ); + } catch { + /* no history to carry over — the copy starts a fresh one */ + } + await refreshLocalLessons(); + return record; + }, + [flushCurrentLesson, localId, refreshLocalLessons, t], + ); + + const removeLocalLesson = useCallback( + async (id) => { + // Drop any save still in flight for it, so nothing recreates what we are + // about to delete. + if (pendingSaveRef.current?.id === id) pendingSaveRef.current = null; + const record = await getLesson(id); + await deleteLesson(id); + try { + const engine = await loadGitEngine(); + // The local repository only. A lesson that reached the cloud keeps its + // history there, and the lesson page clones it back on demand. + // + // Both possible names for it: a published lesson's repository lives + // under its hub id, but one left under the lesson's own id — by an + // adoption that found the destination already taken and returned rather + // than merge two histories — would otherwise be unreachable for ever, + // since nothing else ever looks there again. + await engine.deleteRepo(repoIdFor(record?.lessonId, id)); + if (record?.lessonId) await engine.deleteRepo(id); + } catch { + /* the repo may never have existed */ + } - // Persist the lesson this one was forked from, so the link home survives a - // reload and the fork can still be synced with its original days later. + if (id === localId) { + const request = ++openRequestRef.current; + const remaining = (await listLessons()).filter((l) => l.id !== id); + const next = remaining[0] + ? await getLesson(remaining[0].id) + : await createLesson({ doc: createInitialDoc(t) }); + if (request === openRequestRef.current) adoptRecord(next); + } + await refreshLocalLessons(); + }, + [adoptRecord, localId, refreshLocalLessons, t], + ); + + const renameLocalLesson = useCallback( + async (id, title) => { + if (id === localId) { + // Written through rather than left to the debounce, because the list is + // re-read the moment this returns and would otherwise show the old title + // until the panel was closed and opened again. The same object goes into + // React state and into storage, so `savedDocRef` matching it keeps the + // debounce from writing the identical document a second time. + const next = { ...docRef.current, title }; + setDoc(next); + savedDocRef.current = next; + await saveLessonDoc(id, next); + } else { + const record = await getLesson(id); + if (record?.doc) await saveLessonDoc(id, { ...record.doc, title }); + } + await refreshLocalLessons(); + }, + [localId, refreshLocalLessons], + ); + + // Deep links into the library: the sidebar lists the lessons on this device + // and links here with ?local=, and its "New lesson" button with ?new=1. + // The editor is already mounted when either is followed from another page, so + // a param is what carries the intent across; it's stripped as soon as it's + // read, which is also what stops this from firing twice. + const localParam = searchParams.get("local"); + const newParam = searchParams.get("new"); useEffect(() => { - if (hydrated) saveForkedFrom(forkedFrom); - }, [forkedFrom, hydrated]); + if (!hydrated || (!localParam && !newParam)) return; + const params = new URLSearchParams(location.search); + params.delete("local"); + params.delete("new"); + const search = params.toString(); + navigate( + { pathname: location.pathname, search: search ? `?${search}` : "" }, + { replace: true }, + ); + if (newParam) startNewLesson(); + else openLocalLesson(localParam); + }, [ + hydrated, + localParam, + newParam, + location.pathname, + location.search, + navigate, + openLocalLesson, + startNewLesson, + ]); // Which sections are collapsed to their header. // @@ -662,11 +928,16 @@ export default function EditorPage() { return () => cancelAnimationFrame(raf); }, [hydrated]); - // Adopt a fetched lesson into the editor: replace the working doc (this is the - // step that overwrites the auto-saved draft). For an edit, enter edit mode so - // "Publish" becomes "Update" on the original row. For a fork, load it as a - // fresh, unattached draft (editingId stays null) titled "… (copy)", so - // publishing creates a separate lesson and the original is left untouched. + // Adopt a fetched lesson into the editor. Each mode lands somewhere different + // in this device's library, and — this is the part that used to need a + // "Replace your current work?" dialog — none of them touches the lesson that + // was on screen. Whatever you were doing is saved and stays in the list. + // + // import a new lesson of its own, keeping the document's own title + // fork a new lesson, unattached, titled "… (copy)" + // edit the lesson you already hold for it, opened as you left it — or, + // when you hold none, a new lesson attached to it, so "Publish" + // means "Update" on the row it came from const applyEdit = async ({ id, doc: nextDoc, @@ -675,20 +946,14 @@ export default function EditorPage() { published, forkedFrom: incomingFork, }) => { + await flushCurrentLesson(); + if (mode === "import") { - // An imported doc loads as a fresh, unattached lesson (like a fork, but - // keeping the document's own title): saving it later creates a new cloud - // lesson rather than overwriting anything. - // - // Its history starts here too. An imported lesson has no relationship to - // whatever was in the editor before, so the draft repo is thrown away - // rather than having the import committed on top of an unrelated timeline. - await git.discard(); - setDoc(nextDoc); - setEditingId(null); - setForkedFrom(null); - setEditingPublished(true); - setPendingEdit(null); + // An imported document has no relationship to whatever was in the editor + // before, so it gets a lesson — and therefore a history — of its own, + // starting at the import rather than continuing someone else's timeline. + adoptRecord(await createLesson({ doc: nextDoc })); + await refreshLocalLessons(); notify({ severity: "info", message: @@ -698,34 +963,37 @@ export default function EditorPage() { }); return; } + if (mode === "fork") { // Forking *clones the lesson's repository*: the copy keeps the original's // full history and, because git addresses commits by content, shares its // ancestry — which is what lets the fork be merged with the original later, // against the exact commit the two diverged from. // + // The library record is created first because its id is the name of the + // repository the clone lands in (see core/browser/storage.js). + // // A lesson published before this feature has no repo to clone. The fork // still works and still gets history from here on; it just has no common // ancestor with the original, so a later sync compares the two directly. + const record = await createLesson({ + doc: { + ...nextDoc, + title: t("labels.copyOf", { + title: nextDoc.title || t("labels.untitledLesson"), + }), + }, + forkedFrom: id, + }); let cloned = false; try { const engine = await loadGitEngine(); - cloned = Boolean(await engine.forkLessonRepo(id)); + cloned = Boolean(await engine.forkLessonRepo(id, record.id)); } catch { /* no history to clone — fall through to a fresh one */ } - - setDoc({ - ...nextDoc, - title: t("labels.copyOf", { - title: nextDoc.title || t("labels.untitledLesson"), - }), - }); - setEditingId(null); - setForkedFrom(id); - setEditingPublished(true); - setPendingEdit(null); - git.reload(); + adoptRecord(record); + await refreshLocalLessons(); notify({ severity: "info", message: cloned @@ -734,16 +1002,51 @@ export default function EditorPage() { }); return; } - setDoc(nextDoc); - setEditingId(id); - setForkedFrom(incomingFork || null); - setEditingPublished(published); - setPendingEdit(null); + + // Editing a hub lesson we already hold reopens *that* copy, exactly as it was + // left, rather than starting a second copy of the same lesson — and, just as + // importantly, rather than overwriting it with the document just fetched. + // The device copy is the only one that can hold edits made since the last + // save to the cloud, and replacing it would discard them with no warning: + // the single flow in here that would still destroy local work, on the one + // page that now promises not to. + // + // The hub does stay authoritative about the lesson's *status* — whether it + // is published, and what it was forked from — so the record's metadata is + // refreshed from what came back. + // + // When the two documents differ we say so, because the reason can be either + // side (unsaved work here, or a save from another device) and only the user + // knows which. Saving to the cloud is what settles it: the push refuses to + // overwrite a lesson that has moved on and offers the merge instead. + const existing = (await listLessons()).find((l) => l.lessonId === id); + let record; + let differs = false; + if (existing) { + await saveLessonMeta(existing.id, { + lessonId: id, + published, + forkedFrom: incomingFork || null, + }); + record = await getLesson(existing.id); + differs = diffDocs(record?.doc, nextDoc).length > 0; + } else { + record = await createLesson({ + doc: nextDoc, + lessonId: id, + published, + forkedFrom: incomingFork || null, + }); + } + adoptRecord(record); + await refreshLocalLessons(); notify({ severity: "info", - message: published - ? t("messages.loadedPublished") - : t("messages.loadedDraft"), + message: differs + ? t("messages.loadedLocalCopyDiffers") + : published + ? t("messages.loadedPublished") + : t("messages.loadedDraft"), }); }; @@ -805,16 +1108,10 @@ export default function EditorPage() { // "sync with the original" action stays available across sessions. forkedFrom: lesson.forkedFrom || null, }; - // Edit can adopt straight away when re-opening the same lesson; either - // mode adopts when there's no in-progress work to lose. Otherwise warn. - if ( - (mode === "edit" && editingIdRef.current === lesson.id) || - !docHasContent(docRef.current) - ) { - applyEditRef.current(incoming); - } else { - setPendingEdit(incoming); - } + // Straight in. Nothing is at risk: an edit reopens the copy this device + // already has of that lesson (or makes one), and a fork always becomes a + // lesson of its own. + applyEditRef.current(incoming); }) .catch((err) => { notify({ @@ -1236,29 +1533,40 @@ export default function EditorPage() { } }; - // Detach the working doc from the published lesson it was loaded from, so the - // next "Publish" creates a new lesson instead of updating the original. This - // is the explicit way to leave the editing-published status (the status - // otherwise persists across reloads). + // Fork the lesson being edited into a new one, and continue in the fork — so + // the next "Publish" creates a separate lesson instead of updating the one + // this came from. // - // Like forking from the hub, this clones the lesson's repository rather than - // just copying its text: the new lesson keeps the history and shares ancestry - // with the one it left, so it can be merged back with it later. + // The lesson it came from doesn't go anywhere: it stays in this device's + // library, still attached to its hub row, and is one click away in the lessons + // panel. (Before the library there was only one working document, so forking + // had to *detach* that document, and the original was gone from the editor + // until you fetched it again.) + // + // Like forking from the hub, this clones the repository rather than just + // copying the text: the fork keeps the history and shares ancestry with the + // lesson it left, so the two can be merged later. const handleFork = async () => { const from = editingId; - if (from) { - await git.commitNow(); - try { - const engine = await loadGitEngine(); - await engine.forkLocalRepo(from); - } catch { - /* no local history to carry over — the fork starts a fresh one */ - } + await flushCurrentLesson(); + + const record = await createLesson({ + doc: { + ...doc, + title: t("labels.copyOf", { + title: doc.title || t("labels.untitledLesson"), + }), + }, + forkedFrom: from || null, + }); + try { + const engine = await loadGitEngine(); + await engine.forkLocalRepo(repoIdFor(from, localId), record.id); + } catch { + /* no local history to carry over — the fork starts a fresh one */ } - setEditingId(null); - setForkedFrom(from || null); - setEditingPublished(true); - git.reload(); + adoptRecord(record); + await refreshLocalLessons(); notify({ severity: "info", message: t("messages.forkedNoUpstream"), @@ -1901,16 +2209,14 @@ export default function EditorPage() { try { const { importDocxFile } = await loadExportEngine(); const imported = await importDocxFile(file); - // Reuse the overwrite-confirmation flow when there's in-progress work to - // lose; otherwise load straight away. - const incoming = { + // Straight in — the import opens as a new lesson beside the one you were + // working on, rather than in place of it. + await applyEdit({ doc: imported, title: imported.title, mode: "import", source: "word", - }; - if (docHasContent(doc)) setPendingEdit(incoming); - else applyEdit(incoming); + }); } catch (err) { setImportErrorSource("word"); setImportError(err?.message || t("messages.wordImportFailed")); @@ -1934,14 +2240,12 @@ export default function EditorPage() { setBusy("import"); try { const imported = await importJsonFile(file); - const incoming = { + await applyEdit({ doc: imported, title: imported.title, mode: "import", source: "json", - }; - if (docHasContent(doc)) setPendingEdit(incoming); - else applyEdit(incoming); + }); } catch (err) { setImportErrorSource("json"); setImportError(err?.message || t("messages.jsonImportFailed")); @@ -2099,6 +2403,11 @@ export default function EditorPage() { {t("header.actionsTooltip")} + openPanel("lessons")}> + + {t("header.lessons")} + + {t("header.preview")} @@ -2194,6 +2503,24 @@ export default function EditorPage() { {t("header.helpTooltip")} + {/* The way back to everything else this device is holding. It sits + with the actions rather than in the document panel because it is + about which lesson you're in, not about the one you're in. */} + + + + + + {t("header.lessonsTooltip")} + + - - - - - {/* Hidden picker for Word import, triggered from the warning dialog. */} + {/* Every lesson this device is holding. Switching between them is the one + thing the editor could not do before: there was a single working + document, and opening anything meant overwriting it. */} + openPanel(null)} + lessons={localLessons} + currentId={localId} + onRefresh={refreshLocalLessons} + onOpen={openLocalLesson} + onCreate={startNewLesson} + onDuplicate={duplicateLocalLesson} + onDelete={removeLocalLesson} + onRename={renameLocalLesson} + /> + {/* Variations: the other branches of this lesson's repository, as an author sees them — separate copies to try things in. */} /.git the object store, refs and config // -// `repoId` is the hub lesson's id once it has one, and DRAFT_REPO while the -// lesson is still an unattached local draft. Publishing a draft copies its repo -// under the new lesson id (adoptDraftRepo), so the history a user built up -// before they first published isn't thrown away. +// `repoId` is the hub lesson's id once it has one, and the lesson's id in this +// device's library (see browser/storage.js) while it is still an unattached +// local draft — so every lesson held locally has a repository to itself, and +// switching lessons in the editor switches histories. Publishing a draft copies +// its repo under the new lesson id (adoptDraftRepo), so the history a user built +// up before they first published isn't thrown away. import LightningFS from "@isomorphic-git/lightning-fs"; import { DRAFT_REPO } from "../../git/doc.js"; @@ -51,25 +53,28 @@ export async function repoExists(repoId) { } /** - * Copy the draft repo to a published lesson's id, then drop the draft. + * Copy a local draft's repo to a published lesson's id, then drop the draft. * * Called the first time a lesson is saved to the cloud: the user may have been * editing (and accumulating history) for an hour before they hit Publish, and * that history is theirs. Copying the object store is a legitimate clone — git * objects are immutable and content-addressed, so the copy is byte-identical and * every commit oid survives. + * + * `draftRepoId` is the library id the lesson has been living under; it defaults + * to the old single-draft slot for anything still calling this the old way. */ -export async function adoptDraftRepo(lessonId) { - if (!lessonId || lessonId === DRAFT_REPO) return; +export async function adoptDraftRepo(lessonId, draftRepoId = DRAFT_REPO) { + if (!lessonId || !draftRepoId || lessonId === draftRepoId) return; const fs = gitFs(); - if (!(await exists(fs, `${ROOT}/${DRAFT_REPO}/.git/config`))) return; + if (!(await exists(fs, `${ROOT}/${draftRepoId}/.git/config`))) return; // Don't clobber an existing repo for this lesson (e.g. re-publishing after a // fork already cloned one under this id). if (await exists(fs, `${ROOT}/${lessonId}/.git/config`)) return; - await copyDir(fs, `${ROOT}/${DRAFT_REPO}`, `${ROOT}/${lessonId}`); - await removeDir(fs, `${ROOT}/${DRAFT_REPO}`); + await copyDir(fs, `${ROOT}/${draftRepoId}`, `${ROOT}/${lessonId}`); + await removeDir(fs, `${ROOT}/${draftRepoId}`); } /** Delete a lesson's repository (used when the draft is reset or a lesson deleted). */ diff --git a/packages/core/src/browser/git/sync.js b/packages/core/src/browser/git/sync.js index 5c5a76f..2b4dace 100644 --- a/packages/core/src/browser/git/sync.js +++ b/packages/core/src/browser/git/sync.js @@ -877,16 +877,22 @@ export async function prepareProposalReview({ * older lesson, from before this feature): the caller then falls back to seeding * a fresh repo from the lesson's plain doc, which still gives the fork history * from that point on, just no common ancestor with the original. + * + * `targetRepoId` is the new library lesson the fork is being cloned into — the + * fork is a lesson of its own now, not a takeover of the one draft slot. */ -export async function forkLessonRepo(sourceLessonId) { +export async function forkLessonRepo( + sourceLessonId, + targetRepoId = DRAFT_REPO, +) { const pack = await fetchPack(sourceLessonId).catch(() => null); if (!pack) return null; - // A fork starts from a clean draft repo — any earlier draft history belongs to + // A fork starts from a clean repo — anything already under this id belongs to // a different lesson and must not be grafted onto this one. - await deleteRepo(DRAFT_REPO); + await deleteRepo(targetRepoId); - const ctx = repoCtx(DRAFT_REPO); + const ctx = repoCtx(targetRepoId); // The lesson, and only the lesson. Its author's variations came down in the same // pack — they are in it so that *they* can reach them from another device — but // somebody else's half-finished ideas are not part of what was forked, and @@ -905,14 +911,16 @@ export async function forkLessonRepo(sourceLessonId) { * which detaches the open lesson so the next save creates a separate one. * * Same idea as forking from the hub, without the download: copy the repository - * into the draft slot and remember where it came from. The new lesson keeps the - * original's history and shares ancestry with it, so it can be merged back later. + * into the new lesson's own id and remember where it came from. The new lesson + * keeps the original's history and shares ancestry with it, so it can be merged + * back later — and the lesson it was forked from stays in the library, still + * attached to whatever it was attached to. */ -export async function forkLocalRepo(sourceRepoId) { - const copied = await copyRepo(sourceRepoId, DRAFT_REPO); +export async function forkLocalRepo(sourceRepoId, targetRepoId = DRAFT_REPO) { + const copied = await copyRepo(sourceRepoId, targetRepoId); if (!copied) return false; - const ctx = repoCtx(DRAFT_REPO); + const ctx = repoCtx(targetRepoId); const head = await headOid(ctx); if (head) { await git.writeRef({ ...ctx, ref: UPSTREAM_REF, value: head, force: true }); diff --git a/packages/core/src/browser/imageStore.js b/packages/core/src/browser/imageStore.js index ede4a8b..d61b35a 100644 --- a/packages/core/src/browser/imageStore.js +++ b/packages/core/src/browser/imageStore.js @@ -1,16 +1,34 @@ -// IndexedDB-backed storage for the working lesson and its images. +// IndexedDB-backed storage for the lessons held on this device and their images. // // This replaces the old localStorage doc + flags (which were capped at ~5 MB // and forced images to be stored as base64). Images live as binary Blobs keyed // by their SHA-256 content hash (so identical images dedupe automatically); the -// working doc and the small editor flags live in a separate key-value store. +// small editor flags live in a key-value store. +// +// The lessons themselves are split across *two* stores, and the split is the +// reason the library can be listed cheaply: +// +// lessons one small metadata record per lesson — title, counts, which hub +// lesson it is attached to, when it was last touched +// lessonDocs the documents themselves, keyed by the same id +// +// A lesson document with images and a hundred blocks is not small, and the +// library UI (and the sidebar) only ever want the titles. Keeping the bodies in +// their own store means listing every lesson reads the metadata store and +// nothing else — `getAll` on a store that held the documents too would deserialise +// every one of them to show a list of names. // // Everything here is async — IndexedDB has no synchronous API. Callers await. const DB_NAME = "s2c-lesson-maker"; -const DB_VERSION = 1; +// v2 added the `lessons` / `lessonDocs` stores — the library. v1 held a single +// working document under the app store's `doc` key; see migrateToLibrary() in +// storage.js, which moves it across. +const DB_VERSION = 2; const IMAGE_STORE = "images"; const APP_STORE = "app"; +const LESSON_STORE = "lessons"; +const LESSON_DOC_STORE = "lessonDocs"; // app-store keys for the bits of editor state that used to live in localStorage. const DOC_KEY = "doc"; @@ -21,6 +39,8 @@ const WIZARD_SEEN_KEY = "wizard-seen"; // editing id so a fork still knows where it came from after a reload, and can // offer to pull the original's later changes in (see lib/git/sync.js). const FORKED_FROM_KEY = "forked-from"; +// Which lesson in the library is open in the editor. +const CURRENT_LESSON_KEY = "current-lesson"; let dbPromise = null; @@ -42,6 +62,12 @@ function openDb() { if (!db.objectStoreNames.contains(APP_STORE)) { db.createObjectStore(APP_STORE); } + if (!db.objectStoreNames.contains(LESSON_STORE)) { + db.createObjectStore(LESSON_STORE, { keyPath: "id" }); + } + if (!db.objectStoreNames.contains(LESSON_DOC_STORE)) { + db.createObjectStore(LESSON_DOC_STORE); + } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); @@ -258,3 +284,143 @@ export async function saveWizardSeen() { /* ignore — the wizard just shows again next visit */ } } + +// ---- the lesson library ---------------------------------------------------- +// +// Raw store access, no policy: storage.js layers the library API (titles, +// counts, timestamps, the current lesson) on top of these. Every one of them +// swallows a failed transaction and answers with "nothing", because a browser +// that has denied us IndexedDB (private mode, a full disk) must still let the +// editor run against an in-memory document. + +/** Every lesson's metadata record, in no particular order. */ +export async function listLessonRecords() { + try { + const db = await openDb(); + return ( + (await reqToPromise(store(db, LESSON_STORE, "readonly").getAll())) || [] + ); + } catch { + return []; + } +} + +export async function getLessonRecord(id) { + if (!id) return null; + try { + const db = await openDb(); + return ( + (await reqToPromise(store(db, LESSON_STORE, "readonly").get(id))) || null + ); + } catch { + return null; + } +} + +/** + * Read-modify-write a lesson's record — and, when `doc` is given, store that + * document in the *same* transaction. + * + * The single transaction is the point. A lesson's title and block counts are + * derived from its document, while its hub attachment is set from somewhere else + * entirely, and the editor writes both on their own schedules: a debounced save + * as you type, and a metadata write the moment a lesson is published. Done as + * separate get-then-put pairs, the save that read first could put its stale copy + * back last and quietly drop the lesson's brand-new hub id. IndexedDB serialises + * overlapping readwrite transactions over the same store, so doing the read and + * the write inside one makes each update see the previous one's result. + * + * It is also the deletion guard: `derive` is never called, and nothing is + * written, when the record is already gone — so a save still in flight when its + * lesson is deleted cannot resurrect it. + * + * `derive` must be synchronous; awaiting anything else would let the transaction + * close underneath it. + */ +export async function updateLessonRecord(id, derive, doc) { + if (!id) return null; + try { + const db = await openDb(); + const tx = db.transaction([LESSON_STORE, LESSON_DOC_STORE], "readwrite"); + const record = await reqToPromise(tx.objectStore(LESSON_STORE).get(id)); + if (!record) return null; + const next = derive(record); + if (doc !== undefined) tx.objectStore(LESSON_DOC_STORE).put(doc, id); + tx.objectStore(LESSON_STORE).put(next); + return next; + } catch { + // Quota errors and the like are non-fatal — the in-memory lesson still works. + return null; + } +} + +export async function putLessonRecord(record) { + try { + const db = await openDb(); + await reqToPromise(store(db, LESSON_STORE, "readwrite").put(record)); + } catch { + /* ignore — the in-memory lesson still works */ + } +} + +export async function deleteLessonRecord(id) { + if (!id) return; + try { + const db = await openDb(); + await reqToPromise(store(db, LESSON_STORE, "readwrite").delete(id)); + } catch { + /* ignore */ + } +} + +/** One lesson's document, or null when it has none stored yet. */ +export async function getLessonDoc(id) { + if (!id) return null; + try { + const db = await openDb(); + return ( + (await reqToPromise(store(db, LESSON_DOC_STORE, "readonly").get(id))) || + null + ); + } catch { + return null; + } +} + +export async function putLessonDoc(id, doc) { + if (!id) return; + try { + const db = await openDb(); + await reqToPromise(store(db, LESSON_DOC_STORE, "readwrite").put(doc, id)); + } catch { + // Quota errors are non-fatal — the in-memory doc still works. + } +} + +export async function deleteLessonDoc(id) { + if (!id) return; + try { + const db = await openDb(); + await reqToPromise(store(db, LESSON_DOC_STORE, "readwrite").delete(id)); + } catch { + /* ignore */ + } +} + +/** The library lesson the editor is on, so a reload comes back to it. */ +export async function loadCurrentLessonId() { + try { + return (await appGet(CURRENT_LESSON_KEY)) || null; + } catch { + return null; + } +} + +export async function saveCurrentLessonId(id) { + try { + if (id) await appSet(CURRENT_LESSON_KEY, id); + else await appDelete(CURRENT_LESSON_KEY); + } catch { + /* ignore */ + } +} diff --git a/packages/core/src/browser/storage.js b/packages/core/src/browser/storage.js index a3997a4..3d4860b 100644 --- a/packages/core/src/browser/storage.js +++ b/packages/core/src/browser/storage.js @@ -1,10 +1,30 @@ -// Working-lesson persistence. Backed by IndexedDB (see imageStore.js) so images -// can be stored as binary blobs and large drafts aren't capped by localStorage's -// ~5 MB quota. These functions are async — callers await them. +// The lesson library: every lesson this device is holding, and which one is open. // -// migrateLocalStorage() performs a one-time move of any pre-IndexedDB draft (the -// old localStorage keys, with base64 images inline) into IndexedDB, converting -// each inline image to a binary blob + hash ref. It is idempotent. +// The editor used to keep exactly one working document — a `doc` key in +// IndexedDB, plus a handful of flags beside it saying which hub lesson it was +// attached to. That made "open this lesson" a destructive act: the only way to +// start something new, fork a lesson or import a document was to overwrite the +// draft already there, which is why the editor had a "Replace your current +// work?" dialog guarding three separate flows. +// +// IndexedDB has no reason to hold one lesson rather than fifty, so it holds as +// many as the user makes. A library record is the small stuff — title, counts, +// which hub lesson it's attached to, when it was last touched — and the document +// itself lives in its own store keyed by the same id (see imageStore.js for why +// the two are split). Each lesson also owns a git repository named by that same +// id while it is a local draft (see browser/git/fs.js and `repoIdFor`), so +// switching lessons switches histories too. +// +// Everything here is async — IndexedDB has no synchronous API. Callers await. +// +// Two migrations run once each, in order, on the editor's first mount: +// +// migrateLocalStorage() the pre-IndexedDB draft (localStorage, base64 images) +// -> the v1 IndexedDB doc + flags +// migrateToLibrary() that single doc + flags -> one library record +// +// Both are idempotent, and both are best-effort: a browser that refuses us +// storage still gets a working editor, it just won't remember anything. import { loadDocument, @@ -18,22 +38,156 @@ import { saveForkedFrom, loadWizardSeen, saveWizardSeen, + listLessonRecords, + getLessonRecord, + putLessonRecord, + deleteLessonRecord, + getLessonDoc, + putLessonDoc, + deleteLessonDoc, + updateLessonRecord, + loadCurrentLessonId, + saveCurrentLessonId, } from "./imageStore.js"; import { convertDocImages } from "./imageRef.js"; +import { DRAFT_REPO } from "../git/doc.js"; +import { newId } from "../id.js"; -export { - loadDocument, - saveDocument, - clearDocument, - loadEditingId, - saveEditingId, - loadEditingPublished, - saveEditingPublished, - loadForkedFrom, - saveForkedFrom, - loadWizardSeen, - saveWizardSeen, -}; +export { loadWizardSeen, saveWizardSeen }; + +// ---- the library ----------------------------------------------------------- + +/** + * The metadata kept beside a lesson's document, derived from it on every save. + * + * Deliberately denormalised: the library list and the sidebar want a title and + * a size for each lesson, and reading every document to work them out would + * make listing the library cost as much as opening all of it. + */ +function statsFor(doc) { + const sections = doc?.sections || []; + let blocks = 0; + for (const section of sections) blocks += (section.blocks || []).length; + return { + title: typeof doc?.title === "string" ? doc.title : "", + sections: sections.length, + blocks, + }; +} + +/** Newest first — "what I was working on" is the order a library is read in. */ +function byRecency(a, b) { + return (b.updatedAt || 0) - (a.updatedAt || 0); +} + +/** Every lesson on this device, newest first. Metadata only — no documents. */ +export async function listLessons() { + return (await listLessonRecords()).sort(byRecency); +} + +/** One lesson, document included, or null if this device doesn't have it. */ +export async function getLesson(id) { + const record = await getLessonRecord(id); + if (!record) return null; + return { ...record, doc: await getLessonDoc(id) }; +} + +/** + * Add a lesson to the library and return its record (document included). + * + * `id` is normally left to us. It is worth knowing what it becomes, though, + * because it is also the name of the lesson's git repository until the lesson is + * published and takes the hub's id instead — so a caller that wants to clone a + * repository into a new lesson (a fork, an import) creates the record first and + * clones into `record.id`. + * + * @param {object} [opts] + * @param {string} [opts.id] The lesson's id here, and its repo id. Ours to make unless you need to know it in advance. + * @param {object} [opts.doc] The document to start from. Defaults to an empty one. + * @param {?string} [opts.lessonId] The hub lesson this one is attached to, if it is already published or saved as a cloud draft. + * @param {boolean} [opts.published] Whether that hub lesson is public or a private draft. Only meaningful with `lessonId`. + * @param {?string} [opts.forkedFrom] The hub lesson this one was forked from, so it can be synced with it later. + */ +export async function createLesson({ + id = newId(), + doc = { title: "", sections: [] }, + lessonId = null, + published = true, + forkedFrom = null, +} = {}) { + const now = Date.now(); + const record = { + id, + ...statsFor(doc), + lessonId, + published, + forkedFrom, + createdAt: now, + updatedAt: now, + }; + // The document first: a record whose document hasn't landed yet would list a + // lesson that opens empty, where the reverse is merely a document nothing + // points at (and the next createLesson overwrites it). + await putLessonDoc(id, doc); + await putLessonRecord(record); + return { ...record, doc }; +} + +/** + * Store a lesson's document, refreshing the title and counts beside it. + * + * Document and record go down together, in one transaction, and a lesson that no + * longer has a record is left alone entirely — the editor's document save is + * debounced, so one can still be in flight when its lesson is deleted, and it + * must neither resurrect the lesson nor leave a document body behind that + * nothing points at. + */ +export async function saveLessonDoc(id, doc) { + return updateLessonRecord( + id, + (record) => ({ ...record, ...statsFor(doc), updatedAt: Date.now() }), + doc, + ); +} + +/** + * Update the record's own fields — which hub lesson it's attached to, whether + * that lesson is published, what it was forked from. + * + * Deliberately *not* stamped with updatedAt: these are consequences of saving to + * the cloud rather than edits, and re-ordering the library because a lesson + * learnt its own hub id would be noise. + */ +export async function saveLessonMeta(id, patch) { + return updateLessonRecord(id, (record) => ({ ...record, ...patch })); +} + +/** + * Remove a lesson from the library. The caller deletes its git repository — + * that lives in a different database (LightningFS) and needs the git engine + * loaded, which this module deliberately doesn't pull in. + */ +export async function deleteLesson(id) { + if (!id) return; + await deleteLessonRecord(id); + await deleteLessonDoc(id); + if ((await loadCurrentLessonId()) === id) await saveCurrentLessonId(null); +} + +/** The lesson the editor last had open, so a reload comes back to it. */ +export async function getCurrentLessonId() { + return loadCurrentLessonId(); +} + +/** + * Remember which lesson is open, so a reload comes back to it. A nullish `id` + * clears the memory, and the editor then opens the most recently edited lesson. + */ +export async function setCurrentLessonId(id) { + return saveCurrentLessonId(id); +} + +// ---- migrations ------------------------------------------------------------ // Legacy localStorage keys (pre-IndexedDB). Read once by migrateLocalStorage, // then removed. @@ -104,3 +258,64 @@ export async function migrateLocalStorage() { // Best effort: leave the old keys in place so the next load can retry. } } + +/** + * Move the single working document into the library, as its first lesson. + * + * The record takes the id `draft` on purpose. That is the name the old working + * lesson's repository already has on disk (`/lessons/draft/.git` — see + * browser/git/fs.js), and a library lesson's id *is* its repo id while it is + * unpublished, so naming the record after the repository carries the history + * across without copying a single git object. Lessons created from here on get + * ordinary random ids, which cannot collide with it. + * + * A no-op once the library has anything in it, so it is safe on every load. + */ +export async function migrateToLibrary() { + const existing = await listLessonRecords(); + if (existing.length > 0) { + // Already a library. Make sure something is open — a device whose current + // lesson was deleted in another tab shouldn't come back to nothing. + const current = await loadCurrentLessonId(); + if (!current || !existing.some((record) => record.id === current)) { + await saveCurrentLessonId(existing.sort(byRecency)[0].id); + } + return; + } + + const doc = await loadDocument(); + if (!doc) return; // nothing was ever saved here — the editor starts fresh + + const [lessonId, published, forkedFrom] = await Promise.all([ + loadEditingId(), + loadEditingPublished(), + loadForkedFrom(), + ]); + + await createLesson({ + id: DRAFT_REPO, + doc, + lessonId: lessonId || null, + published, + forkedFrom: forkedFrom || null, + }); + await saveCurrentLessonId(DRAFT_REPO); + + // Read the lesson back before dropping the v1 keys, because the writes above + // cannot fail loudly: every store helper swallows a failed transaction so that + // a browser refusing us IndexedDB still gets a working editor. That is right + // for an autosave and quite wrong here — this is the only durable copy of the + // lesson, and deleting it on the strength of a write that may never have + // landed would lose it outright. If either half is missing, leave v1 alone and + // let the next load try again. + const [migrated, migratedDoc] = await Promise.all([ + getLessonRecord(DRAFT_REPO), + getLessonDoc(DRAFT_REPO), + ]); + if (!migrated || !migratedDoc) return; + + await clearDocument(); + await saveEditingId(null); + await saveEditingPublished(null); + await saveForkedFrom(null); +} diff --git a/packages/core/src/git/doc.js b/packages/core/src/git/doc.js index d6ece37..53d008e 100644 --- a/packages/core/src/git/doc.js +++ b/packages/core/src/git/doc.js @@ -107,10 +107,20 @@ export function stripLocalFields(doc) { return out; } -/** The repo id used before a lesson has been saved to the hub. */ +/** + * The repo id of the one working lesson the editor used to have, and the id the + * library's migration gives it (see browser/storage.js) so its history survives. + */ export const DRAFT_REPO = "draft"; -/** The repo id for a lesson: its hub id, or the draft repo when unattached. */ -export function repoIdFor(editingId) { - return editingId || DRAFT_REPO; +/** + * The repo id for a lesson: its hub id once it has one, and otherwise the id it + * has in this device's library — which is what gives every local lesson a + * repository of its own rather than all of them sharing one draft slot. + * + * `localId` is optional because the readers of a *published* lesson's repository + * (the history tab, a proposal's diff) know its hub id and nothing else. + */ +export function repoIdFor(lessonId, localId) { + return lessonId || localId || DRAFT_REPO; }