From d74fea49055ea81098d861e8677d91b2f9c528e9 Mon Sep 17 00:00:00 2001 From: marc Date: Wed, 12 Aug 2026 14:20:14 +0200 Subject: [PATCH] Chapters as rows, in two views, in their own colours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things Marc asked for, and one of them turned out to be the hinge the other two hang from. **A chapter is a row like any other.** Same grip, same drag, same place in a hand-made arrangement as a note or a separator. That needed notes and chapters numbered from *one* counter: numbered separately they are two sequences that cannot be interleaved again, and a chapter dragged between two notes springs back to the top. Positions therefore have to be named rather than read off an array index, so POST /notes/reorder now takes `positions: [{id, order}]` beside the older `ids`, and chapters have a reorder route of their own. **Two ways for a chapter to open**, chosen per account beside the typeface, for the same reason that one lives there: it is a choice about how your own notes are laid out and it should follow you to the phone. Beside the list, its notes in a further column the way OneNote lays out pages — the notebook's own list carrying on to the left, chapter rows and all. Or unfolded under the chapter's own row, indented against a rule in its colour. A phone has room for neither and fills the list instead, with a way back, which is what it did before there was a choice. **Its own colour**, from eight named ones rather than the full palette: the point of chapters is that they are different places, and twenty near-identical blues do not help anyone tell them apart. The colour is validated on the way in — it is written by a browser, so only a plain #rrggbb gets through and anything else becomes no colour. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 20 +++ backend/server.js | 85 ++++++++-- e2e/chapters.mjs | 76 ++++++++- frontend/src/App.jsx | 207 ++++++++++++++++++++++- frontend/src/features/notes/NoteList.jsx | 59 ++++--- frontend/src/features/notes/noteSort.ts | 93 +++++++--- frontend/src/hooks/useNoteData.ts | 26 ++- frontend/src/syncEngine.ts | 50 +++++- frontend/src/types.ts | 12 ++ frontend/src/ui/AccountMenu.jsx | 47 ++++- frontend/src/ui/constants.jsx | 18 ++ readme.md | 35 +++- 12 files changed, 640 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c09e8d..3c9644c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ not from the output of `docker push`. --- +## Unreleased + +**Added** +- **Two ways for a chapter to open**, chosen per account: its notes in a further + column beside the list, the way OneNote lays out its pages, or unfolded under + the chapter's own row. A phone has room for neither and fills the list, with a + way back. +- **A chapter can be given its own colour**, since the point of having them is + that they are different places. + +**Changed** +- **Chapters sit at the same level as notes and separators** — same grip, same + drag, same place in a hand-made arrangement. Notes and chapters are numbered + from one counter now; separately, the two sequences could not be interleaved + and a chapter dragged between two notes would spring back. `POST + /notes/reorder` takes named positions beside the older list of ids, and + chapters have a reorder route of their own. + +--- + ## 2026-08-12 — Moving in one press **Images** — web `sha256:3700247c…`, server unchanged at `sha256:181d77be…` diff --git a/backend/server.js b/backend/server.js index 75bc9cd..e531548 100644 --- a/backend/server.js +++ b/backend/server.js @@ -297,6 +297,10 @@ const signedInUser = (row) => ({ ...publicUser(row), defaultMaxVersions: MAX_VER * kept: this column is written by the browser, so it is an input like any other, * and a fixed shape is what stops it becoming a place to put arbitrary data. */ const NOTE_FONTS = ["serif", "sans", "mono"]; +/* How a chapter opens: as a further column beside the notes, the way OneNote + * lays out its pages, or unfolded in place under its own row. A phone has room + * for neither, and falls back to filling the screen with a way back. */ +const CHAPTER_VIEWS = ["column", "inline"]; function readPreferences(raw) { let parsed = null; @@ -306,7 +310,11 @@ function readPreferences(raw) { parsed = null; } const font = parsed?.noteFont; - return { noteFont: NOTE_FONTS.includes(font) ? font : "serif" }; + const view = parsed?.chapterView; + return { + noteFont: NOTE_FONTS.includes(font) ? font : "serif", + chapterView: CHAPTER_VIEWS.includes(view) ? view : "column", + }; } // Login used to be e-mail based. Rebuild the table with a username column and @@ -439,6 +447,7 @@ db.exec(` user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE, name TEXT NOT NULL, + color TEXT, sort_order INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), @@ -447,6 +456,11 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_chapters_notebook ON chapters(notebook_id); `); +/* Added after chapters shipped, so an existing table needs it too. */ +if (!tableColumns("chapters").includes("color")) { + db.exec("ALTER TABLE chapters ADD COLUMN color TEXT"); +} + if (!tableColumns("notes").includes("chapter_id")) { db.exec("ALTER TABLE notes ADD COLUMN chapter_id TEXT REFERENCES chapters(id) ON DELETE SET NULL"); db.exec("CREATE INDEX IF NOT EXISTS idx_notes_chapter ON notes(chapter_id)"); @@ -725,14 +739,21 @@ app.put("/api/auth/profile", authMiddleware, (req, res) => { }); app.put("/api/auth/preferences", authMiddleware, (req, res) => { - const { noteFont } = req.body || {}; + const { noteFont, chapterView } = req.body || {}; if (noteFont !== undefined && !NOTE_FONTS.includes(noteFont)) { return res.status(400).json({ error: `noteFont must be one of ${NOTE_FONTS.join(", ")}` }); } + if (chapterView !== undefined && !CHAPTER_VIEWS.includes(chapterView)) { + return res.status(400).json({ error: `chapterView must be one of ${CHAPTER_VIEWS.join(", ")}` }); + } const current = readPreferences( db.prepare("SELECT preferences FROM users WHERE id = ?").get(req.userId)?.preferences ); - const next = { ...current, ...(noteFont !== undefined ? { noteFont } : {}) }; + const next = { + ...current, + ...(noteFont !== undefined ? { noteFont } : {}), + ...(chapterView !== undefined ? { chapterView } : {}), + }; db.prepare("UPDATE users SET preferences = ?, updated_at = datetime('now') WHERE id = ?") .run(JSON.stringify(next), req.userId); res.json(publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId))); @@ -1124,16 +1145,27 @@ app.get("/api/notes/:id", authMiddleware, (req, res) => { */ const MAX_REORDER = 5000; app.post("/api/notes/reorder", authMiddleware, (req, res) => { - const ids = Array.isArray(req.body?.ids) ? req.body.ids.slice(0, MAX_REORDER) : null; - if (!ids) return res.status(400).json({ error: "ids must be an array" }); + /* Two shapes. `ids` numbers them by their place in the array, which is all + * that was ever needed while notes were the only rows. `positions` names the + * number for each — needed once chapters share the sequence, where a note's + * place in its own array is no longer its position in the list. */ + const positions = Array.isArray(req.body?.positions) + ? req.body.positions.slice(0, MAX_REORDER) + : Array.isArray(req.body?.ids) + ? req.body.ids.slice(0, MAX_REORDER).map((id, index) => ({ id, order: index })) + : null; + if (!positions) return res.status(400).json({ error: "ids or positions must be an array" }); const write = db.transaction(() => { const update = db.prepare("UPDATE notes SET sort_order = ? WHERE id = ? AND user_id = ?"); - ids.forEach((id, index) => { - if (typeof id === "string" && id) update.run(index, id, req.userId); - }); + for (const p of positions) { + if (p && typeof p.id === "string" && p.id && Number.isFinite(Number(p.order))) { + update.run(Math.max(0, Math.round(Number(p.order))), p.id, req.userId); + } + } }); write(); + const ids = positions.map((p) => p?.id); broadcastToUser(req.userId, { type: "notes_reordered", data: { count: ids.length } }, req.deviceId); res.json({ success: true }); @@ -1674,16 +1706,21 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => { const MAX_CHAPTER_NAME = 120; +/* A colour is written by a browser, so it is an input like any other: only a + * plain `#rrggbb` gets through, and anything else becomes "no colour". */ +const cleanColor = (value) => + typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : null; + app.get("/api/chapters", authMiddleware, (req, res) => { res.json( db - .prepare("SELECT id, notebook_id, name, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at") + .prepare("SELECT id, notebook_id, name, color, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at") .all(req.userId) ); }); app.post("/api/chapters", authMiddleware, (req, res) => { - const { notebookId, name } = req.body || {}; + const { notebookId, name, color } = req.body || {}; const label = String(name ?? "").trim(); if (!label) return res.status(400).json({ error: "name required" }); if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" }); @@ -1697,14 +1734,32 @@ app.post("/api/chapters", authMiddleware, (req, res) => { .prepare("SELECT COUNT(*) AS n FROM chapters WHERE notebook_id = ?") .get(notebookId).n; const id = uuid(); - db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?)") - .run(id, req.userId, notebookId, label, order, req.deviceId); + db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, color, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run(id, req.userId, notebookId, label, cleanColor(color), order, req.deviceId); broadcastToUser(req.userId, { type: "chapter_created", data: { id, notebookId, name: label } }, req.deviceId); res.json({ id, notebookId, name: label, sortOrder: order }); }); +app.post("/api/chapters/reorder", authMiddleware, (req, res) => { + const positions = Array.isArray(req.body?.positions) ? req.body.positions.slice(0, MAX_REORDER) : null; + if (!positions) return res.status(400).json({ error: "positions must be an array" }); + + const write = db.transaction(() => { + const update = db.prepare("UPDATE chapters SET sort_order = ? WHERE id = ? AND user_id = ?"); + for (const p of positions) { + if (p && typeof p.id === "string" && p.id && Number.isFinite(Number(p.order))) { + update.run(Math.max(0, Math.round(Number(p.order))), p.id, req.userId); + } + } + }); + write(); + + broadcastToUser(req.userId, { type: "chapters_reordered", data: { count: positions.length } }, req.deviceId); + res.json({ success: true }); +}); + app.put("/api/chapters/:id", authMiddleware, (req, res) => { - const { name, sortOrder } = req.body || {}; + const { name, sortOrder, color } = req.body || {}; const chapter = db.prepare("SELECT id FROM chapters WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!chapter) return res.status(404).json({ error: "Not found" }); @@ -1714,8 +1769,8 @@ app.put("/api/chapters/:id", authMiddleware, (req, res) => { if (!label) return res.status(400).json({ error: "name required" }); if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" }); } - db.prepare("UPDATE chapters SET name = COALESCE(?, name), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), device_id = ? WHERE id = ?") - .run(label ?? null, sortOrder ?? null, req.deviceId, req.params.id); + db.prepare("UPDATE chapters SET name = COALESCE(?, name), color = COALESCE(?, color), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), device_id = ? WHERE id = ?") + .run(label ?? null, color === undefined ? null : cleanColor(color), sortOrder ?? null, req.deviceId, req.params.id); broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId); res.json({ success: true }); }); diff --git a/e2e/chapters.mjs b/e2e/chapters.mjs index a8af018..e8efc47 100644 --- a/e2e/chapters.mjs +++ b/e2e/chapters.mjs @@ -75,27 +75,85 @@ try { await p.locator("[data-chapter-row]").first().click(); await p.waitForTimeout(1200); - check((await titles()).join(",") === "Stromrechnung", "and is inside the chapter", (await titles()).join(", ")); - check((await p.locator("[data-chapter-back]").count()) === 1, "with a way back out"); + const inChapter = () => + p.locator("[data-chapter-note]").evaluateAll((els) => els.map((e) => e.textContent.trim())); + check((await inChapter()).join(",").includes("Stromrechnung"), "and is inside the chapter", (await inChapter()).join(", ")); + check((await p.locator("[data-chapter-close]").count()) === 1, "with a way to close it again"); const filed = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Stromrechnung"); check(!!filed?.chapter_id, "the server filed it too", String(filed?.chapter_id)); - /* A note made inside a chapter belongs to it, without being moved. */ + /* A note made while a chapter is open belongs to it, without being moved. */ await createNote(p); await setTitle(p, "Wasserrechnung"); await p.waitForTimeout(1500); const made = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Wasserrechnung"); - check(made?.chapter_id === filed?.chapter_id, "a note made inside a chapter is filed there", String(made?.chapter_id)); + check(made?.chapter_id === filed?.chapter_id, "a note made while it is open is filed there", String(made?.chapter_id)); - await p.locator("[data-chapter-back]").click(); + await p.locator("[data-chapter-close]").click(); await p.waitForTimeout(1200); check( (await titles()).join(",") === "Zählerstand", - "and going back shows the notebook again, without the chapter's notes", + "and the notebook's own list never held the chapter's notes", (await titles()).join(", ") ); + /* ── the two ways a chapter opens ── + * A further column beside the list, the way OneNote lays out its pages, or + * unfolded under its own row. Chosen on the account, so the answer follows to + * the phone — where there is room for neither and the chapter fills the + * screen instead. */ + await addChapter("Verträge"); + await p.locator("[data-chapter-row]").first().click(); + await p.waitForTimeout(1200); + check((await p.locator("[data-chapter-column]").count()) === 1, "a chapter opens beside the list by default"); + check( + (await p.locator("[data-chapter-row]").count()) === 2, + "and the notebook's own list keeps every chapter row", + String(await p.locator("[data-chapter-row]").count()) + ); + + await p.getByTitle("Account").click(); + await p.waitForTimeout(800); + await p.locator('[data-chapter-view="inline"]').click(); + await p.waitForTimeout(1500); + await p.mouse.click(1380, 900); // outside the panel + await p.waitForTimeout(1200); + await p.locator("[data-chapter-row]").first().click(); + await p.waitForTimeout(1200); + check((await p.locator("[data-chapter-notes]").count()) === 1, "the other setting unfolds it under its row"); + check((await p.locator("[data-chapter-column]").count()) === 0, "and there is no column then"); + + const me = await api(p, "GET", "/auth/me"); + check(me.body?.preferences?.chapterView === "inline", "the choice is on the account, not in this browser"); + + /* ── a chapter is a row like any other ── + * Same level as notes and separators, so it can be dragged into place in the + * arrangement rather than being stuck above everything. */ + await p.locator("[data-sort-button]").click(); + await p.waitForTimeout(300); + await p.locator('[data-sort-option="manual"]').click(); + await p.waitForTimeout(1200); + check( + (await p.locator("[data-reorder-grip]").count()) >= 2, + "a chapter has a grip like a note does", + String(await p.locator("[data-reorder-grip]").count()) + ); + + /* ── its own colour ── */ + await p.locator("[data-chapter-row]").first().click({ button: "right", force: true }); + await p.waitForTimeout(600); + await p.locator("[data-submenu-toggle]").filter({ hasText: "Colour" }).first().click(); + await p.waitForTimeout(500); + await p.locator("[data-submenu] button").filter({ hasText: "Green" }).first().click(); + await p.waitForTimeout(2000); + const coloured = (await api(p, "GET", "/chapters")).body; + check( + coloured.some((c) => c.color === "#2da44e"), + "a chapter can be given its own colour", + JSON.stringify(coloured.map((c) => [c.name, c.color])) + ); + /* ── a chapter is not a wastebasket ── * Removing one must never remove what it held: the notes come back to the * notebook, where they can be seen and filed again. */ @@ -107,7 +165,11 @@ try { await p.getByRole("button", { name: "Remove", exact: true }).click(); await p.waitForTimeout(2500); - check((await p.locator("[data-chapter-row]").count()) === 0, "the chapter is gone"); + check( + (await p.locator("[data-chapter-row]").count()) === 1, + "the chapter is gone, and only that one", + String(await p.locator("[data-chapter-row]").count()) + ); const after = (await titles()).sort().join(","); check( after === "Stromrechnung,Wasserrechnung,Zählerstand", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 96e605e..3ab2c24 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,7 +8,7 @@ import { usePwaInstall } from "./hooks/usePwaInstall"; import { useNotebookDnd } from "./hooks/useNotebookDnd"; import { useNavigationHistory } from "./hooks/useNavigationHistory"; -import { DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx"; +import { CHAPTER_COLORS, DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx"; import { I } from "./ui/icons.jsx"; import { makeTheme, FONT_UI } from "./ui/theme"; import { ConfirmDialog } from "./ui/ConfirmDialog.jsx"; @@ -103,6 +103,12 @@ export default function App() { // Presentation only, and it lives on the account so it follows to the phone. const noteFont = user?.preferences?.noteFont || "serif"; + /* How a chapter opens: a further column beside the list, or unfolded under + its own row. A phone has room for neither, so there it fills the screen — + which is what "column" already did before there was a choice. */ + const chapterView = user?.preferences?.chapterView || "column"; + const inlineChapters = chapterView === "inline" && !isMobile; + const columnChapters = chapterView === "column" && !isMobile; const data = useNoteData(user, activeNote); const { notes, notebooks, noteContents, backlinks } = data; @@ -287,7 +293,8 @@ export default function App() { if (activeFolder === "trash") list = trashedNotes; else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred); else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes; - else if (activeChapter) list = activeNotes.filter((n) => n.chapterId === activeChapter); + else if (activeChapter && !inlineChapters && !columnChapters) + list = activeNotes.filter((n) => n.chapterId === activeChapter); /* In the notebook itself, a note filed into a chapter is *in* that chapter and not loose here — otherwise it would appear twice and moving it would look like it had done nothing. */ @@ -305,7 +312,24 @@ export default function App() { return list.filter((n) => n.title.toLowerCase().includes(q)); } return sortNotes(list, sortMode); - }, [activeFolder, activeChapter, searchQuery, search.results, activeNotes, trashedNotes, sortMode]); + }, [ + activeFolder, + activeChapter, + inlineChapters, + columnChapters, + searchQuery, + search.results, + activeNotes, + trashedNotes, + sortMode, + ]); + + /* The notes of the chapter being looked into, in the same order they would + have in a list of their own. */ + const chapterNotes = useMemo( + () => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), sortMode) : []), + [activeChapter, activeNotes, sortMode] + ); const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null; // The editor opens as soon as the metadata is known; the body fills in when @@ -603,6 +627,18 @@ export default function App() { onSave: (name) => data.renameChapter(chapter.id, name), }), }, + { + label: "Colour", + icon: I.edit, + submenu: [ + { label: "None", colorDot: T.faint, action: () => data.recolourChapter(chapter.id, null) }, + ...CHAPTER_COLORS.map((c) => ({ + label: c.label, + colorDot: c.value, + action: () => data.recolourChapter(chapter.id, c.value), + })), + ], + }, { divider: true }, { label: "Remove chapter", @@ -680,6 +716,58 @@ export default function App() { if (!user) return ; // On a phone the list and the editor take turns; on a desktop both are shown. + /* A chapter's notes, unfolded under its own row. Indented and against a rule, + so it reads as "inside this" rather than as more of the same list. */ + const renderChapterNotes = (chapter) => ( +
+ {chapterNotes.length === 0 && ( +
Nothing in here yet.
+ )} + {chapterNotes.map((n) => ( + + ))} +
+ ); + const showList = !(isMobile && mobileView === "editor" && activeNote); const showEditor = !isMobile || mobileView === "editor"; @@ -775,10 +863,15 @@ export default function App() { onRenameSeparator={renameSeparator} onSeparatorContextMenu={separatorContextMenu} chapters={folderChapters} - openChapter={openChapter} + /* Only in the fallback: there the list becomes the chapter, and + its header is the way back. Beside or below, the notebook's own + list carries on and keeps its chapter rows. */ + openChapter={inlineChapters || columnChapters ? null : openChapter} onOpenChapter={setActiveChapter} onAddChapter={activeNotebook ? addChapter : null} onChapterContextMenu={chapterContextMenu} + expandedChapter={inlineChapters ? activeChapter : null} + renderChapterNotes={inlineChapters ? renderChapterNotes : null} onMoveNote={setMovingNote} onReorder={rearrangeNotes} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} @@ -790,6 +883,112 @@ export default function App() { onEmptyTrash={emptyTrash} /> )} + {/* The chapter's own column, beside the list — the way OneNote lays + out its pages. Only where there is room: on a phone the chapter + fills the screen instead, with a way back. */} + {columnChapters && openChapter && ( +
+
+ + {I.books} + + + {openChapter.name} + + +
+
+ {chapterNotes.length === 0 && ( +
+ Nothing in here yet. +
+ )} + {chapterNotes.map((n) => ( + + ))} +
+
+ )} {showEditor && ( ({ color: T.muted, }); -/** A note rendered outside the manual order, wrapped to look like an entry. */ +/** A row rendered outside the manual order, wrapped to look like an entry. */ const asNoteEntry = (note) => ({ kind: "note", id: note.id, note }); +const asChapterEntry = (chapter) => ({ kind: "chapter", id: chapter.id, chapter }); /** * Where the dragged row will land. @@ -211,7 +212,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex * place, and the notes are what is in this one. The count is what makes it worth * opening — a row saying only "Invoices" tells you nothing about whether to. */ -function ChapterRow({ T, chapter, onOpen, onContextMenu }) { +function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) { return ( ); } @@ -453,6 +457,8 @@ export function NoteList({ onOpenChapter, onAddChapter, onChapterContextMenu, + expandedChapter, + renderChapterNotes, onMoveNote, onReorder, onToggleSidebar, @@ -471,6 +477,9 @@ export function NoteList({ or the trash, which are views over notebooks rather than a place to file anything, and not inside a chapter, because a chapter holds notes. */ const canAddChapter = !!onAddChapter && !openChapter; + /* `openChapter` means this list *is* the chapter — the fallback where there is + no room beside or below, so the chapter fills the list and the header offers + a way back. In the other two views the chapter rows stay where they are. */ const shownChapters = openChapter ? [] : chapters || []; return ( @@ -705,19 +714,10 @@ export function NoteList({ something there: in "newest first" the notes would slide past a line on every edit. They are not shown in the other orders, and are still in place on the way back. */} - {shownChapters.map((chapter) => ( - - ))} - {shownChapters.length > 0 && notes.length > 0 && ( -
- )} - {(reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry)).map((entry) => { + {(reorderable + ? mergeEntries(notes, separators, shownChapters) + : [...shownChapters.map(asChapterEntry), ...notes.map(asNoteEntry)] + ).map((entry) => { const showDropLine = reorderable && dnd.overId === entry.id; /* The whole row, indicator included, is the drop target. * @@ -728,6 +728,25 @@ export function NoteList({ const rowProps = reorderable ? dnd.rowProps(entry.id) : {}; const lifted = dnd.draggingId === entry.id; + if (entry.kind === "chapter") { + return ( +
+ {showDropLine && } +
+ {reorderable && } + +
+ {expandedChapter === entry.id && renderChapterNotes?.(entry.chapter)} +
+ ); + } + if (entry.kind === "separator") { return (
diff --git a/frontend/src/features/notes/noteSort.ts b/frontend/src/features/notes/noteSort.ts index f5c455d..10d4dcd 100644 --- a/frontend/src/features/notes/noteSort.ts +++ b/frontend/src/features/notes/noteSort.ts @@ -12,7 +12,7 @@ * scramble everything that was not being looked at. */ -import type { NoteMeta, Separator, SortMode } from "../../types"; +import type { Chapter, NoteMeta, Separator, SortMode } from "../../types"; export const DEFAULT_SORT: SortMode = "updated"; @@ -86,6 +86,7 @@ export function sortNotes(notes: NoteMeta[], mode: SortMode = DEFAULT_SORT): Not */ export type Entry = | { kind: "note"; id: string; order: number; note: NoteMeta } + | { kind: "chapter"; id: string; order: number; chapter: Chapter } | { kind: "separator"; id: string; order: number; separator: Separator }; /** @@ -94,51 +95,90 @@ export type Entry = * A separator sitting at the same position as a note comes first, so "insert a * line above this note" lands where it was asked for rather than one row late. */ -export function mergeEntries(notes: NoteMeta[], separators: Separator[] = []): Entry[] { - /* Notes are placed by their rank, not by their stored number. Notes that have - * never been dragged all carry 0, and sorting has already broken that tie — - * using the stored numbers here would pile them all at position 0 and push - * every separator behind the lot. */ - const noteEntries: Entry[] = sortNotes(notes, "manual").map((note, rank) => ({ - kind: "note" as const, - id: note.id, - order: rank, - note, - })); +export function mergeEntries( + notes: NoteMeta[], + separators: Separator[] = [], + chapters: Chapter[] = [] +): Entry[] { + /* Notes and chapters share one sequence. + * + * A chapter is a row in the list like a note is, and can be dragged among + * them, so they cannot each be numbered from zero — they are ranked together + * by the position each carries. + * + * Ranked, not placed by the stored number: rows that have never been dragged + * all carry 0, and using the raw numbers would pile them at position 0 and + * push every separator behind the lot. */ + const ranked = sortNotes(notes, "manual"); + const rows = [ + ...chapters.map((chapter, i) => ({ + kind: "chapter" as const, + id: chapter.id, + chapter, + sort: chapter.sortOrder ?? 0, + // Keeps the order within one kind settled when several share a position, + // which every row does until it has been dragged once. + tie: i, + })), + ...ranked.map((note, i) => ({ kind: "note" as const, id: note.id, note, sort: note.sortOrder ?? 0, tie: i })), + ].sort( + (a, b) => + a.sort - b.sort || + // A chapter shares a position with a note only before either has been + // moved; putting chapters first there matches where they are created. + (a.kind === b.kind ? a.tie - b.tie : a.kind === "chapter" ? -1 : 1) + ); + + const rowEntries: Entry[] = rows.map((row, rank) => + row.kind === "chapter" + ? { kind: "chapter", id: row.id, order: rank, chapter: row.chapter } + : { kind: "note", id: row.id, order: rank, note: row.note } + ); const separatorEntries: Entry[] = separators.map((separator) => ({ kind: "separator" as const, id: separator.id, // Clamped, so a separator left over from a notebook that has since shrunk // still appears — at the end — rather than vanishing with its label. - order: Math.max(0, Math.min(separator.order, noteEntries.length)), + order: Math.max(0, Math.min(separator.order, rowEntries.length)), separator, })); - return [...noteEntries, ...separatorEntries].sort( - // A separator sitting at the same position as a note comes first, so - // "a line above this note" lands where it was asked for. + return [...rowEntries, ...separatorEntries].sort( + // A separator sitting at the same position as a row comes first, so + // "a line above this one" lands where it was asked for. (a, b) => a.order - b.order || (a.kind === "separator" ? -1 : 1) ); } -/** The result of a drag: what to write back for notes and for separators. */ +/** The result of a drag: what to write back for each kind of row. */ export interface Arrangement { - noteIds: string[]; + /** Notes in their new order, with the position each should carry. */ + notes: Array<{ id: string; order: number }>; + /** Chapters likewise — numbered from the *same* counter as the notes. */ + chapters: Array<{ id: string; order: number }>; separators: Separator[]; + /** The note ids alone, for callers that only reorder notes. */ + noteIds: string[]; } /** Turns an ordered entry list back into storable positions. */ export function toArrangement(entries: Entry[]): Arrangement { - const noteIds: string[] = []; + const notes: Array<{ id: string; order: number }> = []; + const chapters: Array<{ id: string; order: number }> = []; const separators: Separator[] = []; + /* One counter for notes and chapters together. Numbering them separately + * would leave two sequences that cannot be interleaved again, and a chapter + * dragged between two notes would spring back. */ + let position = 0; for (const entry of entries) { - if (entry.kind === "note") noteIds.push(entry.id); - // A separator's position is "how many notes come before me". Expressed that - // way it stays put as the notes around it are renamed, edited or opened. - else separators.push({ ...entry.separator, order: noteIds.length }); + if (entry.kind === "note") notes.push({ id: entry.id, order: position++ }); + else if (entry.kind === "chapter") chapters.push({ id: entry.id, order: position++ }); + // A separator's position is "how many rows come before me". Expressed that + // way it stays put as the rows around it are renamed, edited or opened. + else separators.push({ ...entry.separator, order: position }); } - return { noteIds, separators }; + return { notes, chapters, separators, noteIds: notes.map((n) => n.id) }; } /** @@ -151,10 +191,11 @@ export function reindexEntries( notes: NoteMeta[], separators: Separator[], dragId: string, - targetId: string + targetId: string, + chapters: Chapter[] = [] ): Arrangement | null { if (dragId === targetId) return null; - const entries = mergeEntries(notes, separators); + const entries = mergeEntries(notes, separators, chapters); const from = entries.findIndex((e) => e.id === dragId); if (from < 0) return null; diff --git a/frontend/src/hooks/useNoteData.ts b/frontend/src/hooks/useNoteData.ts index 4119a36..7cc4a9a 100644 --- a/frontend/src/hooks/useNoteData.ts +++ b/frontend/src/hooks/useNoteData.ts @@ -54,6 +54,7 @@ export interface NoteData { chapters: Chapter[]; createChapter: (notebookId: string, name: string) => Promise; renameChapter: (id: string, name: string) => Promise; + recolourChapter: (id: string, color: string | null) => Promise; deleteChapter: (id: string) => Promise; deleteNotebook: (id: string) => Promise; reorderNotebooks: (dragId: string, targetId: string) => void; @@ -79,6 +80,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD notesRef.current = notes; const notebooksRef = useRef(notebooks); notebooksRef.current = notebooks; + const chaptersRef = useRef(chapters); + chaptersRef.current = chapters; const contentsRef = useRef(noteContents); contentsRef.current = noteContents; @@ -300,14 +303,25 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD const all = notebook.separators || []; const mine = all.filter((sep) => (sep.chapterId ?? null) === chapterId); const others = all.filter((sep) => (sep.chapterId ?? null) !== chapterId); + /* Chapters are rows in this list too, and only in the notebook itself — a + chapter has no chapters of its own. */ + const inList = chapterId + ? [] + : chaptersRef.current.filter((c) => c.notebookId === notebookId); - const next = reindexEntries(inNotebook, mine, dragId, targetId); + const next = reindexEntries(inNotebook, mine, dragId, targetId, inList); if (!next) return; const merged = [...others, ...next.separators.map((sep) => ({ ...sep, chapterId }))]; - const position = new Map(next.noteIds.map((id, i) => [id, i])); + const position = new Map(next.notes.map((n) => [n.id, n.order])); setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n))); - void sync.reorderNotes(next.noteIds); + void sync.reorderNotes(next.notes); + + if (next.chapters.length) { + const chapterAt = new Map(next.chapters.map((c) => [c.id, c.order])); + setChapters((p) => p.map((c) => (chapterAt.has(c.id) ? { ...c, sortOrder: chapterAt.get(c.id)! } : c))); + void sync.reorderChapters(next.chapters); + } /* Only written when it actually changed. * @@ -353,6 +367,11 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD await sync.updateChapter(id, { name }); }, []); + const recolourChapter = useCallback(async (id: string, color: string | null) => { + setChapters((p) => p.map((c) => (c.id === id ? { ...c, color } : c))); + await sync.updateChapter(id, { color }); + }, []); + const deleteChapter = useCallback( async (id: string) => { await sync.deleteChapter(id); @@ -416,6 +435,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD chapters, createChapter, renameChapter, + recolourChapter, deleteChapter, deleteNotebook, reorderNotebooks, diff --git a/frontend/src/syncEngine.ts b/frontend/src/syncEngine.ts index 4719a74..b024f8c 100644 --- a/frontend/src/syncEngine.ts +++ b/frontend/src/syncEngine.ts @@ -83,6 +83,7 @@ interface ServerChapter { id: string; notebook_id: string; name: string; + color?: string | null; sort_order?: number; } @@ -530,7 +531,7 @@ type QueuedAction = } | { action: "deleteNotebook"; data: { id: string } } | { action: "createChapter"; data: { localId: string; notebookId: string; name: string } } - | { action: "updateChapter"; data: { id: string; name?: string; sortOrder?: number } } + | { action: "updateChapter"; data: { id: string; name?: string; sortOrder?: number; color?: string | null } } | { action: "deleteChapter"; data: { id: string } } | { action: "createNote"; @@ -660,7 +661,11 @@ async function processEntry(entry: QueueEntry): Promise { break; } case "updateChapter": - await api.put(`/chapters/${a.data.id}`, { name: a.data.name, sortOrder: a.data.sortOrder }); + await api.put(`/chapters/${a.data.id}`, { + name: a.data.name, + sortOrder: a.data.sortOrder, + color: a.data.color, + }); break; case "deleteChapter": await api.del(`/chapters/${a.data.id}`); @@ -792,6 +797,7 @@ export async function fetchChapters(): Promise { id: c.id, notebookId: c.notebook_id, name: c.name, + color: c.color ?? null, sortOrder: c.sort_order || 0, })); const local = await db.getAllChapters(); @@ -825,7 +831,10 @@ export async function createChapter(notebookId: string, name: string): Promise { +export async function updateChapter( + id: string, + updates: { name?: string; sortOrder?: number; color?: string | null } +): Promise { const chapter = (await db.getAllChapters()).find((c) => c.id === id); if (!chapter) return; await db.putChapter({ ...chapter, ...updates }); @@ -998,15 +1007,40 @@ export async function getNotes(): Promise { * note update would be wrong for the same reason — it is sent when there is a * connection and simply re-derived from the local order otherwise. */ -export async function reorderNotes(orderedIds: string[]): Promise { +/** + * Writes new positions for notes. + * + * Positions are named rather than taken from the array index: chapters share + * the sequence, so a note's place among the notes is no longer its place in the + * list. A bare list of ids still works and is numbered in order. + */ +export async function reorderNotes( + order: string[] | Array<{ id: string; order: number }> +): Promise { + const positions = order.map((entry, i) => + typeof entry === "string" ? { id: entry, order: i } : entry + ); const notes = await db.getAllNotes(); - for (let i = 0; i < orderedIds.length; i++) { - const note = notes.find((n) => n.id === orderedIds[i]); - if (note && note.sortOrder !== i) await db.putNote({ ...note, sortOrder: i }); + for (const p of positions) { + const note = notes.find((n) => n.id === p.id); + if (note && note.sortOrder !== p.order) await db.putNote({ ...note, sortOrder: p.order }); } notifyData("notes", null); if (_online && api.token) { - api.post("/notes/reorder", { ids: orderedIds }).catch(() => {}); + api.post("/notes/reorder", { positions }).catch(() => {}); + } +} + +/** The same, for chapters — they are rows in that one sequence too. */ +export async function reorderChapters(positions: Array<{ id: string; order: number }>): Promise { + const chapters = await db.getAllChapters(); + for (const p of positions) { + const chapter = chapters.find((c) => c.id === p.id); + if (chapter && chapter.sortOrder !== p.order) await db.putChapter({ ...chapter, sortOrder: p.order }); + } + notifyData("chapters", null); + if (_online && api.token) { + api.post("/chapters/reorder", { positions }).catch(() => {}); } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2362566..10a2cd1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -105,6 +105,8 @@ export interface Chapter { id: string; notebookId: string; name: string; + /** `#rrggbb` for its icon, so a shelf of chapters can be told apart. */ + color?: string | null; sortOrder: number; /** Set while a chapter created offline has not reached the server yet. */ _pendingCreate?: boolean; @@ -129,8 +131,18 @@ export interface Notebook { /** How the note body is set. Presentation only — the export is unaffected. */ export type NoteFont = "serif" | "sans" | "mono"; +/** + * How a chapter opens. + * + * "column" puts its notes in a further column beside the list, the way OneNote + * lays out its pages; "inline" unfolds them under the chapter's own row. A + * phone has room for neither and fills the screen either way, with a way back. + */ +export type ChapterView = "column" | "inline"; + export interface UserPreferences { noteFont: NoteFont; + chapterView?: ChapterView; } export interface User { diff --git a/frontend/src/ui/AccountMenu.jsx b/frontend/src/ui/AccountMenu.jsx index aef29fd..9c8f008 100644 --- a/frontend/src/ui/AccountMenu.jsx +++ b/frontend/src/ui/AccountMenu.jsx @@ -4,13 +4,15 @@ import { I } from "./icons.jsx"; /* ═══════ ACCOUNT MENU ═══════ */ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) { - const chooseFont = async (noteFont) => { + const choosePreference = async (change) => { try { - onUserUpdate(await sync.updatePreferences({ noteFont })); + onUserUpdate(await sync.updatePreferences(change)); } catch { /* the choice is presentation; failing to store it must not block the panel */ } }; + const chooseFont = (noteFont) => choosePreference({ noteFont }); + const chooseChapterView = (chapterView) => choosePreference({ chapterView }); const [tab, setTab] = useState("profile"); // profile | username | password const [displayName, setDisplayName] = useState(user?.displayName || ""); const [newUsername, setNewUsername] = useState(user?.username || ""); @@ -305,6 +307,47 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin })}
+ + {/* How a chapter opens. Kept beside the typeface because both are + the same kind of thing: how your own notes are laid out, stored + on the account so the answer follows you to the phone. */} +
+ +
+ {[ + ["column", "Beside the list", "Its notes in a further column, the way OneNote lays out pages"], + ["inline", "Under the chapter", "Its notes unfold in place, below the chapter's own row"], + ].map(([value, label, hint]) => { + const active = (user.preferences?.chapterView || "column") === value; + return ( + + ); + })} +
+
+ A phone has room for neither, and fills the screen either way — with a way back. +
+
)} diff --git a/frontend/src/ui/constants.jsx b/frontend/src/ui/constants.jsx index 340e3f0..e44bf74 100644 --- a/frontend/src/ui/constants.jsx +++ b/frontend/src/ui/constants.jsx @@ -41,6 +41,24 @@ export const COVERS = [ { bg: "linear-gradient(135deg,#654ea3,#eaafc8)", spine: "#4e3c7e", accent: "#f8bbd0" }, ]; +/** + * Colours a chapter's icon can be given. + * + * A short, named list rather than the full palette: the point is telling one + * chapter from another at a glance, and twenty near-identical blues do not help + * with that. Each is legible on both themes. + */ +export const CHAPTER_COLORS = [ + { label: "Red", value: "#e5534b" }, + { label: "Orange", value: "#e07c24" }, + { label: "Yellow", value: "#d4a017" }, + { label: "Green", value: "#2da44e" }, + { label: "Teal", value: "#1f9c96" }, + { label: "Blue", value: "#4a8ff5" }, + { label: "Purple", value: "#a371f7" }, + { label: "Pink", value: "#db61a2" }, +]; + /* The icon is a key rather than an emoji: the sidebar draws it from the app's * own set, so it matches the stroke weight of everything around it and looks * the same on every machine. */ diff --git a/readme.md b/readme.md index 49ae694..97f6dcb 100644 --- a/readme.md +++ b/readme.md @@ -432,7 +432,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running | `conflict` | 10 | a late write wins but the state it buries survives as a version | | `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back | | `organise` | 52 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole | -| `chapters` | 13 | a note filed into a chapter leaves the notebook's list, and removing the chapter gives every note back | +| `chapters` | 21 | a note filed into a chapter leaves the notebook's list, and removing the chapter gives every note back | | `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection | | `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it | @@ -483,7 +483,7 @@ the left edge. Centred, the same leftover space reads as two margins and needs n explaining. **The typeface is a per-account setting** (Account → Profile): serif, sans or -mono. On the account rather than in the browser, because a choice about how your +mono. How a chapter opens is chosen there too, for the same reason. On the account rather than in the browser, because a choice about how your own notes are set should follow you to the phone rather than be made again on every device. The server accepts those three values and drops anything else — that column is written by a browser, so it is an input like any other. @@ -971,7 +971,36 @@ Three rules that keep the two levels honest: A note made while a chapter is open is filed there without being moved. A chapter is named when it is created, unlike a separator: a separator is a line and reads perfectly well unnamed, while an unnamed chapter is a row nobody can tell from -the next one. +the next one. Each can be given **its own colour** — the point of having chapters +is that they are different places, and eight named colours tell them apart where +twenty near-identical blues would not. + +### How a chapter opens + +Two ways, chosen per account under Account → Profile, beside the typeface and for +the same reason: it is a choice about how your own notes are laid out, and it +should follow you to the phone. + +- **Beside the list** — its notes in a further column, the way OneNote lays out + its pages. The notebook's own list carries on to the left, chapter rows and + all. +- **Under the chapter** — its notes unfold in place, indented against a rule in + the chapter's colour. + +A phone has room for neither. There the chapter fills the list and the header +button becomes a way back out, which is what it did before there was a choice. + +### A chapter is a row like any other + +Chapters sit at the same level as notes and separators: same grip, same drag, +same place in a hand-made arrangement. That takes **one counter for both** in +`toArrangement` — numbering notes and chapters separately would leave two +sequences that cannot be interleaved again, and a chapter dragged between two +notes would spring back to the top. + +Positions therefore have to be *named* rather than taken from an array index, +which is why `POST /notes/reorder` accepts `positions: [{id, order}]` beside the +older `ids`, and why chapters have a reorder route of their own. ---