diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dbbd18..455b1a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,34 @@ not from the output of `docker push`. --- +## Unreleased + +**Added** +- **Patterned notebook covers** — ruled paper, graph paper, pinstripe, bands, + dots, cross-hatch, linen, washes, a ledger. Drawn entirely in CSS: no image + files, nothing to upload, crisp at any size, and they work offline like + everything else. Existing notebooks keep exactly the cover they had; the new + ones are appended, never inserted. + +**Changed** +- **The search box in a notebook is simply there**, and the button that used to + open it is gone. A magnifying glass cost a click and having to remember that + searching was possible; the box is one line and says for itself what it does. +- **Margin notes are bigger** — the column went from 208px to 288px and a card + from three short lines to about five. Three lines is a caption, not a remark + on a passage, and the space beside the page was sitting mostly empty. +- The notebook dialog's cover grid scrolls, so the dialog cannot run past the + bottom of a laptop screen and take the *Create* button with it. + +**Fixed** +- The notebook edit dialog was still part German — *"Versionen pro Notiz"*, + *"Vorgabe"* — and would not say what the server's default actually is. It now + names it (50), read from the server rather than written into the interface, + since `MAX_VERSIONS_PER_NOTE` can be changed and a hardcoded number would + quietly start lying. + +--- + ## 2026-08-11 — Margins and a stray placeholder **Images** — web `sha256:3a181ff3…`, server unchanged at `sha256:0832ee2b…` diff --git a/backend/server.js b/backend/server.js index 68a050b..cec4e79 100644 --- a/backend/server.js +++ b/backend/server.js @@ -278,6 +278,21 @@ const publicUser = (row) => ({ preferences: readPreferences(row.preferences), }); +/** + * The account as it is handed to whoever just signed in as it. + * + * Carries one thing that is not about the account at all: how many versions a + * notebook keeps when it names no number of its own. It is sent rather than + * written into the interface because it is configurable — + * `MAX_VERSIONS_PER_NOTE` — and a dialog that said "50" would quietly start + * lying the day that is changed. It rides here because this is the one payload + * every client already fetches, on login and on restoring a session. + * + * `publicUser` stays as it is: the admin list shows *other* people's accounts, + * and a server-wide setting has no business being repeated on each row. + */ +const signedInUser = (row) => ({ ...publicUser(row), defaultMaxVersions: MAX_VERSIONS_PER_NOTE }); + /* Interface settings the client may store. Anything else is dropped rather than * kept: this column is written by the browser, so it is an input like any other, * and a fixed shape is what stops it becoming a place to put arbitrary data. */ @@ -653,7 +668,7 @@ app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => { clearLoginAttempts(req); const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" }); - res.json({ token, user: publicUser(user) }); + res.json({ token, user: signedInUser(user) }); } catch (err) { next(err); } }); @@ -661,7 +676,7 @@ app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => { app.get("/api/auth/me", authMiddleware, (req, res) => { const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); if (!user) return res.status(404).json({ error: "User not found" }); - res.json(publicUser(user)); + res.json(signedInUser(user)); }); app.put("/api/auth/profile", authMiddleware, (req, res) => { diff --git a/e2e/lib.mjs b/e2e/lib.mjs index de2b7ae..d527205 100644 --- a/e2e/lib.mjs +++ b/e2e/lib.mjs @@ -184,14 +184,9 @@ export async function createNote(page) { await page.waitForTimeout(1200); } -/** Opens the search box in the note list and types into it. */ +/** Types into the note list's search box, which is always there. */ export async function search(page, query) { - const box = page.locator('input[aria-label="Search in this notebook"]'); - if ((await box.count()) === 0) { - await page.locator("[data-search-toggle]").click(); - await page.waitForTimeout(400); - } - await box.fill(query); + await page.locator('input[aria-label="Search in this notebook"]').fill(query); await page.waitForTimeout(1300); } diff --git a/e2e/organise.mjs b/e2e/organise.mjs index 78458db..daf422b 100644 --- a/e2e/organise.mjs +++ b/e2e/organise.mjs @@ -107,6 +107,18 @@ try { `${beforeDrag.join(",")} → ${arranged.join(",")}` ); + /* The search box is simply there, with nothing to open first. A magnifying + * glass costs a click and having to remember that searching is possible; the + * box is one line and says for itself what it does. */ + check( + (await p.locator('input[aria-label="Search in this notebook"]').count()) === 1, + "the search box is there without opening anything" + ); + check( + (await p.locator("[data-search-toggle]").count()) === 0, + "and the button that used to open it is gone" + ); + /* The point of the whole feature. */ await search(p, "Beta"); const searched = await titles(); @@ -371,6 +383,24 @@ try { "and it lands in the notebook that was chosen, not the first one", `${fresh?.notebook_id} vs ${target.id}` ); + + /* ── choosing a cover ── + * Covers are patterns as well as colours now, and there are enough of them + * that the grid has to scroll: without that the dialog runs past the bottom of + * a laptop screen and takes the Create button with it. */ + await p.setViewportSize({ width: 1440, height: 800 }); + await p.locator("button").filter({ hasText: "Dashboard" }).first().click(); + await p.waitForTimeout(600); + await p.getByRole("button", { name: "Add Notebook" }).click(); + await p.waitForTimeout(700); + const covers = p.locator('[data-cover-option]'); + check((await covers.count()) > 20, "there are patterned covers as well as plain ones", `${await covers.count()} covers`); + check( + await p.getByRole("button", { name: "Create", exact: true }).isVisible(), + "and the dialog still fits a laptop screen, Create and all" + ); + await p.getByRole("button", { name: "Cancel" }).click(); + await p.waitForTimeout(400); } catch (e) { check(false, "suite ran to the end", e.message); } finally { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8fd0501..e4319fd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -699,6 +699,7 @@ export default function App() { notebook={editingNb} onClose={() => setEditingNb(null)} onSave={(u) => updateNotebook(editingNb.id, u)} + defaultMaxVersions={user?.defaultMaxVersions} dark={dark} /> )} diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx index 1cce7b3..8931f78 100644 --- a/frontend/src/features/notes/NoteList.jsx +++ b/frontend/src/features/notes/NoteList.jsx @@ -346,7 +346,6 @@ export function NoteList({ }) { // Opened by the button and kept open while there is something typed in it, so // a search never disappears because the box collapsed under it. - const [searching, setSearching] = useState(false); return (
{!isTrashView && } - {!isTrashView && ( - - )} {!isTrashView && ( onQueryChange(e.target.value)} onKeyDown={(e) => { - if (e.key === "Escape") { - onQueryChange(""); - setSearching(false); - } + if (e.key !== "Escape") return; + onQueryChange(""); + e.currentTarget.blur(); }} placeholder={`Search in ${title}...`} aria-label="Search in this notebook" diff --git a/frontend/src/features/pdf/PdfMargin.jsx b/frontend/src/features/pdf/PdfMargin.jsx index 5b90653..4cee9e1 100644 --- a/frontend/src/features/pdf/PdfMargin.jsx +++ b/frontend/src/features/pdf/PdfMargin.jsx @@ -15,8 +15,11 @@ import { useEffect, useRef, useState } from "react"; * others, because that one is being read closely and the rest are not. */ -export const MARGIN_W = 208; -const CARD_H = 74; +/* The margin is a column you actually write in, not a strip of labels. It was + * 208 x 74, which held about three short lines and left the space beside the + * page mostly empty — for a remark on a passage, three lines is a caption. */ +export const MARGIN_W = 288; +const CARD_H = 112; const CARD_GAP = 6; /** Cards at their anchors, pushed down where they would sit on top of each other. */ @@ -108,7 +111,7 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) { }} aria-label="Pinned note" placeholder="Note on this passage…" - rows={4} + rows={7} style={{ width: "100%", resize: "vertical", @@ -116,9 +119,9 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) { borderRadius: 8, background: "transparent", color: T.text, - fontSize: 11.5, - lineHeight: 1.5, - padding: "5px 7px", + fontSize: 12.5, + lineHeight: 1.55, + padding: "7px 9px", outline: "none", fontFamily: "inherit", }} @@ -162,13 +165,13 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) { ) : (
setVersions(e.target.value)} - placeholder="Vorgabe" + placeholder={defaultMaxVersions ? String(defaultMaxVersions) : "Default"} aria-label="Versions per note" style={{ width: 110, @@ -108,16 +108,29 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) { }} />
- Older versions are only deleted once newer ones push them out — never by age. Leave empty for the - server's default. + Older versions are only deleted once newer ones push them out — never by age. Leave it empty + for the server's default{defaultMaxVersions ? `, which is ${defaultMaxVersions}` : ""}.
Cover style
-
+
{COVERS.map((c, i) => (