Chapters as rows, in two views, in their own colours

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 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 14:20:14 +02:00
1 parent 6cad632a0c
commit d74fea4905
12 files changed
+640 -88

No files matched your search

+70 -15
View File
@@ -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 });
});