From 8ca1ed6e06ec39b2f507ada59b6b5c66e07d8ea6 Mon Sep 17 00:00:00 2001 From: marc Date: Wed, 12 Aug 2026 02:20:37 +0200 Subject: [PATCH] Choose a cover in two parts, and let the app be installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cover is a colour *and* a pattern now, chosen in turn. Offering every combination as its own entry meant fifteen colours times a dozen patterns in a grid nobody could take in — and in practice only the handful someone had thought to write out. Each pattern is previewed over the colour in hand, so what is on screen is the cover as it will be. `cover_pattern` is its own column: they are two decisions, and folding them into one number would mean renumbering every cover the day a pattern is added. For one day the patterns *were* twelve appended entries. Those indices are read back as the colour-and-pattern pair they were drawn as, so nothing chosen then looks different now. And three separate reasons the install button never appeared, none of them in anyone's configuration: - `beforeinstallprompt` fires once, early — usually before React has mounted — and is never fired again. A listener added from inside a component simply missed it. It is caught at the top of index.html now. - A `` is fetched with credentials omitted unless it says otherwise. Behind a reverse proxy that authenticates in front of the app, that one request comes back as the proxy's login page and the browser reads it as an invalid manifest — while the app itself works perfectly, because its own requests carry the cookie. - The service worker cached whatever came back, so a lapsed session could be stored as the application itself. The app would then "work offline" by showing a login screen it could never get past, curable only by clearing the site's data. Only successful, unredirected responses are cached now, and the cache name is bumped to drop anything already stored under the old rules. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 31 +++ CLAUDE.md | 13 ++ backend/server.js | 26 ++- e2e/organise.mjs | 22 +- frontend/index.html | 23 ++- frontend/public/sw.js | 27 ++- frontend/src/App.jsx | 4 +- frontend/src/features/notebooks/Dashboard.jsx | 4 +- frontend/src/hooks/usePwaInstall.ts | 24 ++- frontend/src/syncEngine.ts | 48 ++++- frontend/src/types.ts | 2 + frontend/src/ui/NotebookCover.jsx | 9 +- frontend/src/ui/NotebookModals.jsx | 179 +++++++++-------- frontend/src/ui/NotebookPicker.jsx | 4 +- frontend/src/ui/Sidebar.jsx | 4 +- frontend/src/ui/constants.jsx | 190 ++++++++++-------- readme.md | 66 ++++-- 17 files changed, 455 insertions(+), 221 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b44b71..8b84b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,37 @@ not from the output of `docker push`. --- +## Unreleased + +**Changed** +- **A cover is now a colour and a pattern, chosen in turn**, rather than a list + with the combinations someone happened to write out. Every combination is + available, and each pattern is previewed over the colour in hand. Covers saved + during the day the patterns were one list are read back as the pair they were + drawn as, so nothing repaints itself. + +**Fixed** +- **The install button never appeared, even over HTTPS.** Two causes, both in + this app rather than in anyone's configuration: + - `beforeinstallprompt` fires once, early — usually before React has mounted — + and is never fired again, so a listener added from inside a component simply + missed it. It is caught at the top of `index.html` now. + - The manifest is fetched **without credentials** unless the link says + otherwise. Behind a reverse proxy that authenticates in front of the app, + that request comes back as the proxy's login page and the browser reads it + as an invalid manifest — while the app itself works perfectly, because its + own requests carry the cookie. The link now says + `crossorigin="use-credentials"`. +- **The service worker could cache a proxy's login page as the application.** + Nothing checked that a response was the thing that had been asked for, so a + lapsed session could be stored as the app shell: it then "worked offline" by + showing a login screen it could never get past, curable only by clearing the + site's data by hand. Redirected and unsuccessful responses are no longer + cached, and the cache name is bumped so anything already stored under the old + rules is dropped. + +--- + ## 2026-08-12 — Making the phone work **Images** — web `sha256:5ea846f3…`, server unchanged at `sha256:06f94f43…` diff --git a/CLAUDE.md b/CLAUDE.md index 8542396..349ea97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,19 @@ ist, was auf dem Server läuft. Der `readme.md` beschreibt den *jetzigen* Stand und wird mitgeändert, wenn sich Verhalten ändert; das Changelog beschreibt den Weg dahin. +## Betrieb bei Marc + +InkNote läuft hinter **Pangolin** (Reverse Proxy mit eigener Anmeldung), über +HTTPS, und wird am Handy als PWA auf **Android** genutzt. Daraus folgen drei +Dinge, die bei PWA- oder Sync-Fehlern zuerst zu prüfen sind: + +- `/manifest.json` und `/sw.js` müssen die Datei liefern, nicht die Login-Seite + des Proxys. Eine **Weiterleitung** auf `sw.js` lässt die Registrierung + scheitern — dann gibt es weder Install noch Offline. +- `/api/*` unverändert durchreichen, `/ws` mit WebSocket-Upgrade. +- Jede UI-Änderung auch am Handy denken: Touch löst kein HTML5-Drag aus, und + `onMouseEnter` gibt es dort nicht. + ## Stil Der Code erklärt in Kommentaren das *Warum*, nicht das *Was* – oft mit dem diff --git a/backend/server.js b/backend/server.js index cec4e79..b8cbe21 100644 --- a/backend/server.js +++ b/backend/server.js @@ -422,6 +422,14 @@ if (!tableColumns("users").includes("preferences")) { * * On `attachments` rather than in a table of its own: it is a property of the * file, it is deduplicated with the file, and it is freed with the file. */ +/* The pattern laid over a notebook's cover, chosen apart from its colour. + * Its own column rather than more values in `cover_index`: they are two + * decisions, and folding them into one number would mean renumbering every + * cover the day a pattern is added. */ +if (!tableColumns("notebooks").includes("cover_pattern")) { + db.exec("ALTER TABLE notebooks ADD COLUMN cover_pattern INTEGER NOT NULL DEFAULT 0"); +} + if (!tableColumns("attachments").includes("text_index")) { db.exec("ALTER TABLE attachments ADD COLUMN text_index BLOB"); } @@ -850,10 +858,10 @@ app.get("/api/notebooks", authMiddleware, (req, res) => { }); app.post("/api/notebooks", authMiddleware, (req, res) => { - const { name, coverIndex } = req.body; + const { name, coverIndex, coverPattern } = req.body; const id = uuid(); - db.prepare("INSERT INTO notebooks (id, user_id, name, cover_index, device_id) VALUES (?, ?, ?, ?, ?)") - .run(id, req.userId, name, coverIndex || 0, req.deviceId); + db.prepare("INSERT INTO notebooks (id, user_id, name, cover_index, cover_pattern, device_id) VALUES (?, ?, ?, ?, ?, ?)") + .run(id, req.userId, name, coverIndex || 0, coverPattern || 0, req.deviceId); db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'notebook', ?, 'create', ?)") .run(req.userId, id, req.deviceId); @@ -884,7 +892,7 @@ function cleanSeparators(input) { } app.put("/api/notebooks/:id", authMiddleware, (req, res) => { - const { name, coverIndex, sortOrder, maxVersions, sortMode, separators } = req.body; + const { name, coverIndex, coverPattern, sortOrder, maxVersions, sortMode, separators } = req.body; const nb = db.prepare("SELECT * FROM notebooks WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!nb) return res.status(404).json({ error: "Not found" }); @@ -902,8 +910,8 @@ app.put("/api/notebooks/:id", authMiddleware, (req, res) => { return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` }); } - db.prepare("UPDATE notebooks SET name = COALESCE(?, name), cover_index = COALESCE(?, cover_index), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), version = version + 1 WHERE id = ?") - .run(name, coverIndex, sortOrder, req.params.id); + db.prepare("UPDATE notebooks SET name = COALESCE(?, name), cover_index = COALESCE(?, cover_index), cover_pattern = COALESCE(?, cover_pattern), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), version = version + 1 WHERE id = ?") + .run(name, coverIndex, coverPattern, sortOrder, req.params.id); if (maxVersions !== undefined) { db.prepare("UPDATE notebooks SET max_versions = ? WHERE id = ?").run(versions, req.params.id); } @@ -916,7 +924,11 @@ app.put("/api/notebooks/:id", authMiddleware, (req, res) => { db.prepare("UPDATE notebooks SET separators = ? WHERE id = ?").run(JSON.stringify(cleaned), req.params.id); } - broadcastToUser(req.userId, { type: "notebook_updated", data: { id: req.params.id, name, coverIndex } }, req.deviceId); + broadcastToUser( + req.userId, + { type: "notebook_updated", data: { id: req.params.id, name, coverIndex, coverPattern } }, + req.deviceId + ); res.json({ success: true, defaultMaxVersions: MAX_VERSIONS_PER_NOTE }); }); diff --git a/e2e/organise.mjs b/e2e/organise.mjs index 153ad9b..3de8a93 100644 --- a/e2e/organise.mjs +++ b/e2e/organise.mjs @@ -395,14 +395,28 @@ try { 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`); + /* Colour and pattern are two choices, not one long list of combinations. */ + check((await p.locator("[data-cover-option]").count()) >= 15, "a colour to choose", `${await p.locator("[data-cover-option]").count()}`); + check((await p.locator("[data-pattern-option]").count()) > 5, "and a pattern to lay over it", `${await p.locator("[data-pattern-option]").count()}`); 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); + + // The two together make the cover, and both reach the server. + await p.getByPlaceholder("Notebook name...").fill("Kariert"); + await p.locator('[data-cover-option="6"]').click(); + await p.locator('[data-pattern-option="2"]').click(); + await p.waitForTimeout(300); + await p.getByRole("button", { name: "Create", exact: true }).click(); + await p.waitForTimeout(2000); + const books = (await api(p, "GET", "/notebooks")).body; + const made = books.find((b) => b.name === "Kariert"); + check( + made?.cover_index === 6 && made?.cover_pattern === 2, + "the colour and the pattern are both stored", + `cover ${made?.cover_index}, pattern ${made?.cover_pattern}` + ); /* ── moving a row with a finger ── * Last in the suite, because moving rows about would pull the ground from * under every check above that names an order. diff --git a/frontend/index.html b/frontend/index.html index 393c4fa..e7d8d1e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,11 +10,32 @@ - + + InkNote +