Compare commits
3
Commits
7cb26898aa
...
1757ec9af3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1757ec9af3 | ||
|
|
e037f01dc2 | ||
|
|
83b76b95a0 |
No files matched your search
@@ -8,6 +8,34 @@ not from the output of `docker push`.
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
|
||||
**Added**
|
||||
- **Chapters — folders inside a notebook.** A note is in one or in none; tapping
|
||||
a chapter opens its own list, with a way back out. Removing a chapter never
|
||||
removes what it held: the notes come back to the notebook, and the dialog says
|
||||
so with the number affected. Separators now belong to the list they were drawn
|
||||
in, so a line drawn in one chapter does not appear in another.
|
||||
|
||||
**Changed**
|
||||
- **A notebook's design is its own.** A 45-degree stripe texture was baked into
|
||||
every cover at 1.5% white — meant to be imperceptible, visibly banding on the
|
||||
darker gradients, and a second pattern fighting the one actually chosen. And
|
||||
the same pair of rings sat in the middle of every notebook. The mark belongs
|
||||
to the design now, and the designs whose pattern already fills the face wear
|
||||
none.
|
||||
|
||||
**Fixed**
|
||||
- **A submenu was being clipped away on a wide screen.** The menu had been given
|
||||
`overflow-y: auto`, which cuts off anything flying out sideways — so the list
|
||||
of notebooks under *Move to…* was invisible and every click on it landed on
|
||||
the note list behind. The side was also inverted: `right: 100%` places a panel
|
||||
on the left.
|
||||
- **A separator was effectively invisible on a phone** — a one-pixel hairline in
|
||||
the border colour with faint grey text on near-black.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-12 — Two-part covers, and an app that can be installed
|
||||
|
||||
**Images** — web `sha256:1217b81b…`, server `sha256:3303d3dc…`
|
||||
|
||||
+150
-6
@@ -422,6 +422,36 @@ if (!tableColumns("users").includes("preferences")) {
|
||||
*
|
||||
* On `attachments` rather than in a table of its own: it is a property of the
|
||||
* file, it is deduplicated with the file, and it is freed with the file. */
|
||||
/**
|
||||
* Chapters — folders inside a notebook.
|
||||
*
|
||||
* A note sits in exactly one chapter, or in none, which is the notebook itself.
|
||||
* `ON DELETE SET NULL` is the whole design in one clause: removing a chapter
|
||||
* must never take notes with it. They come back to the notebook, where they can
|
||||
* be seen and filed again.
|
||||
*
|
||||
* Separate from notebooks rather than a self-reference on them: a notebook has a
|
||||
* cover, a sort order and separators, none of which a chapter has or wants.
|
||||
*/
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chapters (
|
||||
id TEXT PRIMARY KEY,
|
||||
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,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
device_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chapters_notebook ON chapters(notebook_id);
|
||||
`);
|
||||
|
||||
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)");
|
||||
}
|
||||
|
||||
/* The pattern laid over a notebook's cover, chosen apart from its colour.
|
||||
* Its own column rather than more values in `cover_index`: they are two
|
||||
* decisions, and folding them into one number would mean renumbering every
|
||||
@@ -888,6 +918,10 @@ function cleanSeparators(input) {
|
||||
id: s.id.slice(0, 64),
|
||||
label: typeof s.label === "string" ? s.label.slice(0, MAX_SEPARATOR_LABEL) : "",
|
||||
order: Number.isFinite(Number(s.order)) ? Math.max(0, Math.round(Number(s.order))) : 0,
|
||||
/* Which list it was drawn in — a chapter of the notebook, or the notebook
|
||||
* itself. `order` counts the notes before it, and that only means anything
|
||||
* within one list. */
|
||||
chapterId: typeof s.chapterId === "string" && s.chapterId ? s.chapterId.slice(0, 64) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1025,7 +1059,7 @@ function isBigShrink(previous, next) {
|
||||
|
||||
/* ─── Note routes ─── */
|
||||
app.get("/api/notes", authMiddleware, (req, res) => {
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
|
||||
res.json(notes);
|
||||
});
|
||||
|
||||
@@ -1106,13 +1140,25 @@ app.post("/api/notes/reorder", authMiddleware, (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/api/notes", authMiddleware, (req, res) => {
|
||||
const { type, title, content, notebookId, starred, pdfAttachmentId } = req.body;
|
||||
const { type, title, content, notebookId, chapterId, starred, pdfAttachmentId } = req.body;
|
||||
const id = uuid();
|
||||
const encContent = content ? encrypt(content) : null;
|
||||
|
||||
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, pdf_attachment_id, device_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, pdfAttachmentId || null, req.deviceId);
|
||||
/* A note made while a chapter is open belongs to that chapter. Checked
|
||||
* against the notebook, so it cannot be filed into a chapter of another. */
|
||||
let chapter = null;
|
||||
if (chapterId) {
|
||||
const found = db.prepare("SELECT notebook_id FROM chapters WHERE id = ? AND user_id = ?").get(chapterId, req.userId);
|
||||
if (!found) return res.status(404).json({ error: "Chapter not found" });
|
||||
if (found.notebook_id !== notebookId) {
|
||||
return res.status(400).json({ error: "That chapter is in a different notebook" });
|
||||
}
|
||||
chapter = chapterId;
|
||||
}
|
||||
|
||||
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, chapter_id, type, title, content_encrypted, starred, pdf_attachment_id, device_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, req.userId, notebookId, chapter, type || "text", title || "Untitled", encContent, starred ? 1 : 0, pdfAttachmentId || null, req.deviceId);
|
||||
|
||||
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'note', ?, 'create', ?)")
|
||||
.run(req.userId, id, req.deviceId);
|
||||
@@ -1154,10 +1200,33 @@ const replaceNoteAttachments = db.transaction((noteId, ids) => {
|
||||
});
|
||||
|
||||
app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
const { title, content, notebookId, starred, baseUpdatedAt, links, attachments } = req.body;
|
||||
const { title, content, notebookId, chapterId, starred, baseUpdatedAt, links, attachments } = req.body;
|
||||
const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
if (!note) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
/* Which chapter the note is filed in. `null` means the notebook itself, so it
|
||||
* is meaningful and has to travel — which is why this cannot ride on COALESCE
|
||||
* like the fields above, where undefined and null mean the same thing.
|
||||
*
|
||||
* A chapter belongs to a notebook, so the two have to agree: filing a note
|
||||
* into a chapter of a book it is not in would leave it in a place that cannot
|
||||
* be reached from either. */
|
||||
let chapterUpdate;
|
||||
if (chapterId !== undefined) {
|
||||
if (chapterId === null) chapterUpdate = null;
|
||||
else {
|
||||
const chapter = db
|
||||
.prepare("SELECT notebook_id FROM chapters WHERE id = ? AND user_id = ?")
|
||||
.get(chapterId, req.userId);
|
||||
if (!chapter) return res.status(404).json({ error: "Chapter not found" });
|
||||
const target = notebookId !== undefined && notebookId !== null ? notebookId : note.notebook_id;
|
||||
if (chapter.notebook_id !== target) {
|
||||
return res.status(400).json({ error: "That chapter is in a different notebook" });
|
||||
}
|
||||
chapterUpdate = chapterId;
|
||||
}
|
||||
}
|
||||
|
||||
const conflict = isStaleWrite(note.updated_at, baseUpdatedAt);
|
||||
const encContent = content ? encrypt(content) : note.content_encrypted;
|
||||
|
||||
@@ -1186,6 +1255,14 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
WHERE id = ?`)
|
||||
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, req.deviceId, req.params.id);
|
||||
|
||||
if (chapterId !== undefined) {
|
||||
db.prepare("UPDATE notes SET chapter_id = ? WHERE id = ?").run(chapterUpdate, req.params.id);
|
||||
} else if (notebookId !== undefined && notebookId !== null && notebookId !== note.notebook_id) {
|
||||
// Moved to another notebook without naming a chapter: the old chapter is in
|
||||
// the book it left, so keeping it would file the note somewhere invisible.
|
||||
db.prepare("UPDATE notes SET chapter_id = NULL WHERE id = ?").run(req.params.id);
|
||||
}
|
||||
|
||||
if (Array.isArray(links)) replaceNoteLinks(req.userId, req.params.id, links);
|
||||
if (Array.isArray(attachments)) replaceNoteAttachments(req.params.id, attachments);
|
||||
|
||||
@@ -1589,6 +1666,73 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => {
|
||||
res.end(bytes);
|
||||
});
|
||||
|
||||
/* ─── Chapters ───
|
||||
*
|
||||
* Folders inside a notebook. A note is in one or in none; deleting a chapter
|
||||
* hands its notes back to the notebook rather than taking them with it.
|
||||
*/
|
||||
|
||||
const MAX_CHAPTER_NAME = 120;
|
||||
|
||||
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")
|
||||
.all(req.userId)
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/chapters", authMiddleware, (req, res) => {
|
||||
const { notebookId, name } = 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" });
|
||||
|
||||
// The notebook has to be this user's — otherwise a chapter could be hung off
|
||||
// someone else's, and every note filed into it would follow.
|
||||
const nb = db.prepare("SELECT id FROM notebooks WHERE id = ? AND user_id = ?").get(notebookId, req.userId);
|
||||
if (!nb) return res.status(404).json({ error: "Notebook not found" });
|
||||
|
||||
const order = db
|
||||
.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);
|
||||
broadcastToUser(req.userId, { type: "chapter_created", data: { id, notebookId, name: label } }, req.deviceId);
|
||||
res.json({ id, notebookId, name: label, sortOrder: order });
|
||||
});
|
||||
|
||||
app.put("/api/chapters/:id", authMiddleware, (req, res) => {
|
||||
const { name, sortOrder } = 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" });
|
||||
|
||||
let label;
|
||||
if (name !== undefined) {
|
||||
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" });
|
||||
}
|
||||
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);
|
||||
broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.delete("/api/chapters/:id", authMiddleware, (req, res) => {
|
||||
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" });
|
||||
/* The notes are not deleted with it. Written out rather than left to the
|
||||
* foreign key, because it is the promise the feature rests on and it should
|
||||
* be visible here. */
|
||||
const freed = db.prepare("UPDATE notes SET chapter_id = NULL WHERE chapter_id = ? AND user_id = ?")
|
||||
.run(req.params.id, req.userId).changes;
|
||||
db.prepare("DELETE FROM chapters WHERE id = ?").run(req.params.id);
|
||||
broadcastToUser(req.userId, { type: "chapter_deleted", data: { id: req.params.id } }, req.deviceId);
|
||||
res.json({ success: true, notesFreed: freed });
|
||||
});
|
||||
|
||||
/* ─── The text inside a PDF ───
|
||||
*
|
||||
* Extracted by the device that has the file, never here. The server has no PDF
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* Chapters — folders inside a notebook.
|
||||
*
|
||||
* The claim the feature rests on is the one under test: a note filed into a
|
||||
* chapter is *in* it and not also loose in the notebook, and removing a chapter
|
||||
* never removes what it held. Both look fine on screen while being wrong on the
|
||||
* server, so the server is asked directly.
|
||||
*/
|
||||
import {
|
||||
api,
|
||||
check,
|
||||
createNote,
|
||||
createNotebook,
|
||||
launch,
|
||||
report,
|
||||
setTitle,
|
||||
withFreshUser,
|
||||
} from "./lib.mjs";
|
||||
|
||||
const browser = await launch();
|
||||
let session;
|
||||
try {
|
||||
session = await withFreshUser(browser, "chapters");
|
||||
const p = session.page;
|
||||
|
||||
const titles = () =>
|
||||
p.locator("[data-note-row] [data-note-title]").evaluateAll((els) => els.map((e) => e.textContent.trim()));
|
||||
const addChapter = async (name) => {
|
||||
await p.locator("[data-add-chapter]").click();
|
||||
await p.waitForTimeout(600);
|
||||
await p.getByLabel("Chapter name").fill(name);
|
||||
await p.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await p.waitForTimeout(2000);
|
||||
};
|
||||
const moveTo = async (noteTitle, target) => {
|
||||
await p.locator("[data-note-row]").filter({ hasText: noteTitle }).first().click({ button: "right", force: true });
|
||||
await p.waitForTimeout(600);
|
||||
await p.locator("[data-submenu-toggle]").first().click();
|
||||
await p.waitForTimeout(500);
|
||||
await p.locator("[data-submenu] button").filter({ hasText: target }).first().click();
|
||||
await p.waitForTimeout(2500);
|
||||
};
|
||||
|
||||
await createNotebook(p, "Haus");
|
||||
await p.locator("button").filter({ hasText: "Haus" }).first().click();
|
||||
await p.waitForTimeout(700);
|
||||
for (const name of ["Zählerstand", "Stromrechnung"]) {
|
||||
await createNote(p);
|
||||
await setTitle(p, name);
|
||||
await p.waitForTimeout(600);
|
||||
}
|
||||
await p.waitForTimeout(1000);
|
||||
|
||||
/* ── making one ── */
|
||||
check((await p.locator("[data-add-chapter]").count()) === 1, "a notebook can be given a chapter");
|
||||
await addChapter("Rechnungen");
|
||||
check((await p.locator("[data-chapter-row]").count()) === 1, "and it appears in the list");
|
||||
|
||||
const server = (await api(p, "GET", "/chapters")).body;
|
||||
check(server.length === 1 && server[0].name === "Rechnungen", "the server has it", JSON.stringify(server));
|
||||
|
||||
/* ── filing a note ── */
|
||||
await moveTo("Stromrechnung", "Rechnungen");
|
||||
check(
|
||||
(await titles()).join(",") === "Zählerstand",
|
||||
"a note filed into a chapter leaves the notebook's own list",
|
||||
(await titles()).join(", ")
|
||||
);
|
||||
|
||||
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 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. */
|
||||
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));
|
||||
|
||||
await p.locator("[data-chapter-back]").click();
|
||||
await p.waitForTimeout(1200);
|
||||
check(
|
||||
(await titles()).join(",") === "Zählerstand",
|
||||
"and going back shows the notebook again, without the chapter's notes",
|
||||
(await titles()).join(", ")
|
||||
);
|
||||
|
||||
/* ── 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. */
|
||||
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
|
||||
await p.waitForTimeout(600);
|
||||
await p.getByText("Remove chapter", { exact: true }).click();
|
||||
await p.waitForTimeout(700);
|
||||
check(/stay/i.test(await p.locator("body").innerText()), "removing one says out loud that the notes stay");
|
||||
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");
|
||||
const after = (await titles()).sort().join(",");
|
||||
check(
|
||||
after === "Stromrechnung,Wasserrechnung,Zählerstand",
|
||||
"and every note it held is back in the notebook",
|
||||
after
|
||||
);
|
||||
const freed = (await api(p, "GET", "/notes")).body;
|
||||
check(
|
||||
freed.every((n) => !n.chapter_id),
|
||||
"with nothing on the server still pointing at a chapter that is gone",
|
||||
JSON.stringify(freed.map((n) => [n.title, n.chapter_id]))
|
||||
);
|
||||
} catch (e) {
|
||||
check(false, "suite ran to the end", e.message);
|
||||
} finally {
|
||||
await session?.cleanup();
|
||||
const code = report();
|
||||
await browser.close();
|
||||
process.exit(code);
|
||||
}
|
||||
+29
-7
@@ -134,13 +134,33 @@ try {
|
||||
);
|
||||
|
||||
/* Reordering must not look like editing: a dragged note keeps its place in
|
||||
"last changed", or the two orders would fight each other. */
|
||||
await sortBy("updated");
|
||||
"last changed", or the two orders would fight each other.
|
||||
|
||||
Asked of the timestamps rather than of the order on screen. Three notes made
|
||||
seconds apart routinely share an `updated_at` second — the column has that
|
||||
resolution — and then "which is newest" is a tie broken arbitrarily. The
|
||||
check passed on luck for weeks and started failing on a run that was a
|
||||
fraction faster. */
|
||||
const stamps = async () =>
|
||||
Object.fromEntries((await api(p, "GET", "/notes")).body.map((n) => [n.title, n.updated_at]));
|
||||
|
||||
await sortBy("manual");
|
||||
await p.waitForTimeout(600);
|
||||
const beforeStamps = await stamps();
|
||||
const order = await titles();
|
||||
await drag(rows.nth(2), rows.nth(0));
|
||||
const afterStamps = await stamps();
|
||||
check(
|
||||
(await titles())[0] === "Gamma",
|
||||
"a dragged note did not become the most recently changed one",
|
||||
(await titles()).join(", ")
|
||||
Object.keys(beforeStamps).every((t) => beforeStamps[t] === afterStamps[t]),
|
||||
"dragging a note does not mark it as changed",
|
||||
`${JSON.stringify(beforeStamps)} → ${JSON.stringify(afterStamps)}`
|
||||
);
|
||||
check(
|
||||
(await titles()).join(",") !== order.join(","),
|
||||
"though it did move",
|
||||
`${order.join(",")} → ${(await titles()).join(",")}`
|
||||
);
|
||||
|
||||
await sortBy("manual");
|
||||
await p.waitForTimeout(800);
|
||||
|
||||
@@ -160,6 +180,8 @@ try {
|
||||
);
|
||||
|
||||
/* ── separators ── */
|
||||
// The arrangement as it stands now: rows have been dragged since `arranged`.
|
||||
const beforeSeparator = await titles();
|
||||
await p.locator("[data-add-separator]").click();
|
||||
await p.waitForTimeout(1800);
|
||||
check((await p.locator("[data-separator]").count()) === 1, "a separator can be added");
|
||||
@@ -194,9 +216,9 @@ try {
|
||||
|
||||
const withSeparator = await titles();
|
||||
check(
|
||||
withSeparator.join(",") === arranged.join(","),
|
||||
withSeparator.join(",") === beforeSeparator.join(","),
|
||||
"and adding one did not disturb the arrangement",
|
||||
`${arranged.join(",")} vs ${withSeparator.join(",")}`
|
||||
`${beforeSeparator.join(",")} vs ${withSeparator.join(",")}`
|
||||
);
|
||||
|
||||
// Separators only mean something in a hand-made order; in "newest first" the
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
|
||||
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
|
||||
URL="${INKNOTE_URL:-http://localhost:3000}"
|
||||
SUITES=("$@")
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise offline sync)
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters offline sync)
|
||||
|
||||
mkdir -p shots
|
||||
if [ ! -d node_modules ]; then
|
||||
|
||||
+142
-18
@@ -12,6 +12,7 @@ import { 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";
|
||||
import { NameDialog } from "./ui/NameDialog.jsx";
|
||||
import { ContextMenu } from "./ui/ContextMenu.jsx";
|
||||
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
|
||||
import { AccountMenu } from "./ui/AccountMenu.jsx";
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
canReorder,
|
||||
sortModesFor,
|
||||
sortNotes,
|
||||
separatorsFor,
|
||||
withNewSeparator,
|
||||
} from "./features/notes/noteSort";
|
||||
import { EditorPane } from "./features/notes/EditorPane.jsx";
|
||||
@@ -61,6 +63,10 @@ export default function App() {
|
||||
|
||||
const [activeFolder, setActiveFolder] = useState("dashboard");
|
||||
const [activeNote, setActiveNote] = useState(null);
|
||||
/* Which chapter of the open notebook is being looked into; null is the
|
||||
notebook itself. A chapter is a folder inside a notebook, so this is a
|
||||
second step of "where the user is", not a second notebook. */
|
||||
const [activeChapter, setActiveChapter] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const [showNewNb, setShowNewNb] = useState(false);
|
||||
@@ -82,6 +88,8 @@ export default function App() {
|
||||
const [renamingSeparator, setRenamingSeparator] = useState(null);
|
||||
const [historyFor, setHistoryFor] = useState(null);
|
||||
const [ctxMenu, setCtxMenu] = useState(null);
|
||||
// { title, value, onSave } while a chapter is being named or renamed.
|
||||
const [namingChapter, setNamingChapter] = useState(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState(null);
|
||||
// Bumped on a version restore to force a fresh editor on the restored body.
|
||||
const [restoreEpoch, setRestoreEpoch] = useState(0);
|
||||
@@ -101,8 +109,8 @@ export default function App() {
|
||||
whether a hand-made arrangement is even in effect. Notebooks keep the HTML5
|
||||
hook: they are dragged with a mouse, in the sidebar and on the dashboard. */
|
||||
const rearrangeNotes = useCallback(
|
||||
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId),
|
||||
[data, activeFolder]
|
||||
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, activeChapter),
|
||||
[data, activeFolder, activeChapter]
|
||||
);
|
||||
|
||||
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
|
||||
@@ -151,6 +159,7 @@ export default function App() {
|
||||
const openFolder = useCallback(
|
||||
(id) => {
|
||||
setActiveFolder(id);
|
||||
setActiveChapter(null);
|
||||
setActiveNote(null);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
@@ -186,6 +195,16 @@ export default function App() {
|
||||
|
||||
/* ── Derived note lists ── */
|
||||
const activeNotes = useMemo(() => notes.filter((n) => !n.trashed), [notes]);
|
||||
/* Chapters of the notebook in hand, with how many notes each holds — the
|
||||
count is what makes a chapter worth opening rather than a guess. */
|
||||
const folderChapters = useMemo(() => {
|
||||
const list = data.chapters.filter((c) => c.notebookId === activeFolder);
|
||||
return list.map((c) => ({
|
||||
...c,
|
||||
noteCount: notes.filter((n) => !n.trashed && n.chapterId === c.id).length,
|
||||
}));
|
||||
}, [data.chapters, activeFolder, notes]);
|
||||
const openChapter = folderChapters.find((c) => c.id === activeChapter) || null;
|
||||
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
|
||||
const isTrashView = activeFolder === "trash";
|
||||
|
||||
@@ -211,14 +230,25 @@ export default function App() {
|
||||
/* ── Separators ──
|
||||
* Lines the user draws between notes. They belong to the notebook's own
|
||||
* arrangement, so they are stored on it and only ever shown in manual order. */
|
||||
const separators = activeNotebook?.separators || [];
|
||||
const allSeparators = activeNotebook?.separators || [];
|
||||
/* Only the ones drawn in the list being looked at — the notebook itself, or
|
||||
the chapter that is open. */
|
||||
const separators = useMemo(
|
||||
() => separatorsFor(allSeparators, activeChapter),
|
||||
[allSeparators, activeChapter]
|
||||
);
|
||||
const writeSeparators = (next) =>
|
||||
activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: next });
|
||||
/* Written back whole: the notebook stores one list, and the ones belonging to
|
||||
the other chapters have to survive a change made in this one. */
|
||||
const writeVisible = (visible) =>
|
||||
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== activeChapter), ...visible]);
|
||||
|
||||
const addSeparator = () => writeSeparators(withNewSeparator(separators, `sep-${Date.now().toString(36)}`));
|
||||
const addSeparator = () =>
|
||||
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", activeChapter));
|
||||
const renameSeparator = (id, label) =>
|
||||
writeSeparators(separators.map((s) => (s.id === id ? { ...s, label } : s)));
|
||||
const removeSeparator = (id) => writeSeparators(separators.filter((s) => s.id !== id));
|
||||
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s)));
|
||||
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
|
||||
|
||||
const separatorContextMenu = (e, separator) => {
|
||||
e.preventDefault();
|
||||
@@ -254,7 +284,11 @@ 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 list = activeNotes.filter((n) => n.folderId === activeFolder);
|
||||
else if (activeChapter) 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. */
|
||||
else list = activeNotes.filter((n) => n.folderId === activeFolder && !n.chapterId);
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
// A search reads every note's text, so results keep the index's relevance
|
||||
@@ -268,7 +302,7 @@ export default function App() {
|
||||
return list.filter((n) => n.title.toLowerCase().includes(q));
|
||||
}
|
||||
return sortNotes(list, sortMode);
|
||||
}, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes, sortMode]);
|
||||
}, [activeFolder, activeChapter, searchQuery, search.results, activeNotes, trashedNotes, 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
|
||||
@@ -404,7 +438,11 @@ export default function App() {
|
||||
|
||||
const createNote = () =>
|
||||
withNotebook("New note", async (folderId) => {
|
||||
const note = await data.createNote(folderId);
|
||||
/* A note made while a chapter is open belongs to that chapter — but only
|
||||
if it is a chapter of the notebook being written into, which it is not
|
||||
when the notebook had to be asked for. */
|
||||
const chapterId = folderId === activeFolder ? activeChapter : null;
|
||||
const note = await data.createNote(folderId, chapterId);
|
||||
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
|
||||
openNote(note.id);
|
||||
});
|
||||
@@ -503,16 +541,86 @@ export default function App() {
|
||||
});
|
||||
};
|
||||
|
||||
/** "Move to..." lists every notebook except the one the note is already in. */
|
||||
/**
|
||||
* "Move to..." — the other notebooks, and the chapters of this one.
|
||||
*
|
||||
* Both in one list because both answer the same question: where should this
|
||||
* note live. A chapter of the notebook it is already in is one step; another
|
||||
* notebook is another, and takes it out of whatever chapter it was in, since
|
||||
* a chapter belongs to the book it is in.
|
||||
*/
|
||||
const moveSubmenu = (note) => {
|
||||
const targets = notebooks
|
||||
.filter((nb) => nb.id !== note.folderId)
|
||||
.map((nb) => ({
|
||||
label: nb.name,
|
||||
colorDot: coverBackground(nb.cover, nb.coverPattern),
|
||||
action: () => data.moveNote(note.id, nb.id),
|
||||
}));
|
||||
return targets.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
|
||||
const here = data.chapters.filter((c) => c.notebookId === note.folderId && c.id !== note.chapterId);
|
||||
const targets = [
|
||||
...(note.chapterId
|
||||
? [{ label: "The notebook itself", action: () => data.moveNote(note.id, note.folderId, null) }]
|
||||
: []),
|
||||
...here.map((c) => ({
|
||||
label: c.name,
|
||||
icon: I.books,
|
||||
action: () => data.moveNote(note.id, note.folderId, c.id),
|
||||
})),
|
||||
...(here.length || note.chapterId ? [{ divider: true }] : []),
|
||||
...notebooks
|
||||
.filter((nb) => nb.id !== note.folderId)
|
||||
.map((nb) => ({
|
||||
label: nb.name,
|
||||
colorDot: coverBackground(nb.cover, nb.coverPattern),
|
||||
action: () => data.moveNote(note.id, nb.id, null),
|
||||
})),
|
||||
];
|
||||
// A lone divider is not a menu.
|
||||
const usable = targets.filter((t) => !t.divider);
|
||||
return usable.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
|
||||
};
|
||||
|
||||
/* ── Chapters ──
|
||||
* Folders inside a notebook. Named on creation, because a chapter with no name
|
||||
* is a row nobody can tell from the next one — unlike a separator, which is a
|
||||
* line and reads perfectly well unnamed. */
|
||||
const addChapter = () => {
|
||||
if (!activeNotebook) return;
|
||||
setNamingChapter({ title: "New chapter", value: "", onSave: (name) => data.createChapter(activeNotebook.id, name) });
|
||||
};
|
||||
|
||||
const chapterContextMenu = (e, chapter) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCtxMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
items: [
|
||||
{
|
||||
label: "Rename",
|
||||
icon: I.edit,
|
||||
action: () =>
|
||||
setNamingChapter({
|
||||
title: "Rename chapter",
|
||||
value: chapter.name,
|
||||
onSave: (name) => data.renameChapter(chapter.id, name),
|
||||
}),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
label: "Remove chapter",
|
||||
icon: I.trash,
|
||||
danger: true,
|
||||
action: () =>
|
||||
setConfirmDialog({
|
||||
title: "Remove chapter?",
|
||||
message: `"${chapter.name}" is removed. Its ${chapter.noteCount} note${
|
||||
chapter.noteCount === 1 ? "" : "s"
|
||||
} stay — they go back to the notebook.`,
|
||||
confirmLabel: "Remove",
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
if (activeChapter === chapter.id) setActiveChapter(null);
|
||||
void data.deleteChapter(chapter.id);
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const noteContextMenu = (e, note) => {
|
||||
@@ -663,6 +771,11 @@ export default function App() {
|
||||
onStartRenameSeparator={setRenamingSeparator}
|
||||
onRenameSeparator={renameSeparator}
|
||||
onSeparatorContextMenu={separatorContextMenu}
|
||||
chapters={folderChapters}
|
||||
openChapter={openChapter}
|
||||
onOpenChapter={setActiveChapter}
|
||||
onAddChapter={activeNotebook ? addChapter : null}
|
||||
onChapterContextMenu={chapterContextMenu}
|
||||
onReorder={rearrangeNotes}
|
||||
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
||||
onSelectNote={openNote}
|
||||
@@ -787,6 +900,17 @@ export default function App() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{namingChapter && (
|
||||
<NameDialog
|
||||
title={namingChapter.title}
|
||||
value={namingChapter.value}
|
||||
label="Chapter name"
|
||||
confirmLabel={namingChapter.value ? "Rename" : "Create"}
|
||||
onSave={namingChapter.onSave}
|
||||
onClose={() => setNamingChapter(null)}
|
||||
dark={dark}
|
||||
/>
|
||||
)}
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
title={confirmDialog.title}
|
||||
|
||||
@@ -101,7 +101,9 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 8px 6px",
|
||||
/* Room above it, so it reads as the end of what came before rather than
|
||||
as decoration attached to the next note. */
|
||||
padding: "14px 8px 8px",
|
||||
cursor: "grab",
|
||||
}}
|
||||
>
|
||||
@@ -110,7 +112,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
keeps one width whether it is showing a label or an input. Sizing it to
|
||||
its contents made the whole row jump sideways the moment renaming
|
||||
started, which is exactly when the eye is on it. */}
|
||||
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
|
||||
<div style={{ height: 2, borderRadius: 2, background: T.faint, flex: 1, minWidth: 8 }} />
|
||||
<div style={{ flex: "0 0 auto", width: MIDDLE_W, display: "flex" }}>
|
||||
{editing ? (
|
||||
<input
|
||||
@@ -133,10 +135,10 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
color: T.textSec,
|
||||
fontSize: 10.5,
|
||||
color: T.text,
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
letterSpacing: 0.8,
|
||||
textAlign: "center",
|
||||
// Matches the button's box exactly, so nothing shifts vertically
|
||||
// either when the input replaces it.
|
||||
@@ -151,10 +153,10 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
width: "100%",
|
||||
border: "1px solid transparent",
|
||||
background: "transparent",
|
||||
color: separator.label ? T.textSec : T.faint,
|
||||
fontSize: 10.5,
|
||||
color: separator.label ? T.text : T.muted,
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
letterSpacing: 0.8,
|
||||
textTransform: "uppercase",
|
||||
textAlign: "center",
|
||||
padding: "1px 4px",
|
||||
@@ -167,11 +169,59 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
|
||||
<div style={{ height: 2, borderRadius: 2, background: T.faint, flex: 1, minWidth: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A chapter, in the list of the notebook it belongs to.
|
||||
*
|
||||
* Shown above the loose notes rather than mixed in with them: a chapter is a
|
||||
* 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 }) {
|
||||
return (
|
||||
<button
|
||||
data-chapter-row={chapter.id}
|
||||
onClick={() => onOpen(chapter.id)}
|
||||
onContextMenu={(e) => onContextMenu(e, chapter)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.borderLight}`,
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
marginBottom: 4,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", color: T.accent, flexShrink: 0 }}>{I.books}</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How this notebook's notes are ordered.
|
||||
*
|
||||
@@ -368,6 +418,11 @@ export function NoteList({
|
||||
onStartRenameSeparator,
|
||||
onRenameSeparator,
|
||||
onSeparatorContextMenu,
|
||||
chapters,
|
||||
openChapter,
|
||||
onOpenChapter,
|
||||
onAddChapter,
|
||||
onChapterContextMenu,
|
||||
onReorder,
|
||||
onToggleSidebar,
|
||||
onSelectNote,
|
||||
@@ -381,6 +436,11 @@ export function NoteList({
|
||||
browsers never fire from touch — on a phone the arrangement could not be
|
||||
changed at all, with nothing on screen to say why. */
|
||||
const dnd = useReorderDrag(!!reorderable, onReorder);
|
||||
/* Chapters divide a notebook, so they are offered in one — not in "All Notes"
|
||||
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;
|
||||
const shownChapters = openChapter ? [] : chapters || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -405,8 +465,13 @@ export function NoteList({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
{/* Inside a chapter this is the way back out to the notebook — which
|
||||
on a phone is the only way, since the chapter fills the screen. */}
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
onClick={openChapter ? () => onOpenChapter(null) : onToggleSidebar}
|
||||
title={openChapter ? "Back to the notebook" : "Menu"}
|
||||
aria-label={openChapter ? "Back to the notebook" : "Menu"}
|
||||
data-chapter-back={openChapter ? "on" : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -417,10 +482,10 @@ export function NoteList({
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: T.muted,
|
||||
color: openChapter ? T.accent : T.muted,
|
||||
}}
|
||||
>
|
||||
{I.menu}
|
||||
{openChapter ? I.back : I.menu}
|
||||
</button>
|
||||
<h2
|
||||
style={{
|
||||
@@ -434,7 +499,7 @@ export function NoteList({
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
{openChapter ? openChapter.name : title}
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||
@@ -488,7 +553,7 @@ export function NoteList({
|
||||
onQueryChange("");
|
||||
e.currentTarget.blur();
|
||||
}}
|
||||
placeholder={`Search in ${title}...`}
|
||||
placeholder={`Search in ${openChapter ? openChapter.name : title}...`}
|
||||
aria-label="Search in this notebook"
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -506,15 +571,40 @@ export function NoteList({
|
||||
|
||||
{/* Just the one control. The sentence that used to sit here explained the
|
||||
order the sort menu already names, on every visit, forever. */}
|
||||
{reorderable && (
|
||||
{(reorderable || canAddChapter) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 6,
|
||||
padding: "5px 10px",
|
||||
borderBottom: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
{/* A chapter divides the notebook; a separator divides a list. They
|
||||
sit together because both are "add something that is not a note",
|
||||
and apart from the + button, which makes notes. */}
|
||||
{canAddChapter && (
|
||||
<button
|
||||
onClick={onAddChapter}
|
||||
data-add-chapter
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: T.muted,
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
+ Chapter
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onAddSeparator}
|
||||
data-add-separator
|
||||
@@ -583,6 +673,18 @@ 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) => {
|
||||
const showDropLine = reorderable && dnd.overId === entry.id;
|
||||
/* The whole row, indicator included, is the drop target.
|
||||
|
||||
@@ -174,10 +174,23 @@ export function reindexEntries(
|
||||
export const END_ID = "__end__";
|
||||
|
||||
/** A new separator, added at the top where it can be seen and then dragged. */
|
||||
export const withNewSeparator = (separators: Separator[], id: string, label = ""): Separator[] => [
|
||||
{ id, label, order: 0 },
|
||||
...separators,
|
||||
];
|
||||
export const withNewSeparator = (
|
||||
separators: Separator[],
|
||||
id: string,
|
||||
label = "",
|
||||
chapterId: string | null = null
|
||||
): Separator[] => [{ id, label, order: 0, chapterId }, ...separators];
|
||||
|
||||
/**
|
||||
* The separators belonging to one list.
|
||||
*
|
||||
* A separator's `order` counts the notes before it, which only means anything
|
||||
* inside the list it was drawn in — the notebook itself, or one of its chapters.
|
||||
* Showing all of a notebook's separators in every chapter would put lines in
|
||||
* places nobody drew them.
|
||||
*/
|
||||
export const separatorsFor = (separators: Separator[], chapterId: string | null): Separator[] =>
|
||||
separators.filter((s) => (s.chapterId ?? null) === chapterId);
|
||||
|
||||
/**
|
||||
* The full notebook renumbered after a note was dragged onto another.
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as searchIndex from "../search";
|
||||
import { sameStrokeDoc } from "../features/drawing/strokes.js";
|
||||
import { reindexEntries } from "../features/notes/noteSort";
|
||||
import { extractOnImport } from "../features/pdf/pdfText";
|
||||
import type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
|
||||
import type { Backlink, Chapter, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
|
||||
|
||||
/** How often the whole account is reconciled with the server. */
|
||||
const FULL_SYNC_MS = 30_000;
|
||||
@@ -36,7 +36,7 @@ export interface NoteData {
|
||||
reload: () => Promise<void>;
|
||||
reset: () => void;
|
||||
|
||||
createNote: (folderId: string) => Promise<NoteMeta>;
|
||||
createNote: (folderId: string, chapterId?: string | null) => Promise<NoteMeta>;
|
||||
importPdf: (folderId: string, file: File) => Promise<NoteMeta>;
|
||||
updateNote: (id: string, edit: NoteEdit) => void;
|
||||
setNoteContent: (id: string, content: NoteContent) => void;
|
||||
@@ -45,12 +45,16 @@ export interface NoteData {
|
||||
restoreNote: (id: string) => Promise<void>;
|
||||
permanentDeleteNote: (id: string) => Promise<void>;
|
||||
emptyTrash: () => Promise<void>;
|
||||
moveNote: (id: string, folderId: string) => Promise<void>;
|
||||
moveNote: (id: string, folderId: string, chapterId?: string | null) => Promise<void>;
|
||||
/** Drag-to-reorder within a notebook; moves notes and separators alike. */
|
||||
rearrange: (notebookId: string, dragId: string, targetId: string) => void;
|
||||
rearrange: (notebookId: string, dragId: string, targetId: string, chapterId?: string | null) => void;
|
||||
|
||||
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
|
||||
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
|
||||
chapters: Chapter[];
|
||||
createChapter: (notebookId: string, name: string) => Promise<Chapter>;
|
||||
renameChapter: (id: string, name: string) => Promise<void>;
|
||||
deleteChapter: (id: string) => Promise<void>;
|
||||
deleteNotebook: (id: string) => Promise<void>;
|
||||
reorderNotebooks: (dragId: string, targetId: string) => void;
|
||||
}
|
||||
@@ -67,6 +71,7 @@ export interface NoteData {
|
||||
export function useNoteData(user: User | null, activeNote: string | null): NoteData {
|
||||
const [notes, setNotes] = useState<NoteMeta[]>([]);
|
||||
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
|
||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||
const [noteContents, setNoteContents] = useState<Record<string, NoteContent>>({});
|
||||
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
||||
|
||||
@@ -79,12 +84,14 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setNotebooks(await sync.getNotebooks());
|
||||
setChapters(await sync.getChapters());
|
||||
setNotes(await sync.getNotes());
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setNotes([]);
|
||||
setNotebooks([]);
|
||||
setChapters([]);
|
||||
setNoteContents({});
|
||||
setBacklinks([]);
|
||||
}, []);
|
||||
@@ -185,8 +192,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
|
||||
/* ── Notes ── */
|
||||
|
||||
const createNote = useCallback(async (folderId: string) => {
|
||||
const note = await sync.createNote(folderId);
|
||||
const createNote = useCallback(async (folderId: string, chapterId: string | null = null) => {
|
||||
const note = await sync.createNote(folderId, chapterId);
|
||||
setNotes((p) => [note, ...p]);
|
||||
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
|
||||
return note;
|
||||
@@ -266,9 +273,9 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
setNotes((p) => p.filter((n) => !n.trashed));
|
||||
}, []);
|
||||
|
||||
const moveNote = useCallback(async (id: string, folderId: string) => {
|
||||
await sync.moveNote(id, folderId);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId } : n)));
|
||||
const moveNote = useCallback(async (id: string, folderId: string, chapterId: string | null = null) => {
|
||||
await sync.moveNote(id, folderId, chapterId);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId, chapterId } : n)));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -279,31 +286,82 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
* taken from the store rather than from whatever the list is showing: a search
|
||||
* or a filter must never be able to renumber the notes it is hiding.
|
||||
*/
|
||||
const rearrange = useCallback((notebookId: string, dragId: string, targetId: string) => {
|
||||
/* `chapterId` scopes the arrangement to one list: the notebook itself, or one
|
||||
of its chapters. A note's place is only meaningful beside the notes it is
|
||||
shown with, and the separators of the other chapters must come through the
|
||||
write untouched. */
|
||||
const rearrange = useCallback(
|
||||
(notebookId: string, dragId: string, targetId: string, chapterId: string | null = null) => {
|
||||
const notebook = notebooksRef.current.find((nb) => nb.id === notebookId);
|
||||
if (!notebook) return;
|
||||
const inNotebook = notesRef.current.filter((n) => !n.trashed && n.folderId === notebookId);
|
||||
const inNotebook = notesRef.current.filter(
|
||||
(n) => !n.trashed && n.folderId === notebookId && (n.chapterId ?? null) === chapterId
|
||||
);
|
||||
const all = notebook.separators || [];
|
||||
const mine = all.filter((sep) => (sep.chapterId ?? null) === chapterId);
|
||||
const others = all.filter((sep) => (sep.chapterId ?? null) !== chapterId);
|
||||
|
||||
const next = reindexEntries(inNotebook, notebook.separators || [], dragId, targetId);
|
||||
const next = reindexEntries(inNotebook, mine, dragId, targetId);
|
||||
if (!next) return;
|
||||
const merged = [...others, ...next.separators.map((sep) => ({ ...sep, chapterId }))];
|
||||
|
||||
const position = new Map(next.noteIds.map((id, i) => [id, i]));
|
||||
setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n)));
|
||||
void sync.reorderNotes(next.noteIds);
|
||||
|
||||
// Only written when it actually changed: a plain note drag leaves the
|
||||
// separators alone, and a pointless notebook write would sync for nothing.
|
||||
const before = JSON.stringify(notebook.separators || []);
|
||||
if (JSON.stringify(next.separators) !== before) {
|
||||
setNotebooks((p) =>
|
||||
p.map((nb) => (nb.id === notebookId ? { ...nb, separators: next.separators } : nb))
|
||||
);
|
||||
void sync.updateNotebook(notebookId, { separators: next.separators });
|
||||
/* Only written when it actually changed.
|
||||
*
|
||||
* A plain note drag leaves the separators alone, and a write for nothing
|
||||
* costs more than a request: it reloads the notes, which reshuffles any that
|
||||
* share an `updatedAt` second — so "last changed" quietly came out in a
|
||||
* different order after every drag.
|
||||
*
|
||||
* Compared as a set rather than as an array, because the merge above puts
|
||||
* the other chapters' separators first and that says nothing about whether
|
||||
* anything changed. */
|
||||
const fingerprint = (list: typeof all) =>
|
||||
list
|
||||
.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.chapterId ?? ""}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
const before = fingerprint(all);
|
||||
if (fingerprint(merged) !== before) {
|
||||
setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb)));
|
||||
void sync.updateNotebook(notebookId, { separators: merged });
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/* ── Notebooks ── */
|
||||
|
||||
/* ── Chapters ──
|
||||
*
|
||||
* Folders inside a notebook. Deleting one never deletes its notes: they come
|
||||
* back to the notebook, which is why the local copy is refreshed rather than
|
||||
* having the removal applied by hand here.
|
||||
*/
|
||||
|
||||
const createChapter = useCallback(async (notebookId: string, name: string) => {
|
||||
const chapter = await sync.createChapter(notebookId, name);
|
||||
setChapters((p) => [...p, chapter]);
|
||||
return chapter;
|
||||
}, []);
|
||||
|
||||
const renameChapter = useCallback(async (id: string, name: string) => {
|
||||
setChapters((p) => p.map((c) => (c.id === id ? { ...c, name } : c)));
|
||||
await sync.updateChapter(id, { name });
|
||||
}, []);
|
||||
|
||||
const deleteChapter = useCallback(
|
||||
async (id: string) => {
|
||||
await sync.deleteChapter(id);
|
||||
setChapters((p) => p.filter((c) => c.id !== id));
|
||||
setNotes((p) => p.map((n) => (n.chapterId === id ? { ...n, chapterId: null } : n)));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
|
||||
const nb = await sync.createNotebook(fields);
|
||||
setNotebooks((p) => [...p, nb]);
|
||||
@@ -355,6 +413,10 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
rearrange,
|
||||
createNotebook,
|
||||
updateNotebook,
|
||||
chapters,
|
||||
createChapter,
|
||||
renameChapter,
|
||||
deleteChapter,
|
||||
deleteNotebook,
|
||||
reorderNotebooks,
|
||||
};
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
* pushes queued changes to the server when a connection is available.
|
||||
*/
|
||||
|
||||
import type { NoteMeta, NoteContent, Notebook, StrokeDoc, QueueEntry } from "./types";
|
||||
import type { Chapter, NoteMeta, NoteContent, Notebook, StrokeDoc, QueueEntry } from "./types";
|
||||
|
||||
const DB_NAME = "inknote";
|
||||
const DB_VERSION = 3;
|
||||
const DB_VERSION = 4;
|
||||
|
||||
type StoreName = "notebooks" | "notes" | "noteContents" | "syncQueue" | "meta" | "attachments";
|
||||
type StoreName = "notebooks" | "chapters" | "notes" | "noteContents" | "syncQueue" | "meta" | "attachments";
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
|
||||
@@ -53,6 +53,12 @@ function open(): Promise<IDBDatabase> {
|
||||
if (!d.objectStoreNames.contains("attachments")) {
|
||||
d.createObjectStore("attachments", { keyPath: "id" });
|
||||
}
|
||||
|
||||
// Chapters — folders inside a notebook.
|
||||
if (!d.objectStoreNames.contains("chapters")) {
|
||||
const ch = d.createObjectStore("chapters", { keyPath: "id" });
|
||||
ch.createIndex("notebookId", "notebookId");
|
||||
}
|
||||
};
|
||||
|
||||
req.onsuccess = (e) => {
|
||||
@@ -101,6 +107,29 @@ export async function deleteMeta(key: string): Promise<void> {
|
||||
await reqToPromise(s.delete(key));
|
||||
}
|
||||
|
||||
/* ─── Chapters ─── */
|
||||
|
||||
export async function getAllChapters(): Promise<Chapter[]> {
|
||||
const s = await store("chapters", "readonly");
|
||||
return reqToPromise<Chapter[]>(s.getAll());
|
||||
}
|
||||
|
||||
export async function putChapter(c: Chapter): Promise<void> {
|
||||
const s = await store("chapters", "readwrite");
|
||||
await reqToPromise(s.put(c));
|
||||
}
|
||||
|
||||
export async function putChapters(list: Chapter[]): Promise<void> {
|
||||
const s = await store("chapters", "readwrite");
|
||||
for (const c of list) s.put(c);
|
||||
await txDone(s.transaction);
|
||||
}
|
||||
|
||||
export async function deleteChapter(id: string): Promise<void> {
|
||||
const s = await store("chapters", "readwrite");
|
||||
await reqToPromise(s.delete(id));
|
||||
}
|
||||
|
||||
/* ─── Notebooks ─── */
|
||||
export async function getAllNotebooks(): Promise<Notebook[]> {
|
||||
const s = await store("notebooks", "readonly");
|
||||
|
||||
+171
-7
@@ -8,6 +8,7 @@
|
||||
import * as db from "./offlineStore";
|
||||
import type {
|
||||
Backlink,
|
||||
Chapter,
|
||||
ManagedUser,
|
||||
NoteContent,
|
||||
NoteMeta,
|
||||
@@ -78,10 +79,18 @@ interface ServerNotebook {
|
||||
separators?: string | null;
|
||||
}
|
||||
|
||||
interface ServerChapter {
|
||||
id: string;
|
||||
notebook_id: string;
|
||||
name: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
interface ServerNote {
|
||||
id: string;
|
||||
title: string;
|
||||
notebook_id: string | null;
|
||||
chapter_id?: string | null;
|
||||
starred?: number | boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
@@ -108,6 +117,14 @@ export interface NoteUpdate {
|
||||
title?: string;
|
||||
content?: string;
|
||||
notebookId?: string | null;
|
||||
/**
|
||||
* Which chapter the note is filed in; `null` means the notebook itself.
|
||||
*
|
||||
* Meaningful as null, so it must not be dropped the way an absent field is —
|
||||
* "take it out of its chapter" and "leave it where it is" are different
|
||||
* instructions and both have to be sendable.
|
||||
*/
|
||||
chapterId?: string | null;
|
||||
starred?: boolean;
|
||||
links?: string[];
|
||||
/** Attachments the body embeds, so ones nothing uses can be released. */
|
||||
@@ -404,6 +421,14 @@ export async function fullSync(): Promise<void> {
|
||||
try {
|
||||
await flushQueue();
|
||||
|
||||
// Chapters first: the notes arriving below point at them.
|
||||
try {
|
||||
await fetchChapters();
|
||||
} catch (e) {
|
||||
if (isAuthError(e)) throw e;
|
||||
// An older server has no /chapters. Everything else still syncs.
|
||||
}
|
||||
|
||||
const localNbs = await db.getAllNotebooks();
|
||||
const serverNbs = await api.get<ServerNotebook[]>("/notebooks");
|
||||
const nbs: Notebook[] = serverNbs.map((nb) => ({
|
||||
@@ -504,7 +529,13 @@ type QueuedAction =
|
||||
};
|
||||
}
|
||||
| { action: "deleteNotebook"; data: { id: string } }
|
||||
| { action: "createNote"; data: { localId: string; title: string; folderId: string | null } }
|
||||
| { action: "createChapter"; data: { localId: string; notebookId: string; name: string } }
|
||||
| { action: "updateChapter"; data: { id: string; name?: string; sortOrder?: number } }
|
||||
| { action: "deleteChapter"; data: { id: string } }
|
||||
| {
|
||||
action: "createNote";
|
||||
data: { localId: string; title: string; folderId: string | null; chapterId?: string | null };
|
||||
}
|
||||
| { action: "updateNote"; data: { id: string; updates: NoteUpdate } }
|
||||
| { action: "updateDrawing"; data: { id: string; drawingStrokes: StrokeDoc | null } }
|
||||
| { action: "trashNote"; data: { id: string } }
|
||||
@@ -607,12 +638,40 @@ async function processEntry(entry: QueueEntry): Promise<void> {
|
||||
case "deleteNotebook":
|
||||
await api.del(`/notebooks/${a.data.id}`);
|
||||
break;
|
||||
case "createChapter": {
|
||||
const d = a.data;
|
||||
const r = await api.post<{ id: string }>("/chapters", { notebookId: d.notebookId, name: d.name });
|
||||
if (r.id !== d.localId) {
|
||||
const local = (await db.getAllChapters()).find((c) => c.id === d.localId);
|
||||
await db.deleteChapter(d.localId);
|
||||
await db.putChapter({
|
||||
id: r.id,
|
||||
notebookId: d.notebookId,
|
||||
name: d.name,
|
||||
sortOrder: local?.sortOrder ?? 0,
|
||||
});
|
||||
// Notes filed into it while it was local, and anything still queued
|
||||
// behind this create — both address a name only this device knew.
|
||||
for (const n of await db.getAllNotes()) {
|
||||
if (n.chapterId === d.localId) await db.putNote({ ...n, chapterId: r.id });
|
||||
}
|
||||
await db.repointQueue(d.localId, r.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "updateChapter":
|
||||
await api.put(`/chapters/${a.data.id}`, { name: a.data.name, sortOrder: a.data.sortOrder });
|
||||
break;
|
||||
case "deleteChapter":
|
||||
await api.del(`/chapters/${a.data.id}`);
|
||||
break;
|
||||
case "createNote": {
|
||||
const d = a.data;
|
||||
const content = await db.getNoteContent(d.localId);
|
||||
const r = await api.post<{ id: string }>("/notes", {
|
||||
title: d.title,
|
||||
notebookId: d.folderId,
|
||||
chapterId: d.chapterId ?? null,
|
||||
content: content?.content || "",
|
||||
});
|
||||
if (r.id !== d.localId) {
|
||||
@@ -715,6 +774,93 @@ export async function getNotebooks(): Promise<Notebook[]> {
|
||||
return db.getAllNotebooks();
|
||||
}
|
||||
|
||||
/* ─── Chapters ───
|
||||
*
|
||||
* Folders inside a notebook. They ride the same road as notebooks: written
|
||||
* locally first so the app works offline, queued when there is no connection,
|
||||
* and repointed when the server names a locally created one something else.
|
||||
*/
|
||||
|
||||
/** The local copy, in the order they were made. */
|
||||
export async function getChapters(): Promise<Chapter[]> {
|
||||
return (await db.getAllChapters()).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
||||
}
|
||||
|
||||
export async function fetchChapters(): Promise<Chapter[]> {
|
||||
const rows = await api.get<ServerChapter[]>("/chapters");
|
||||
const chapters: Chapter[] = rows.map((c) => ({
|
||||
id: c.id,
|
||||
notebookId: c.notebook_id,
|
||||
name: c.name,
|
||||
sortOrder: c.sort_order || 0,
|
||||
}));
|
||||
const local = await db.getAllChapters();
|
||||
// Anything made offline is kept: its create is still in the queue, and the
|
||||
// server cannot know about it yet.
|
||||
const server = new Set(chapters.map((c) => c.id));
|
||||
const pending = local.filter((c) => c._pendingCreate && !server.has(c.id));
|
||||
await db.putChapters([...chapters, ...pending]);
|
||||
return [...chapters, ...pending];
|
||||
}
|
||||
|
||||
export async function createChapter(notebookId: string, name: string): Promise<Chapter> {
|
||||
const sortOrder = (await db.getAllChapters()).filter((c) => c.notebookId === notebookId).length;
|
||||
if (_online && api.token) {
|
||||
try {
|
||||
const r = await api.post<{ id: string }>("/chapters", { notebookId, name });
|
||||
const chapter: Chapter = { id: r.id, notebookId, name, sortOrder };
|
||||
await db.putChapter(chapter);
|
||||
notifyData("chapter", chapter);
|
||||
return chapter;
|
||||
} catch (e) {
|
||||
if (isAuthError(e)) throw e;
|
||||
}
|
||||
}
|
||||
const lid = "ch_" + Math.random().toString(36).slice(2, 10);
|
||||
const chapter: Chapter = { id: lid, notebookId, name, sortOrder, _pendingCreate: true };
|
||||
await db.putChapter(chapter);
|
||||
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name } });
|
||||
notifyData("chapter", chapter);
|
||||
notifyStatus("pending");
|
||||
return chapter;
|
||||
}
|
||||
|
||||
export async function updateChapter(id: string, updates: { name?: string; sortOrder?: number }): Promise<void> {
|
||||
const chapter = (await db.getAllChapters()).find((c) => c.id === id);
|
||||
if (!chapter) return;
|
||||
await db.putChapter({ ...chapter, ...updates });
|
||||
notifyData("chapter", { ...chapter, ...updates });
|
||||
if (_online && api.token) {
|
||||
try {
|
||||
await api.put(`/chapters/${id}`, updates);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (isAuthError(e)) throw e;
|
||||
}
|
||||
}
|
||||
await enqueue({ action: "updateChapter", data: { id, ...updates } });
|
||||
notifyStatus("pending");
|
||||
}
|
||||
|
||||
/** Removes a chapter. Its notes are not removed — they return to the notebook. */
|
||||
export async function deleteChapter(id: string): Promise<void> {
|
||||
await db.deleteChapter(id);
|
||||
for (const n of await db.getAllNotes()) {
|
||||
if (n.chapterId === id) await db.putNote({ ...n, chapterId: null });
|
||||
}
|
||||
notifyData("chapter", null);
|
||||
if (_online && api.token) {
|
||||
try {
|
||||
await api.del(`/chapters/${id}`);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (isAuthError(e)) throw e;
|
||||
}
|
||||
}
|
||||
await enqueue({ action: "deleteChapter", data: { id } });
|
||||
notifyStatus("pending");
|
||||
}
|
||||
|
||||
export async function createNotebook({
|
||||
name,
|
||||
cover,
|
||||
@@ -909,11 +1055,16 @@ export async function loadNoteContent(
|
||||
return shapeContent(await db.getNoteContent(id));
|
||||
}
|
||||
|
||||
export async function createNote(folderId: string | null): Promise<NoteMeta> {
|
||||
/** A note made while a chapter is open belongs to that chapter. */
|
||||
export async function createNote(
|
||||
folderId: string | null,
|
||||
chapterId: string | null = null
|
||||
): Promise<NoteMeta> {
|
||||
const ts = new Date().toISOString();
|
||||
const base = {
|
||||
title: "Untitled Note",
|
||||
folderId,
|
||||
chapterId,
|
||||
starred: false,
|
||||
trashed: false,
|
||||
createdAt: ts,
|
||||
@@ -925,6 +1076,7 @@ export async function createNote(folderId: string | null): Promise<NoteMeta> {
|
||||
const r = await api.post<{ id: string }>("/notes", {
|
||||
title: base.title,
|
||||
notebookId: folderId,
|
||||
chapterId,
|
||||
content: "",
|
||||
});
|
||||
const note: NoteMeta = { id: r.id, ...base };
|
||||
@@ -941,7 +1093,7 @@ export async function createNote(folderId: string | null): Promise<NoteMeta> {
|
||||
const note: NoteMeta = { id: lid, ...base, _pendingCreate: true };
|
||||
await db.putNote(note);
|
||||
await db.putNoteContent(lid, "", null);
|
||||
await enqueue({ action: "createNote", data: { localId: lid, title: base.title, folderId } });
|
||||
await enqueue({ action: "createNote", data: { localId: lid, title: base.title, folderId, chapterId } });
|
||||
notifyData("note", note);
|
||||
notifyStatus("pending");
|
||||
return note;
|
||||
@@ -1041,17 +1193,29 @@ export async function toggleNoteStar(id: string): Promise<boolean> {
|
||||
return updated.starred;
|
||||
}
|
||||
|
||||
export async function moveNote(id: string, folderId: string | null): Promise<void> {
|
||||
/**
|
||||
* Files a note in a notebook, and optionally in a chapter of it.
|
||||
*
|
||||
* Moving to another notebook clears the chapter unless a new one is named: a
|
||||
* chapter belongs to the book it is in, so keeping it would file the note
|
||||
* somewhere that cannot be reached from either.
|
||||
*/
|
||||
export async function moveNote(
|
||||
id: string,
|
||||
folderId: string | null,
|
||||
chapterId: string | null = null
|
||||
): Promise<void> {
|
||||
const note = await db.getNote(id);
|
||||
if (!note) return;
|
||||
const updated: NoteMeta = { ...note, folderId, updatedAt: new Date().toISOString() };
|
||||
const updated: NoteMeta = { ...note, folderId, chapterId, updatedAt: new Date().toISOString() };
|
||||
await db.putNote(updated);
|
||||
notifyData("note", updated);
|
||||
|
||||
const updates: NoteUpdate = { notebookId: folderId, chapterId };
|
||||
if (_online && api.token && !updated._pendingCreate) {
|
||||
api.put(`/notes/${id}`, { notebookId: folderId }).catch(() => {});
|
||||
api.put(`/notes/${id}`, updates).catch(() => {});
|
||||
} else {
|
||||
await enqueue({ action: "updateNote", data: { id, updates: { notebookId: folderId } } });
|
||||
await enqueue({ action: "updateNote", data: { id, updates } });
|
||||
notifyStatus("pending");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface NoteMeta {
|
||||
/** Attachment holding the PDF, for notes of type "pdf". */
|
||||
pdfAttachmentId?: string | null;
|
||||
folderId: string | null;
|
||||
/** Chapter within the notebook; null means the notebook itself. */
|
||||
chapterId?: string | null;
|
||||
starred: boolean;
|
||||
trashed: boolean;
|
||||
createdAt: string;
|
||||
@@ -84,6 +86,28 @@ export interface Separator {
|
||||
id: string;
|
||||
label: string;
|
||||
order: number;
|
||||
/**
|
||||
* The list it was drawn in: a chapter of the notebook, or null for the
|
||||
* notebook itself. `order` counts the notes before it, which only means
|
||||
* anything within one list.
|
||||
*/
|
||||
chapterId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A folder inside a notebook.
|
||||
*
|
||||
* A note is in exactly one chapter, or in none — which means the notebook
|
||||
* itself. Deleting a chapter never deletes its notes; they come back to the
|
||||
* notebook, where they can be seen and filed again.
|
||||
*/
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
notebookId: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
/** Set while a chapter created offline has not reached the server yet. */
|
||||
_pendingCreate?: boolean;
|
||||
}
|
||||
|
||||
export interface Notebook {
|
||||
|
||||
@@ -67,8 +67,11 @@ export function ContextMenu({ x, y, items, onClose, dark }) {
|
||||
padding: 4,
|
||||
minWidth: 190,
|
||||
maxWidth: "calc(100vw - 16px)",
|
||||
maxHeight: "calc(100vh - 16px)",
|
||||
overflowY: "auto",
|
||||
/* No `overflow` here. A submenu flies out sideways, and any scrolling
|
||||
ancestor clips it away — which is exactly what happened: the list of
|
||||
notebooks was cut off and every click on it landed on the note list
|
||||
behind. The submenu does its own scrolling instead, and these menus
|
||||
are half a dozen items long. */
|
||||
fontFamily: "'DM Sans',sans-serif",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -150,7 +153,9 @@ function SubMenu({ item, T, onClose }) {
|
||||
if (!row) return;
|
||||
// 180 is this submenu's minimum width; going off the right edge means it
|
||||
// belongs on the other side.
|
||||
setSide(row.right + 180 > window.innerWidth - 8 ? "left" : "right");
|
||||
// `left: 100%` places it to the right of the row, and `right: 100%` to the
|
||||
// left — so the property is the opposite of the side it ends up on.
|
||||
setSide(row.right + 180 > window.innerWidth - 8 ? "right" : "left");
|
||||
}, [open]);
|
||||
|
||||
const panel = (
|
||||
@@ -211,7 +216,7 @@ function SubMenu({ item, T, onClose }) {
|
||||
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
|
||||
{item.label}
|
||||
<span style={{ marginLeft: "auto", fontSize: 10, opacity: 0.5 }}>
|
||||
{inline && open ? "▾" : side === "left" ? "◂" : "▸"}
|
||||
{inline && open ? "▾" : side === "right" ? "◂" : "▸"}
|
||||
</span>
|
||||
</div>
|
||||
{open && panel}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Asking for one name.
|
||||
*
|
||||
* A real dialog rather than `window.prompt`, for the same reasons the link
|
||||
* dialog is one: the browser's box cannot be styled, cannot be reached by the
|
||||
* tests, and on a phone it arrives as a system alert with the app greyed out
|
||||
* behind it.
|
||||
*
|
||||
* Deliberately small. Naming a chapter is one field and two buttons, and
|
||||
* anything more would be a form.
|
||||
*/
|
||||
export function NameDialog({ title, value = "", label = "Name", confirmLabel = "Save", onSave, onClose, dark }) {
|
||||
const [name, setName] = useState(value);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const T = useMemo(
|
||||
() => ({
|
||||
bg: dark ? "#161b22" : "#fff",
|
||||
border: dark ? "#30363d" : "#d0d7de",
|
||||
text: dark ? "#e6edf3" : "#1f2328",
|
||||
muted: dark ? "#8b949e" : "#656d76",
|
||||
accent: dark ? "#388bfd" : "#0969da",
|
||||
field: dark ? "#0d1117" : "#f6f8fa",
|
||||
}),
|
||||
[dark]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
onSave(trimmed);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-name-dialog
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 4000,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 380,
|
||||
background: T.bg,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 16,
|
||||
padding: 22,
|
||||
fontFamily: "'DM Sans',sans-serif",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: "0 0 14px", fontSize: 16, fontWeight: 700, color: T.text }}>{title}</h3>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
aria-label={label}
|
||||
placeholder={label}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") save();
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 14px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: T.field,
|
||||
color: T.text,
|
||||
fontSize: 14,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 18 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: "transparent",
|
||||
color: T.text,
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!name.trim()}
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: name.trim() ? T.accent : T.border,
|
||||
color: "#fff",
|
||||
cursor: name.trim() ? "pointer" : "default",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,105 @@
|
||||
import { useState } from "react";
|
||||
import { coverBackground, coverStyle } from "./constants.jsx";
|
||||
import { coverBackground, coverEmblem, coverStyle } from "./constants.jsx";
|
||||
|
||||
/**
|
||||
* The mark in the middle of a cover.
|
||||
*
|
||||
* Every notebook used to wear the same pair of rings, whatever cover was
|
||||
* chosen — which is most of why a shelf of them looked alike. It belongs to the
|
||||
* design now, and the designs whose pattern already fills the face wear nothing.
|
||||
*
|
||||
* Drawn with borders rather than with an image, so it takes the cover's own
|
||||
* accent colour and stays sharp at any size.
|
||||
*/
|
||||
function Emblem({ kind, accent }) {
|
||||
if (kind === "none") return null;
|
||||
|
||||
const box = {
|
||||
position: "absolute",
|
||||
top: 28,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 50,
|
||||
height: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
const line = `${accent}55`;
|
||||
const faint = `${accent}33`;
|
||||
|
||||
if (kind === "ring") {
|
||||
return (
|
||||
<div style={{ ...box, borderRadius: "50%", border: `1.5px solid ${accent}44` }}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: "50%", border: `1px solid ${faint}` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "square") {
|
||||
return (
|
||||
<div style={{ ...box, border: `1.5px solid ${accent}44`, borderRadius: 3 }}>
|
||||
<div style={{ width: 26, height: 26, border: `1px solid ${faint}`, borderRadius: 2 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "diamond") {
|
||||
return (
|
||||
<div style={box}>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
border: `1.5px solid ${accent}44`,
|
||||
transform: "rotate(45deg)",
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "dot") {
|
||||
return (
|
||||
<div style={box}>
|
||||
<div style={{ width: 16, height: 16, borderRadius: "50%", background: line }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "cross") {
|
||||
return (
|
||||
<div style={{ ...box, flexDirection: "column", gap: 0 }}>
|
||||
<div style={{ width: 40, height: 1.5, background: line }} />
|
||||
<div style={{ width: 1.5, height: 40, background: line, marginTop: -20.75, marginBottom: -20.75 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "arc") {
|
||||
return (
|
||||
<div style={box}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: "50%",
|
||||
borderTop: `1.5px solid ${accent}55`,
|
||||
borderRight: `1.5px solid ${accent}22`,
|
||||
borderBottom: "1.5px solid transparent",
|
||||
borderLeft: `1.5px solid ${accent}22`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "bars") {
|
||||
return (
|
||||
<div style={{ ...box, gap: 5 }}>
|
||||
{[18, 30, 22].map((h, i) => (
|
||||
<div key={i} style={{ width: 2, height: h, borderRadius: 1, background: line }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ═══════ NOTEBOOK COVER ═══════ */
|
||||
export function NotebookCover({
|
||||
@@ -19,6 +119,7 @@ export function NotebookCover({
|
||||
// colour, the face from both.
|
||||
const cover = coverStyle(notebook.cover, notebook.coverPattern);
|
||||
const face = coverBackground(notebook.cover, notebook.coverPattern);
|
||||
const emblem = coverEmblem(notebook.cover, notebook.coverPattern);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
@@ -126,14 +227,6 @@ export function NotebookCover({
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background:
|
||||
"repeating-linear-gradient(45deg,transparent,transparent 30px,rgba(255,255,255,0.015) 30px,rgba(255,255,255,0.015) 60px)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -145,25 +238,7 @@ export function NotebookCover({
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 28,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: "50%",
|
||||
border: `1.5px solid ${cover.accent}44`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ width: 30, height: 30, borderRadius: "50%", border: `1px solid ${cover.accent}33` }}
|
||||
/>
|
||||
</div>
|
||||
<Emblem kind={emblem} accent={cover.accent} />
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
||||
@@ -65,57 +65,67 @@ export const DEFAULT_FOLDERS = [
|
||||
* two crossed diagonals meant as a chevron came out as noisy plaid at that size.
|
||||
*/
|
||||
export const PATTERNS = [
|
||||
{ id: "plain", label: "Plain", layers: "" },
|
||||
{ emblem: "ring", id: "plain", label: "Plain", layers: "" },
|
||||
{
|
||||
id: "ruled",
|
||||
emblem: "none",
|
||||
label: "Ruled",
|
||||
layers: "repeating-linear-gradient(0deg,rgba(255,255,255,.12) 0 1px,transparent 1px 17px)",
|
||||
},
|
||||
{
|
||||
id: "grid",
|
||||
emblem: "square",
|
||||
label: "Grid",
|
||||
layers:
|
||||
"repeating-linear-gradient(0deg,rgba(255,255,255,.10) 0 1px,transparent 1px 20px), repeating-linear-gradient(90deg,rgba(255,255,255,.10) 0 1px,transparent 1px 20px)",
|
||||
},
|
||||
{
|
||||
id: "pinstripe",
|
||||
emblem: "diamond",
|
||||
label: "Pinstripe",
|
||||
layers: "repeating-linear-gradient(45deg,rgba(255,255,255,.09) 0 6px,transparent 6px 14px)",
|
||||
},
|
||||
{
|
||||
id: "bands",
|
||||
emblem: "none",
|
||||
label: "Bands",
|
||||
layers: "repeating-linear-gradient(0deg,rgba(0,0,0,.18) 0 13px,transparent 13px 26px)",
|
||||
},
|
||||
{
|
||||
id: "dots",
|
||||
emblem: "dot",
|
||||
label: "Dots",
|
||||
layers: "radial-gradient(rgba(255,255,255,.18) 1.4px,transparent 1.5px) 0 0/13px 13px",
|
||||
},
|
||||
{
|
||||
id: "hatch",
|
||||
emblem: "cross",
|
||||
label: "Cross-hatch",
|
||||
layers:
|
||||
"repeating-linear-gradient(45deg,rgba(255,255,255,.08) 0 1px,transparent 1px 11px), repeating-linear-gradient(-45deg,rgba(255,255,255,.08) 0 1px,transparent 1px 11px)",
|
||||
},
|
||||
{
|
||||
id: "linen",
|
||||
emblem: "arc",
|
||||
label: "Linen",
|
||||
layers:
|
||||
"repeating-linear-gradient(0deg,rgba(255,255,255,.07) 0 1px,transparent 1px 4px), repeating-linear-gradient(90deg,rgba(0,0,0,.07) 0 1px,transparent 1px 4px)",
|
||||
},
|
||||
{
|
||||
id: "ledger",
|
||||
emblem: "bars",
|
||||
label: "Ledger",
|
||||
layers: "repeating-linear-gradient(90deg,rgba(255,255,255,.09) 0 1px,transparent 1px 26px)",
|
||||
},
|
||||
{
|
||||
id: "glow",
|
||||
emblem: "none",
|
||||
label: "Corner light",
|
||||
layers: "radial-gradient(120% 85% at 18% 0%,rgba(255,255,255,.20),transparent 62%)",
|
||||
},
|
||||
{
|
||||
id: "foot",
|
||||
emblem: "square",
|
||||
label: "Foot band",
|
||||
layers: "linear-gradient(0deg,rgba(0,0,0,.34) 0 22%,transparent 22%)",
|
||||
},
|
||||
@@ -149,5 +159,16 @@ export function coverBackground(cover, pattern) {
|
||||
return layers ? `${layers}, ${base.bg}` : base.bg;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mark in the middle of a cover.
|
||||
*
|
||||
* It used to be the same pair of rings on every notebook, whatever cover was
|
||||
* chosen — which, with the same title frame and the same three dots, is why a
|
||||
* shelf of them all looked alike. It belongs to the design now: some wear a
|
||||
* ring, some a diamond, and the ones whose pattern already fills the face wear
|
||||
* nothing at all.
|
||||
*/
|
||||
export const coverEmblem = (cover, pattern) => PATTERNS[coverParts(cover, pattern).pattern]?.emblem || "none";
|
||||
|
||||
/** The rest of a cover's colours — the spine, and the accent used beside it. */
|
||||
export const coverStyle = (cover, pattern) => COVERS[coverParts(cover, pattern).cover];
|
||||
@@ -431,7 +431,8 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
||||
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
|
||||
| `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` | 50 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
@@ -940,6 +941,40 @@ rows behind.
|
||||
|
||||
---
|
||||
|
||||
## Chapters
|
||||
|
||||
Folders inside a notebook. A note is in exactly one chapter, or in none — which
|
||||
means the notebook itself. Chapters are listed above the loose notes; tapping one
|
||||
opens its own list, and the menu button becomes a way back out, which on a phone
|
||||
is the only way, since the list fills the screen.
|
||||
|
||||
**A chapter is not a wastebasket.** Removing one never removes what it held: the
|
||||
notes come back to the notebook, where they can be seen and filed again. That is
|
||||
`ON DELETE SET NULL` on `notes.chapter_id`, and it is also written out explicitly
|
||||
in the delete route — it is the promise the whole feature rests on, and it should
|
||||
be visible where it happens rather than inferred from a schema. The dialog says
|
||||
so too, with the number of notes affected.
|
||||
|
||||
Three rules that keep the two levels honest:
|
||||
|
||||
- **A note filed into a chapter leaves the notebook's own list.** Showing it in
|
||||
both would mean filing it appeared to do nothing.
|
||||
- **A chapter belongs to its notebook.** Filing a note into a chapter of a book
|
||||
it is not in is refused by the server; moving a note to another notebook clears
|
||||
its chapter, since the old one is in the book it just left.
|
||||
- **Separators belong to the list they were drawn in.** A separator's `order`
|
||||
counts the notes before it, which only means anything inside one list, so each
|
||||
carries the chapter it belongs to — `null` for the notebook itself. The
|
||||
notebook still stores one list of them, and a change made in one chapter writes
|
||||
the others back untouched.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Sorting a notebook
|
||||
|
||||
Each notebook remembers how its notes are ordered, and remembers it on the
|
||||
|
||||
Reference in new issue
Block a user