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:
1 parent
6cad632a0c
commit
d74fea4905
12 files changed
+640
-88
No files matched your search
+70
-15
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user