Compare commits
3
Commits
| 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
|
## 2026-08-12 — Two-part covers, and an app that can be installed
|
||||||
|
|
||||||
**Images** — web `sha256:1217b81b…`, server `sha256:3303d3dc…`
|
**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
|
* 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. */
|
* 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.
|
/* 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
|
* Its own column rather than more values in `cover_index`: they are two
|
||||||
* decisions, and folding them into one number would mean renumbering every
|
* decisions, and folding them into one number would mean renumbering every
|
||||||
@@ -888,6 +918,10 @@ function cleanSeparators(input) {
|
|||||||
id: s.id.slice(0, 64),
|
id: s.id.slice(0, 64),
|
||||||
label: typeof s.label === "string" ? s.label.slice(0, MAX_SEPARATOR_LABEL) : "",
|
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,
|
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 ─── */
|
/* ─── Note routes ─── */
|
||||||
app.get("/api/notes", authMiddleware, (req, res) => {
|
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);
|
res.json(notes);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1106,13 +1140,25 @@ app.post("/api/notes/reorder", authMiddleware, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/notes", 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 id = uuid();
|
||||||
const encContent = content ? encrypt(content) : null;
|
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)
|
/* A note made while a chapter is open belongs to that chapter. Checked
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
* against the notebook, so it cannot be filed into a chapter of another. */
|
||||||
.run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, pdfAttachmentId || null, req.deviceId);
|
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', ?)")
|
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'note', ?, 'create', ?)")
|
||||||
.run(req.userId, id, req.deviceId);
|
.run(req.userId, id, req.deviceId);
|
||||||
@@ -1154,10 +1200,33 @@ const replaceNoteAttachments = db.transaction((noteId, ids) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
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);
|
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" });
|
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 conflict = isStaleWrite(note.updated_at, baseUpdatedAt);
|
||||||
const encContent = content ? encrypt(content) : note.content_encrypted;
|
const encContent = content ? encrypt(content) : note.content_encrypted;
|
||||||
|
|
||||||
@@ -1186,6 +1255,14 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
|||||||
WHERE id = ?`)
|
WHERE id = ?`)
|
||||||
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, req.deviceId, req.params.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(links)) replaceNoteLinks(req.userId, req.params.id, links);
|
||||||
if (Array.isArray(attachments)) replaceNoteAttachments(req.params.id, attachments);
|
if (Array.isArray(attachments)) replaceNoteAttachments(req.params.id, attachments);
|
||||||
|
|
||||||
@@ -1589,6 +1666,73 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => {
|
|||||||
res.end(bytes);
|
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 ───
|
/* ─── The text inside a PDF ───
|
||||||
*
|
*
|
||||||
* Extracted by the device that has the file, never here. The server has no 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
|
/* Reordering must not look like editing: a dragged note keeps its place in
|
||||||
"last changed", or the two orders would fight each other. */
|
"last changed", or the two orders would fight each other.
|
||||||
await sortBy("updated");
|
|
||||||
|
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(
|
check(
|
||||||
(await titles())[0] === "Gamma",
|
Object.keys(beforeStamps).every((t) => beforeStamps[t] === afterStamps[t]),
|
||||||
"a dragged note did not become the most recently changed one",
|
"dragging a note does not mark it as changed",
|
||||||
(await titles()).join(", ")
|
`${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 sortBy("manual");
|
||||||
await p.waitForTimeout(800);
|
await p.waitForTimeout(800);
|
||||||
|
|
||||||
@@ -160,6 +180,8 @@ try {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/* ── separators ── */
|
/* ── 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.locator("[data-add-separator]").click();
|
||||||
await p.waitForTimeout(1800);
|
await p.waitForTimeout(1800);
|
||||||
check((await p.locator("[data-separator]").count()) === 1, "a separator can be added");
|
check((await p.locator("[data-separator]").count()) === 1, "a separator can be added");
|
||||||
@@ -194,9 +216,9 @@ try {
|
|||||||
|
|
||||||
const withSeparator = await titles();
|
const withSeparator = await titles();
|
||||||
check(
|
check(
|
||||||
withSeparator.join(",") === arranged.join(","),
|
withSeparator.join(",") === beforeSeparator.join(","),
|
||||||
"and adding one did not disturb the arrangement",
|
"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
|
// 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"
|
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
|
||||||
URL="${INKNOTE_URL:-http://localhost:3000}"
|
URL="${INKNOTE_URL:-http://localhost:3000}"
|
||||||
SUITES=("$@")
|
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
|
mkdir -p shots
|
||||||
if [ ! -d node_modules ]; then
|
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 { I } from "./ui/icons.jsx";
|
||||||
import { makeTheme, FONT_UI } from "./ui/theme";
|
import { makeTheme, FONT_UI } from "./ui/theme";
|
||||||
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
|
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
|
||||||
|
import { NameDialog } from "./ui/NameDialog.jsx";
|
||||||
import { ContextMenu } from "./ui/ContextMenu.jsx";
|
import { ContextMenu } from "./ui/ContextMenu.jsx";
|
||||||
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
|
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
|
||||||
import { AccountMenu } from "./ui/AccountMenu.jsx";
|
import { AccountMenu } from "./ui/AccountMenu.jsx";
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
canReorder,
|
canReorder,
|
||||||
sortModesFor,
|
sortModesFor,
|
||||||
sortNotes,
|
sortNotes,
|
||||||
|
separatorsFor,
|
||||||
withNewSeparator,
|
withNewSeparator,
|
||||||
} from "./features/notes/noteSort";
|
} from "./features/notes/noteSort";
|
||||||
import { EditorPane } from "./features/notes/EditorPane.jsx";
|
import { EditorPane } from "./features/notes/EditorPane.jsx";
|
||||||
@@ -61,6 +63,10 @@ export default function App() {
|
|||||||
|
|
||||||
const [activeFolder, setActiveFolder] = useState("dashboard");
|
const [activeFolder, setActiveFolder] = useState("dashboard");
|
||||||
const [activeNote, setActiveNote] = useState(null);
|
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 [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
const [showNewNb, setShowNewNb] = useState(false);
|
const [showNewNb, setShowNewNb] = useState(false);
|
||||||
@@ -82,6 +88,8 @@ export default function App() {
|
|||||||
const [renamingSeparator, setRenamingSeparator] = useState(null);
|
const [renamingSeparator, setRenamingSeparator] = useState(null);
|
||||||
const [historyFor, setHistoryFor] = useState(null);
|
const [historyFor, setHistoryFor] = useState(null);
|
||||||
const [ctxMenu, setCtxMenu] = 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);
|
const [confirmDialog, setConfirmDialog] = useState(null);
|
||||||
// Bumped on a version restore to force a fresh editor on the restored body.
|
// Bumped on a version restore to force a fresh editor on the restored body.
|
||||||
const [restoreEpoch, setRestoreEpoch] = useState(0);
|
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
|
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. */
|
hook: they are dragged with a mouse, in the sidebar and on the dashboard. */
|
||||||
const rearrangeNotes = useCallback(
|
const rearrangeNotes = useCallback(
|
||||||
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId),
|
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, activeChapter),
|
||||||
[data, activeFolder]
|
[data, activeFolder, activeChapter]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
|
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
|
||||||
@@ -151,6 +159,7 @@ export default function App() {
|
|||||||
const openFolder = useCallback(
|
const openFolder = useCallback(
|
||||||
(id) => {
|
(id) => {
|
||||||
setActiveFolder(id);
|
setActiveFolder(id);
|
||||||
|
setActiveChapter(null);
|
||||||
setActiveNote(null);
|
setActiveNote(null);
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
@@ -186,6 +195,16 @@ export default function App() {
|
|||||||
|
|
||||||
/* ── Derived note lists ── */
|
/* ── Derived note lists ── */
|
||||||
const activeNotes = useMemo(() => notes.filter((n) => !n.trashed), [notes]);
|
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 trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
|
||||||
const isTrashView = activeFolder === "trash";
|
const isTrashView = activeFolder === "trash";
|
||||||
|
|
||||||
@@ -211,14 +230,25 @@ export default function App() {
|
|||||||
/* ── Separators ──
|
/* ── Separators ──
|
||||||
* Lines the user draws between notes. They belong to the notebook's own
|
* 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. */
|
* 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) =>
|
const writeSeparators = (next) =>
|
||||||
activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: 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) =>
|
const renameSeparator = (id, label) =>
|
||||||
writeSeparators(separators.map((s) => (s.id === id ? { ...s, label } : s)));
|
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s)));
|
||||||
const removeSeparator = (id) => writeSeparators(separators.filter((s) => s.id !== id));
|
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
|
||||||
|
|
||||||
const separatorContextMenu = (e, separator) => {
|
const separatorContextMenu = (e, separator) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -254,7 +284,11 @@ export default function App() {
|
|||||||
if (activeFolder === "trash") list = trashedNotes;
|
if (activeFolder === "trash") list = trashedNotes;
|
||||||
else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred);
|
else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred);
|
||||||
else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes;
|
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()) {
|
if (searchQuery.trim()) {
|
||||||
// A search reads every note's text, so results keep the index's relevance
|
// 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 list.filter((n) => n.title.toLowerCase().includes(q));
|
||||||
}
|
}
|
||||||
return sortNotes(list, sortMode);
|
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;
|
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
|
// 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 = () =>
|
const createNote = () =>
|
||||||
withNotebook("New note", async (folderId) => {
|
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);
|
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
|
||||||
openNote(note.id);
|
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 moveSubmenu = (note) => {
|
||||||
const targets = notebooks
|
const here = data.chapters.filter((c) => c.notebookId === note.folderId && c.id !== note.chapterId);
|
||||||
.filter((nb) => nb.id !== note.folderId)
|
const targets = [
|
||||||
.map((nb) => ({
|
...(note.chapterId
|
||||||
label: nb.name,
|
? [{ label: "The notebook itself", action: () => data.moveNote(note.id, note.folderId, null) }]
|
||||||
colorDot: coverBackground(nb.cover, nb.coverPattern),
|
: []),
|
||||||
action: () => data.moveNote(note.id, nb.id),
|
...here.map((c) => ({
|
||||||
}));
|
label: c.name,
|
||||||
return targets.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
|
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) => {
|
const noteContextMenu = (e, note) => {
|
||||||
@@ -663,6 +771,11 @@ export default function App() {
|
|||||||
onStartRenameSeparator={setRenamingSeparator}
|
onStartRenameSeparator={setRenamingSeparator}
|
||||||
onRenameSeparator={renameSeparator}
|
onRenameSeparator={renameSeparator}
|
||||||
onSeparatorContextMenu={separatorContextMenu}
|
onSeparatorContextMenu={separatorContextMenu}
|
||||||
|
chapters={folderChapters}
|
||||||
|
openChapter={openChapter}
|
||||||
|
onOpenChapter={setActiveChapter}
|
||||||
|
onAddChapter={activeNotebook ? addChapter : null}
|
||||||
|
onChapterContextMenu={chapterContextMenu}
|
||||||
onReorder={rearrangeNotes}
|
onReorder={rearrangeNotes}
|
||||||
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
||||||
onSelectNote={openNote}
|
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 && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title={confirmDialog.title}
|
title={confirmDialog.title}
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 8,
|
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",
|
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
|
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
|
its contents made the whole row jump sideways the moment renaming
|
||||||
started, which is exactly when the eye is on it. */}
|
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" }}>
|
<div style={{ flex: "0 0 auto", width: MIDDLE_W, display: "flex" }}>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<input
|
<input
|
||||||
@@ -133,10 +135,10 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
|||||||
border: `1px solid ${T.border}`,
|
border: `1px solid ${T.border}`,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
color: T.textSec,
|
color: T.text,
|
||||||
fontSize: 10.5,
|
fontSize: 11.5,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: 0.6,
|
letterSpacing: 0.8,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
// Matches the button's box exactly, so nothing shifts vertically
|
// Matches the button's box exactly, so nothing shifts vertically
|
||||||
// either when the input replaces it.
|
// either when the input replaces it.
|
||||||
@@ -151,10 +153,10 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
border: "1px solid transparent",
|
border: "1px solid transparent",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
color: separator.label ? T.textSec : T.faint,
|
color: separator.label ? T.text : T.muted,
|
||||||
fontSize: 10.5,
|
fontSize: 11.5,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: 0.6,
|
letterSpacing: 0.8,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
padding: "1px 4px",
|
padding: "1px 4px",
|
||||||
@@ -167,11 +169,59 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</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.
|
* How this notebook's notes are ordered.
|
||||||
*
|
*
|
||||||
@@ -368,6 +418,11 @@ export function NoteList({
|
|||||||
onStartRenameSeparator,
|
onStartRenameSeparator,
|
||||||
onRenameSeparator,
|
onRenameSeparator,
|
||||||
onSeparatorContextMenu,
|
onSeparatorContextMenu,
|
||||||
|
chapters,
|
||||||
|
openChapter,
|
||||||
|
onOpenChapter,
|
||||||
|
onAddChapter,
|
||||||
|
onChapterContextMenu,
|
||||||
onReorder,
|
onReorder,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
onSelectNote,
|
onSelectNote,
|
||||||
@@ -381,6 +436,11 @@ export function NoteList({
|
|||||||
browsers never fire from touch — on a phone the arrangement could not be
|
browsers never fire from touch — on a phone the arrangement could not be
|
||||||
changed at all, with nothing on screen to say why. */
|
changed at all, with nothing on screen to say why. */
|
||||||
const dnd = useReorderDrag(!!reorderable, onReorder);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -405,8 +465,13 @@ export function NoteList({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
<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
|
<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={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -417,10 +482,10 @@ export function NoteList({
|
|||||||
border: "none",
|
border: "none",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
color: T.muted,
|
color: openChapter ? T.accent : T.muted,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{I.menu}
|
{openChapter ? I.back : I.menu}
|
||||||
</button>
|
</button>
|
||||||
<h2
|
<h2
|
||||||
style={{
|
style={{
|
||||||
@@ -434,7 +499,7 @@ export function NoteList({
|
|||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{openChapter ? openChapter.name : title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||||
@@ -488,7 +553,7 @@ export function NoteList({
|
|||||||
onQueryChange("");
|
onQueryChange("");
|
||||||
e.currentTarget.blur();
|
e.currentTarget.blur();
|
||||||
}}
|
}}
|
||||||
placeholder={`Search in ${title}...`}
|
placeholder={`Search in ${openChapter ? openChapter.name : title}...`}
|
||||||
aria-label="Search in this notebook"
|
aria-label="Search in this notebook"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -506,15 +571,40 @@ export function NoteList({
|
|||||||
|
|
||||||
{/* Just the one control. The sentence that used to sit here explained the
|
{/* Just the one control. The sentence that used to sit here explained the
|
||||||
order the sort menu already names, on every visit, forever. */}
|
order the sort menu already names, on every visit, forever. */}
|
||||||
{reorderable && (
|
{(reorderable || canAddChapter) && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
|
gap: 6,
|
||||||
padding: "5px 10px",
|
padding: "5px 10px",
|
||||||
borderBottom: `1px solid ${T.borderLight}`,
|
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
|
<button
|
||||||
onClick={onAddSeparator}
|
onClick={onAddSeparator}
|
||||||
data-add-separator
|
data-add-separator
|
||||||
@@ -583,6 +673,18 @@ export function NoteList({
|
|||||||
something there: in "newest first" the notes would slide past a
|
something there: in "newest first" the notes would slide past a
|
||||||
line on every edit. They are not shown in the other orders, and
|
line on every edit. They are not shown in the other orders, and
|
||||||
are still in place on the way back. */}
|
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) : notes.map(asNoteEntry)).map((entry) => {
|
||||||
const showDropLine = reorderable && dnd.overId === entry.id;
|
const showDropLine = reorderable && dnd.overId === entry.id;
|
||||||
/* The whole row, indicator included, is the drop target.
|
/* The whole row, indicator included, is the drop target.
|
||||||
|
|||||||
@@ -174,10 +174,23 @@ export function reindexEntries(
|
|||||||
export const END_ID = "__end__";
|
export const END_ID = "__end__";
|
||||||
|
|
||||||
/** A new separator, added at the top where it can be seen and then dragged. */
|
/** A new separator, added at the top where it can be seen and then dragged. */
|
||||||
export const withNewSeparator = (separators: Separator[], id: string, label = ""): Separator[] => [
|
export const withNewSeparator = (
|
||||||
{ id, label, order: 0 },
|
separators: Separator[],
|
||||||
...separators,
|
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.
|
* 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 { sameStrokeDoc } from "../features/drawing/strokes.js";
|
||||||
import { reindexEntries } from "../features/notes/noteSort";
|
import { reindexEntries } from "../features/notes/noteSort";
|
||||||
import { extractOnImport } from "../features/pdf/pdfText";
|
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. */
|
/** How often the whole account is reconciled with the server. */
|
||||||
const FULL_SYNC_MS = 30_000;
|
const FULL_SYNC_MS = 30_000;
|
||||||
@@ -36,7 +36,7 @@ export interface NoteData {
|
|||||||
reload: () => Promise<void>;
|
reload: () => Promise<void>;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
|
|
||||||
createNote: (folderId: string) => Promise<NoteMeta>;
|
createNote: (folderId: string, chapterId?: string | null) => Promise<NoteMeta>;
|
||||||
importPdf: (folderId: string, file: File) => Promise<NoteMeta>;
|
importPdf: (folderId: string, file: File) => Promise<NoteMeta>;
|
||||||
updateNote: (id: string, edit: NoteEdit) => void;
|
updateNote: (id: string, edit: NoteEdit) => void;
|
||||||
setNoteContent: (id: string, content: NoteContent) => void;
|
setNoteContent: (id: string, content: NoteContent) => void;
|
||||||
@@ -45,12 +45,16 @@ export interface NoteData {
|
|||||||
restoreNote: (id: string) => Promise<void>;
|
restoreNote: (id: string) => Promise<void>;
|
||||||
permanentDeleteNote: (id: string) => Promise<void>;
|
permanentDeleteNote: (id: string) => Promise<void>;
|
||||||
emptyTrash: () => 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. */
|
/** 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>;
|
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
|
||||||
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
|
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>;
|
deleteNotebook: (id: string) => Promise<void>;
|
||||||
reorderNotebooks: (dragId: string, targetId: string) => void;
|
reorderNotebooks: (dragId: string, targetId: string) => void;
|
||||||
}
|
}
|
||||||
@@ -67,6 +71,7 @@ export interface NoteData {
|
|||||||
export function useNoteData(user: User | null, activeNote: string | null): NoteData {
|
export function useNoteData(user: User | null, activeNote: string | null): NoteData {
|
||||||
const [notes, setNotes] = useState<NoteMeta[]>([]);
|
const [notes, setNotes] = useState<NoteMeta[]>([]);
|
||||||
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
|
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
|
||||||
|
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||||
const [noteContents, setNoteContents] = useState<Record<string, NoteContent>>({});
|
const [noteContents, setNoteContents] = useState<Record<string, NoteContent>>({});
|
||||||
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
||||||
|
|
||||||
@@ -79,12 +84,14 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
|||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
setNotebooks(await sync.getNotebooks());
|
setNotebooks(await sync.getNotebooks());
|
||||||
|
setChapters(await sync.getChapters());
|
||||||
setNotes(await sync.getNotes());
|
setNotes(await sync.getNotes());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setNotes([]);
|
setNotes([]);
|
||||||
setNotebooks([]);
|
setNotebooks([]);
|
||||||
|
setChapters([]);
|
||||||
setNoteContents({});
|
setNoteContents({});
|
||||||
setBacklinks([]);
|
setBacklinks([]);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -185,8 +192,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
|||||||
|
|
||||||
/* ── Notes ── */
|
/* ── Notes ── */
|
||||||
|
|
||||||
const createNote = useCallback(async (folderId: string) => {
|
const createNote = useCallback(async (folderId: string, chapterId: string | null = null) => {
|
||||||
const note = await sync.createNote(folderId);
|
const note = await sync.createNote(folderId, chapterId);
|
||||||
setNotes((p) => [note, ...p]);
|
setNotes((p) => [note, ...p]);
|
||||||
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
|
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
|
||||||
return note;
|
return note;
|
||||||
@@ -266,9 +273,9 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
|||||||
setNotes((p) => p.filter((n) => !n.trashed));
|
setNotes((p) => p.filter((n) => !n.trashed));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const moveNote = useCallback(async (id: string, folderId: string) => {
|
const moveNote = useCallback(async (id: string, folderId: string, chapterId: string | null = null) => {
|
||||||
await sync.moveNote(id, folderId);
|
await sync.moveNote(id, folderId, chapterId);
|
||||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId } : n)));
|
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
|
* 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.
|
* 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);
|
const notebook = notebooksRef.current.find((nb) => nb.id === notebookId);
|
||||||
if (!notebook) return;
|
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;
|
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.noteIds.map((id, i) => [id, i]));
|
||||||
setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n)));
|
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.noteIds);
|
||||||
|
|
||||||
// Only written when it actually changed: a plain note drag leaves the
|
/* Only written when it actually changed.
|
||||||
// separators alone, and a pointless notebook write would sync for nothing.
|
*
|
||||||
const before = JSON.stringify(notebook.separators || []);
|
* A plain note drag leaves the separators alone, and a write for nothing
|
||||||
if (JSON.stringify(next.separators) !== before) {
|
* costs more than a request: it reloads the notes, which reshuffles any that
|
||||||
setNotebooks((p) =>
|
* share an `updatedAt` second — so "last changed" quietly came out in a
|
||||||
p.map((nb) => (nb.id === notebookId ? { ...nb, separators: next.separators } : nb))
|
* different order after every drag.
|
||||||
);
|
*
|
||||||
void sync.updateNotebook(notebookId, { separators: next.separators });
|
* 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 ── */
|
/* ── 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 createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
|
||||||
const nb = await sync.createNotebook(fields);
|
const nb = await sync.createNotebook(fields);
|
||||||
setNotebooks((p) => [...p, nb]);
|
setNotebooks((p) => [...p, nb]);
|
||||||
@@ -355,6 +413,10 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
|||||||
rearrange,
|
rearrange,
|
||||||
createNotebook,
|
createNotebook,
|
||||||
updateNotebook,
|
updateNotebook,
|
||||||
|
chapters,
|
||||||
|
createChapter,
|
||||||
|
renameChapter,
|
||||||
|
deleteChapter,
|
||||||
deleteNotebook,
|
deleteNotebook,
|
||||||
reorderNotebooks,
|
reorderNotebooks,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
* pushes queued changes to the server when a connection is available.
|
* 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_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;
|
let db: IDBDatabase | null = null;
|
||||||
|
|
||||||
@@ -53,6 +53,12 @@ function open(): Promise<IDBDatabase> {
|
|||||||
if (!d.objectStoreNames.contains("attachments")) {
|
if (!d.objectStoreNames.contains("attachments")) {
|
||||||
d.createObjectStore("attachments", { keyPath: "id" });
|
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) => {
|
req.onsuccess = (e) => {
|
||||||
@@ -101,6 +107,29 @@ export async function deleteMeta(key: string): Promise<void> {
|
|||||||
await reqToPromise(s.delete(key));
|
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 ─── */
|
/* ─── Notebooks ─── */
|
||||||
export async function getAllNotebooks(): Promise<Notebook[]> {
|
export async function getAllNotebooks(): Promise<Notebook[]> {
|
||||||
const s = await store("notebooks", "readonly");
|
const s = await store("notebooks", "readonly");
|
||||||
|
|||||||
+171
-7
@@ -8,6 +8,7 @@
|
|||||||
import * as db from "./offlineStore";
|
import * as db from "./offlineStore";
|
||||||
import type {
|
import type {
|
||||||
Backlink,
|
Backlink,
|
||||||
|
Chapter,
|
||||||
ManagedUser,
|
ManagedUser,
|
||||||
NoteContent,
|
NoteContent,
|
||||||
NoteMeta,
|
NoteMeta,
|
||||||
@@ -78,10 +79,18 @@ interface ServerNotebook {
|
|||||||
separators?: string | null;
|
separators?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ServerChapter {
|
||||||
|
id: string;
|
||||||
|
notebook_id: string;
|
||||||
|
name: string;
|
||||||
|
sort_order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ServerNote {
|
interface ServerNote {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
notebook_id: string | null;
|
notebook_id: string | null;
|
||||||
|
chapter_id?: string | null;
|
||||||
starred?: number | boolean;
|
starred?: number | boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
@@ -108,6 +117,14 @@ export interface NoteUpdate {
|
|||||||
title?: string;
|
title?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
notebookId?: string | null;
|
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;
|
starred?: boolean;
|
||||||
links?: string[];
|
links?: string[];
|
||||||
/** Attachments the body embeds, so ones nothing uses can be released. */
|
/** Attachments the body embeds, so ones nothing uses can be released. */
|
||||||
@@ -404,6 +421,14 @@ export async function fullSync(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await flushQueue();
|
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 localNbs = await db.getAllNotebooks();
|
||||||
const serverNbs = await api.get<ServerNotebook[]>("/notebooks");
|
const serverNbs = await api.get<ServerNotebook[]>("/notebooks");
|
||||||
const nbs: Notebook[] = serverNbs.map((nb) => ({
|
const nbs: Notebook[] = serverNbs.map((nb) => ({
|
||||||
@@ -504,7 +529,13 @@ type QueuedAction =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { action: "deleteNotebook"; data: { id: string } }
|
| { 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: "updateNote"; data: { id: string; updates: NoteUpdate } }
|
||||||
| { action: "updateDrawing"; data: { id: string; drawingStrokes: StrokeDoc | null } }
|
| { action: "updateDrawing"; data: { id: string; drawingStrokes: StrokeDoc | null } }
|
||||||
| { action: "trashNote"; data: { id: string } }
|
| { action: "trashNote"; data: { id: string } }
|
||||||
@@ -607,12 +638,40 @@ async function processEntry(entry: QueueEntry): Promise<void> {
|
|||||||
case "deleteNotebook":
|
case "deleteNotebook":
|
||||||
await api.del(`/notebooks/${a.data.id}`);
|
await api.del(`/notebooks/${a.data.id}`);
|
||||||
break;
|
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": {
|
case "createNote": {
|
||||||
const d = a.data;
|
const d = a.data;
|
||||||
const content = await db.getNoteContent(d.localId);
|
const content = await db.getNoteContent(d.localId);
|
||||||
const r = await api.post<{ id: string }>("/notes", {
|
const r = await api.post<{ id: string }>("/notes", {
|
||||||
title: d.title,
|
title: d.title,
|
||||||
notebookId: d.folderId,
|
notebookId: d.folderId,
|
||||||
|
chapterId: d.chapterId ?? null,
|
||||||
content: content?.content || "",
|
content: content?.content || "",
|
||||||
});
|
});
|
||||||
if (r.id !== d.localId) {
|
if (r.id !== d.localId) {
|
||||||
@@ -715,6 +774,93 @@ export async function getNotebooks(): Promise<Notebook[]> {
|
|||||||
return db.getAllNotebooks();
|
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({
|
export async function createNotebook({
|
||||||
name,
|
name,
|
||||||
cover,
|
cover,
|
||||||
@@ -909,11 +1055,16 @@ export async function loadNoteContent(
|
|||||||
return shapeContent(await db.getNoteContent(id));
|
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 ts = new Date().toISOString();
|
||||||
const base = {
|
const base = {
|
||||||
title: "Untitled Note",
|
title: "Untitled Note",
|
||||||
folderId,
|
folderId,
|
||||||
|
chapterId,
|
||||||
starred: false,
|
starred: false,
|
||||||
trashed: false,
|
trashed: false,
|
||||||
createdAt: ts,
|
createdAt: ts,
|
||||||
@@ -925,6 +1076,7 @@ export async function createNote(folderId: string | null): Promise<NoteMeta> {
|
|||||||
const r = await api.post<{ id: string }>("/notes", {
|
const r = await api.post<{ id: string }>("/notes", {
|
||||||
title: base.title,
|
title: base.title,
|
||||||
notebookId: folderId,
|
notebookId: folderId,
|
||||||
|
chapterId,
|
||||||
content: "",
|
content: "",
|
||||||
});
|
});
|
||||||
const note: NoteMeta = { id: r.id, ...base };
|
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 };
|
const note: NoteMeta = { id: lid, ...base, _pendingCreate: true };
|
||||||
await db.putNote(note);
|
await db.putNote(note);
|
||||||
await db.putNoteContent(lid, "", null);
|
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);
|
notifyData("note", note);
|
||||||
notifyStatus("pending");
|
notifyStatus("pending");
|
||||||
return note;
|
return note;
|
||||||
@@ -1041,17 +1193,29 @@ export async function toggleNoteStar(id: string): Promise<boolean> {
|
|||||||
return updated.starred;
|
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);
|
const note = await db.getNote(id);
|
||||||
if (!note) return;
|
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);
|
await db.putNote(updated);
|
||||||
notifyData("note", updated);
|
notifyData("note", updated);
|
||||||
|
|
||||||
|
const updates: NoteUpdate = { notebookId: folderId, chapterId };
|
||||||
if (_online && api.token && !updated._pendingCreate) {
|
if (_online && api.token && !updated._pendingCreate) {
|
||||||
api.put(`/notes/${id}`, { notebookId: folderId }).catch(() => {});
|
api.put(`/notes/${id}`, updates).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
await enqueue({ action: "updateNote", data: { id, updates: { notebookId: folderId } } });
|
await enqueue({ action: "updateNote", data: { id, updates } });
|
||||||
notifyStatus("pending");
|
notifyStatus("pending");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface NoteMeta {
|
|||||||
/** Attachment holding the PDF, for notes of type "pdf". */
|
/** Attachment holding the PDF, for notes of type "pdf". */
|
||||||
pdfAttachmentId?: string | null;
|
pdfAttachmentId?: string | null;
|
||||||
folderId: string | null;
|
folderId: string | null;
|
||||||
|
/** Chapter within the notebook; null means the notebook itself. */
|
||||||
|
chapterId?: string | null;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
trashed: boolean;
|
trashed: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -84,6 +86,28 @@ export interface Separator {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
order: number;
|
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 {
|
export interface Notebook {
|
||||||
|
|||||||
@@ -67,8 +67,11 @@ export function ContextMenu({ x, y, items, onClose, dark }) {
|
|||||||
padding: 4,
|
padding: 4,
|
||||||
minWidth: 190,
|
minWidth: 190,
|
||||||
maxWidth: "calc(100vw - 16px)",
|
maxWidth: "calc(100vw - 16px)",
|
||||||
maxHeight: "calc(100vh - 16px)",
|
/* No `overflow` here. A submenu flies out sideways, and any scrolling
|
||||||
overflowY: "auto",
|
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",
|
fontFamily: "'DM Sans',sans-serif",
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -150,7 +153,9 @@ function SubMenu({ item, T, onClose }) {
|
|||||||
if (!row) return;
|
if (!row) return;
|
||||||
// 180 is this submenu's minimum width; going off the right edge means it
|
// 180 is this submenu's minimum width; going off the right edge means it
|
||||||
// belongs on the other side.
|
// 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]);
|
}, [open]);
|
||||||
|
|
||||||
const panel = (
|
const panel = (
|
||||||
@@ -211,7 +216,7 @@ function SubMenu({ item, T, onClose }) {
|
|||||||
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
|
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
|
||||||
{item.label}
|
{item.label}
|
||||||
<span style={{ marginLeft: "auto", fontSize: 10, opacity: 0.5 }}>
|
<span style={{ marginLeft: "auto", fontSize: 10, opacity: 0.5 }}>
|
||||||
{inline && open ? "▾" : side === "left" ? "◂" : "▸"}
|
{inline && open ? "▾" : side === "right" ? "◂" : "▸"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{open && panel}
|
{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 { 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 ═══════ */
|
/* ═══════ NOTEBOOK COVER ═══════ */
|
||||||
export function NotebookCover({
|
export function NotebookCover({
|
||||||
@@ -19,6 +119,7 @@ export function NotebookCover({
|
|||||||
// colour, the face from both.
|
// colour, the face from both.
|
||||||
const cover = coverStyle(notebook.cover, notebook.coverPattern);
|
const cover = coverStyle(notebook.cover, notebook.coverPattern);
|
||||||
const face = coverBackground(notebook.cover, notebook.coverPattern);
|
const face = coverBackground(notebook.cover, notebook.coverPattern);
|
||||||
|
const emblem = coverEmblem(notebook.cover, notebook.coverPattern);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -126,14 +227,6 @@ export function NotebookCover({
|
|||||||
zIndex: 3,
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -145,25 +238,7 @@ export function NotebookCover({
|
|||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<Emblem kind={emblem} accent={cover.accent} />
|
||||||
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>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
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.
|
* two crossed diagonals meant as a chevron came out as noisy plaid at that size.
|
||||||
*/
|
*/
|
||||||
export const PATTERNS = [
|
export const PATTERNS = [
|
||||||
{ id: "plain", label: "Plain", layers: "" },
|
{ emblem: "ring", id: "plain", label: "Plain", layers: "" },
|
||||||
{
|
{
|
||||||
id: "ruled",
|
id: "ruled",
|
||||||
|
emblem: "none",
|
||||||
label: "Ruled",
|
label: "Ruled",
|
||||||
layers: "repeating-linear-gradient(0deg,rgba(255,255,255,.12) 0 1px,transparent 1px 17px)",
|
layers: "repeating-linear-gradient(0deg,rgba(255,255,255,.12) 0 1px,transparent 1px 17px)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "grid",
|
id: "grid",
|
||||||
|
emblem: "square",
|
||||||
label: "Grid",
|
label: "Grid",
|
||||||
layers:
|
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)",
|
"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",
|
id: "pinstripe",
|
||||||
|
emblem: "diamond",
|
||||||
label: "Pinstripe",
|
label: "Pinstripe",
|
||||||
layers: "repeating-linear-gradient(45deg,rgba(255,255,255,.09) 0 6px,transparent 6px 14px)",
|
layers: "repeating-linear-gradient(45deg,rgba(255,255,255,.09) 0 6px,transparent 6px 14px)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "bands",
|
id: "bands",
|
||||||
|
emblem: "none",
|
||||||
label: "Bands",
|
label: "Bands",
|
||||||
layers: "repeating-linear-gradient(0deg,rgba(0,0,0,.18) 0 13px,transparent 13px 26px)",
|
layers: "repeating-linear-gradient(0deg,rgba(0,0,0,.18) 0 13px,transparent 13px 26px)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "dots",
|
id: "dots",
|
||||||
|
emblem: "dot",
|
||||||
label: "Dots",
|
label: "Dots",
|
||||||
layers: "radial-gradient(rgba(255,255,255,.18) 1.4px,transparent 1.5px) 0 0/13px 13px",
|
layers: "radial-gradient(rgba(255,255,255,.18) 1.4px,transparent 1.5px) 0 0/13px 13px",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "hatch",
|
id: "hatch",
|
||||||
|
emblem: "cross",
|
||||||
label: "Cross-hatch",
|
label: "Cross-hatch",
|
||||||
layers:
|
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)",
|
"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",
|
id: "linen",
|
||||||
|
emblem: "arc",
|
||||||
label: "Linen",
|
label: "Linen",
|
||||||
layers:
|
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)",
|
"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",
|
id: "ledger",
|
||||||
|
emblem: "bars",
|
||||||
label: "Ledger",
|
label: "Ledger",
|
||||||
layers: "repeating-linear-gradient(90deg,rgba(255,255,255,.09) 0 1px,transparent 1px 26px)",
|
layers: "repeating-linear-gradient(90deg,rgba(255,255,255,.09) 0 1px,transparent 1px 26px)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "glow",
|
id: "glow",
|
||||||
|
emblem: "none",
|
||||||
label: "Corner light",
|
label: "Corner light",
|
||||||
layers: "radial-gradient(120% 85% at 18% 0%,rgba(255,255,255,.20),transparent 62%)",
|
layers: "radial-gradient(120% 85% at 18% 0%,rgba(255,255,255,.20),transparent 62%)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "foot",
|
id: "foot",
|
||||||
|
emblem: "square",
|
||||||
label: "Foot band",
|
label: "Foot band",
|
||||||
layers: "linear-gradient(0deg,rgba(0,0,0,.34) 0 22%,transparent 22%)",
|
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;
|
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. */
|
/** 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];
|
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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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
|
## Sorting a notebook
|
||||||
|
|
||||||
Each notebook remembers how its notes are ordered, and remembers it on the
|
Each notebook remembers how its notes are ordered, and remembers it on the
|
||||||
|
|||||||
Reference in new issue
Block a user