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
@@ -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…`
|
||||
|
||||
+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 });
|
||||
});
|
||||
|
||||
+69
-7
@@ -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",
|
||||
|
||||
+203
-4
@@ -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 <AuthScreen onLogin={onLogin} dark={dark} />;
|
||||
|
||||
// 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) => (
|
||||
<div
|
||||
data-chapter-notes={chapter.id}
|
||||
style={{ margin: "2px 0 8px 22px", borderLeft: `2px solid ${chapter.color || T.accent}55`, paddingLeft: 8 }}
|
||||
>
|
||||
{chapterNotes.length === 0 && (
|
||||
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
|
||||
)}
|
||||
{chapterNotes.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
data-chapter-note={n.id}
|
||||
onClick={() => openNote(n.id)}
|
||||
onContextMenu={(e) => noteContextMenu(e, n)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
width: "100%",
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
|
||||
background: activeNote === n.id ? T.accentSoft : "transparent",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
|
||||
{n.type === "pdf" ? I.pdf : I.note}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{n.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
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 && (
|
||||
<div
|
||||
data-chapter-column
|
||||
style={{
|
||||
width: 268,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRight: `1px solid ${T.borderLight}`,
|
||||
background: T.panel || "transparent",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "14px 14px 10px",
|
||||
borderBottom: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", color: openChapter.color || T.accent, flexShrink: 0 }}>
|
||||
{I.books}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{openChapter.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setActiveChapter(null)}
|
||||
title="Close chapter"
|
||||
aria-label="Close chapter"
|
||||
data-chapter-close
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: T.muted,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{I.x}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 6 }}>
|
||||
{chapterNotes.length === 0 && (
|
||||
<div style={{ padding: "12px 10px", fontSize: 12, color: T.muted }}>
|
||||
Nothing in here yet.
|
||||
</div>
|
||||
)}
|
||||
{chapterNotes.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
data-chapter-note={n.id}
|
||||
onClick={() => openNote(n.id)}
|
||||
onContextMenu={(e) => noteContextMenu(e, n)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
width: "100%",
|
||||
padding: "9px 10px",
|
||||
borderRadius: 9,
|
||||
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
|
||||
background: activeNote === n.id ? T.accentSoft : "transparent",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
|
||||
{n.type === "pdf" ? I.pdf : I.note}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{n.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showEditor && (
|
||||
<EditorPane
|
||||
T={T}
|
||||
|
||||
@@ -19,8 +19,9 @@ const iconButton = (T) => ({
|
||||
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 (
|
||||
<button
|
||||
data-chapter-row={chapter.id}
|
||||
@@ -221,17 +222,20 @@ function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
width: "100%",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.borderLight}`,
|
||||
background: "transparent",
|
||||
border: `1px solid ${open ? T.accent + "55" : T.borderLight}`,
|
||||
background: open ? T.accentSoft : "transparent",
|
||||
cursor: "pointer",
|
||||
marginBottom: 4,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", color: T.accent, flexShrink: 0 }}>{I.books}</span>
|
||||
{/* Its own colour, so a shelf of chapters can be told apart at a glance
|
||||
— the point of having them is that they are different places. */}
|
||||
<span style={{ display: "flex", color: chapter.color || T.accent, flexShrink: 0 }}>{I.books}</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -247,7 +251,7 @@ function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
|
||||
{chapter.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>{chapter.noteCount}</span>
|
||||
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>›</span>
|
||||
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>{open ? "▾" : "›"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<ChapterRow
|
||||
key={chapter.id}
|
||||
T={T}
|
||||
chapter={chapter}
|
||||
onOpen={onOpenChapter}
|
||||
onContextMenu={onChapterContextMenu}
|
||||
/>
|
||||
))}
|
||||
{shownChapters.length > 0 && notes.length > 0 && (
|
||||
<div style={{ height: 1, background: T.borderLight, margin: "8px 4px 10px" }} />
|
||||
)}
|
||||
{(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 (
|
||||
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
|
||||
{reorderable && <Grip T={T} {...dnd.gripProps} />}
|
||||
<ChapterRow
|
||||
T={T}
|
||||
chapter={entry.chapter}
|
||||
open={expandedChapter === entry.id}
|
||||
onOpen={onOpenChapter}
|
||||
onContextMenu={onChapterContextMenu}
|
||||
/>
|
||||
</div>
|
||||
{expandedChapter === entry.id && renderChapterNotes?.(entry.chapter)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.kind === "separator") {
|
||||
return (
|
||||
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface NoteData {
|
||||
chapters: Chapter[];
|
||||
createChapter: (notebookId: string, name: string) => Promise<Chapter>;
|
||||
renameChapter: (id: string, name: string) => Promise<void>;
|
||||
recolourChapter: (id: string, color: string | null) => Promise<void>;
|
||||
deleteChapter: (id: string) => Promise<void>;
|
||||
deleteNotebook: (id: string) => Promise<void>;
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<Chapter[]> {
|
||||
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<C
|
||||
return chapter;
|
||||
}
|
||||
|
||||
export async function updateChapter(id: string, updates: { name?: string; sortOrder?: number }): Promise<void> {
|
||||
export async function updateChapter(
|
||||
id: string,
|
||||
updates: { name?: string; sortOrder?: number; color?: string | null }
|
||||
): Promise<void> {
|
||||
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<NoteMeta[]> {
|
||||
* 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<void> {
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<label style={{ fontSize: 12, color: T.muted, marginBottom: 6, display: "block" }}>
|
||||
Chapters open
|
||||
</label>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8 }}>
|
||||
{[
|
||||
["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 (
|
||||
<button
|
||||
key={value}
|
||||
data-chapter-view={value}
|
||||
onClick={() => chooseChapterView(value)}
|
||||
title={hint}
|
||||
style={{
|
||||
padding: "10px 8px",
|
||||
borderRadius: 10,
|
||||
border: `1.5px solid ${active ? T.accent : T.border}`,
|
||||
background: active ? T.accent + "15" : "transparent",
|
||||
color: active ? T.accent : T.text,
|
||||
cursor: "pointer",
|
||||
fontSize: 12.5,
|
||||
textAlign: "left",
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: T.muted, marginTop: 6, lineHeight: 1.5 }}>
|
||||
A phone has room for neither, and fills the screen either way — with a way back.
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in new issue
Block a user