Choose a cover in two parts, and let the app be installed
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 `<link rel="manifest">` 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
0d8d1180de
commit
8ca1ed6e06
17 files changed
+455
-221
No files matched your search
@@ -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
|
## 2026-08-12 — Making the phone work
|
||||||
|
|
||||||
**Images** — web `sha256:5ea846f3…`, server unchanged at `sha256:06f94f43…`
|
**Images** — web `sha256:5ea846f3…`, server unchanged at `sha256:06f94f43…`
|
||||||
|
|||||||
@@ -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
|
und wird mitgeändert, wenn sich Verhalten ändert; das Changelog beschreibt den
|
||||||
Weg dahin.
|
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
|
## Stil
|
||||||
|
|
||||||
Der Code erklärt in Kommentaren das *Warum*, nicht das *Was* – oft mit dem
|
Der Code erklärt in Kommentaren das *Warum*, nicht das *Was* – oft mit dem
|
||||||
|
|||||||
+19
-7
@@ -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
|
* 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. */
|
||||||
|
/* 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")) {
|
if (!tableColumns("attachments").includes("text_index")) {
|
||||||
db.exec("ALTER TABLE attachments ADD COLUMN text_index BLOB");
|
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) => {
|
app.post("/api/notebooks", authMiddleware, (req, res) => {
|
||||||
const { name, coverIndex } = req.body;
|
const { name, coverIndex, coverPattern } = req.body;
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
db.prepare("INSERT INTO notebooks (id, user_id, name, cover_index, device_id) VALUES (?, ?, ?, ?, ?)")
|
db.prepare("INSERT INTO notebooks (id, user_id, name, cover_index, cover_pattern, device_id) VALUES (?, ?, ?, ?, ?, ?)")
|
||||||
.run(id, req.userId, name, coverIndex || 0, req.deviceId);
|
.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', ?)")
|
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'notebook', ?, 'create', ?)")
|
||||||
.run(req.userId, id, req.deviceId);
|
.run(req.userId, id, req.deviceId);
|
||||||
@@ -884,7 +892,7 @@ function cleanSeparators(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.put("/api/notebooks/:id", authMiddleware, (req, res) => {
|
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);
|
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" });
|
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(", ")}` });
|
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 = ?")
|
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, sortOrder, req.params.id);
|
.run(name, coverIndex, coverPattern, sortOrder, req.params.id);
|
||||||
if (maxVersions !== undefined) {
|
if (maxVersions !== undefined) {
|
||||||
db.prepare("UPDATE notebooks SET max_versions = ? WHERE id = ?").run(versions, req.params.id);
|
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);
|
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 });
|
res.json({ success: true, defaultMaxVersions: MAX_VERSIONS_PER_NOTE });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+18
-4
@@ -395,14 +395,28 @@ try {
|
|||||||
await p.waitForTimeout(600);
|
await p.waitForTimeout(600);
|
||||||
await p.getByRole("button", { name: "Add Notebook" }).click();
|
await p.getByRole("button", { name: "Add Notebook" }).click();
|
||||||
await p.waitForTimeout(700);
|
await p.waitForTimeout(700);
|
||||||
const covers = p.locator('[data-cover-option]');
|
/* Colour and pattern are two choices, not one long list of combinations. */
|
||||||
check((await covers.count()) > 20, "there are patterned covers as well as plain ones", `${await covers.count()} covers`);
|
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(
|
check(
|
||||||
await p.getByRole("button", { name: "Create", exact: true }).isVisible(),
|
await p.getByRole("button", { name: "Create", exact: true }).isVisible(),
|
||||||
"and the dialog still fits a laptop screen, Create and all"
|
"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 ──
|
/* ── moving a row with a finger ──
|
||||||
* Last in the suite, because moving rows about would pull the ground from
|
* Last in the suite, because moving rows about would pull the ground from
|
||||||
* under every check above that names an order.
|
* under every check above that names an order.
|
||||||
|
|||||||
+22
-1
@@ -10,11 +10,32 @@
|
|||||||
<meta name="apple-mobile-web-app-title" content="InkNote" />
|
<meta name="apple-mobile-web-app-title" content="InkNote" />
|
||||||
<meta name="description" content="Write & draw — notes that sync everywhere" />
|
<meta name="description" content="Write & draw — notes that sync everywhere" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<link rel="manifest" href="./manifest.json" />
|
<!-- `use-credentials` because the manifest is otherwise fetched with
|
||||||
|
credentials omitted, cookies and all. Behind an authenticating reverse
|
||||||
|
proxy that request comes back as the proxy's login page, the browser
|
||||||
|
reads it as an invalid manifest, and no install is ever offered — while
|
||||||
|
the app itself works perfectly, because its own requests do carry the
|
||||||
|
cookie. Harmless without a proxy. -->
|
||||||
|
<link rel="manifest" href="./manifest.json" crossorigin="use-credentials" />
|
||||||
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="./icon-192.png" />
|
<link rel="icon" type="image/png" sizes="192x192" href="./icon-192.png" />
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>✏️</text></svg>" />
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>✏️</text></svg>" />
|
||||||
<title>InkNote</title>
|
<title>InkNote</title>
|
||||||
|
<script>
|
||||||
|
/* Catch the browser's install offer before anything else runs.
|
||||||
|
*
|
||||||
|
* `beforeinstallprompt` is fired once, early — usually before React has
|
||||||
|
* mounted — and is never fired again. Listening for it from inside a
|
||||||
|
* component meant it was simply missed, and the install button never
|
||||||
|
* appeared however correctly the site was served. It is stashed here, at
|
||||||
|
* the top of the document, and the app picks it up when it is ready. */
|
||||||
|
window.__inknoteInstallPrompt = null;
|
||||||
|
window.addEventListener("beforeinstallprompt", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.__inknoteInstallPrompt = e;
|
||||||
|
window.dispatchEvent(new Event("inknote:installable"));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; padding: 0; overflow: hidden; overscroll-behavior: none; }
|
body { margin: 0; padding: 0; overflow: hidden; overscroll-behavior: none; }
|
||||||
#root { padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); height: 100vh; }
|
#root { padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); height: 100vh; }
|
||||||
|
|||||||
+23
-4
@@ -1,4 +1,21 @@
|
|||||||
const CACHE_NAME = 'inknote-v7';
|
/* Bumped to v8 to throw away anything cached under the old rules — including,
|
||||||
|
behind an authenticating reverse proxy, a login page that had been stored as
|
||||||
|
the application itself. */
|
||||||
|
const CACHE_NAME = 'inknote-v8';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a response is the thing that was asked for, and worth keeping.
|
||||||
|
*
|
||||||
|
* Behind a proxy that authenticates in front of the app, a request whose session
|
||||||
|
* has lapsed comes back as that proxy's login page — sometimes as a redirect,
|
||||||
|
* sometimes as a plain 200. Storing either one poisons the cache: the app then
|
||||||
|
* "works offline" by showing a login screen it can never get past, and the only
|
||||||
|
* cure is clearing the site's data by hand.
|
||||||
|
*
|
||||||
|
* A redirected response cannot be cached at all — `Cache.put` rejects on one —
|
||||||
|
* so this also stops an unhandled rejection on every navigation.
|
||||||
|
*/
|
||||||
|
const worthCaching = (r) => r && r.ok && !r.redirected && r.type !== 'opaqueredirect';
|
||||||
|
|
||||||
// Install — don't precache, just activate immediately
|
// Install — don't precache, just activate immediately
|
||||||
self.addEventListener('install', () => {
|
self.addEventListener('install', () => {
|
||||||
@@ -28,8 +45,10 @@ self.addEventListener('fetch', (e) => {
|
|||||||
if (e.request.mode === 'navigate') {
|
if (e.request.mode === 'navigate') {
|
||||||
e.respondWith(
|
e.respondWith(
|
||||||
fetch(e.request).then(r => {
|
fetch(e.request).then(r => {
|
||||||
const clone = r.clone();
|
if (worthCaching(r)) {
|
||||||
caches.open(CACHE_NAME).then(c => c.put(e.request, clone));
|
const clone = r.clone();
|
||||||
|
caches.open(CACHE_NAME).then(c => c.put(e.request, clone)).catch(() => {});
|
||||||
|
}
|
||||||
return r;
|
return r;
|
||||||
}).catch(() => caches.match(e.request).then(r => r || caches.match('/')))
|
}).catch(() => caches.match(e.request).then(r => r || caches.match('/')))
|
||||||
);
|
);
|
||||||
@@ -41,7 +60,7 @@ self.addEventListener('fetch', (e) => {
|
|||||||
caches.open(CACHE_NAME).then(cache =>
|
caches.open(CACHE_NAME).then(cache =>
|
||||||
cache.match(e.request).then(cached => {
|
cache.match(e.request).then(cached => {
|
||||||
const fetchP = fetch(e.request).then(r => {
|
const fetchP = fetch(e.request).then(r => {
|
||||||
if (r.ok) cache.put(e.request, r.clone());
|
if (worthCaching(r)) cache.put(e.request, r.clone()).catch(() => {});
|
||||||
return r;
|
return r;
|
||||||
}).catch(() => cached);
|
}).catch(() => cached);
|
||||||
return cached || fetchP;
|
return cached || fetchP;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { usePwaInstall } from "./hooks/usePwaInstall";
|
|||||||
import { useNotebookDnd } from "./hooks/useNotebookDnd";
|
import { useNotebookDnd } from "./hooks/useNotebookDnd";
|
||||||
import { useNavigationHistory } from "./hooks/useNavigationHistory";
|
import { useNavigationHistory } from "./hooks/useNavigationHistory";
|
||||||
|
|
||||||
import { COVERS, DEFAULT_FOLDERS } from "./ui/constants.jsx";
|
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";
|
||||||
@@ -509,7 +509,7 @@ export default function App() {
|
|||||||
.filter((nb) => nb.id !== note.folderId)
|
.filter((nb) => nb.id !== note.folderId)
|
||||||
.map((nb) => ({
|
.map((nb) => ({
|
||||||
label: nb.name,
|
label: nb.name,
|
||||||
colorDot: COVERS[nb.cover % COVERS.length].bg,
|
colorDot: coverBackground(nb.cover, nb.coverPattern),
|
||||||
action: () => data.moveNote(note.id, nb.id),
|
action: () => data.moveNote(note.id, nb.id),
|
||||||
}));
|
}));
|
||||||
return targets.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
|
return targets.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { COVERS } from "../../ui/constants.jsx";
|
import { coverBackground } from "../../ui/constants.jsx";
|
||||||
import { I } from "../../ui/icons.jsx";
|
import { I } from "../../ui/icons.jsx";
|
||||||
import { NotebookCover } from "../../ui/NotebookCover.jsx";
|
import { NotebookCover } from "../../ui/NotebookCover.jsx";
|
||||||
import { fmtDate } from "../../ui/format";
|
import { fmtDate } from "../../ui/format";
|
||||||
@@ -188,7 +188,7 @@ export function Dashboard({
|
|||||||
width: 14,
|
width: 14,
|
||||||
height: 10,
|
height: 10,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
background: COVERS[nb.cover % COVERS.length].bg,
|
background: coverBackground(nb.cover, nb.coverPattern),
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -28,8 +28,19 @@ const isStandalone = () =>
|
|||||||
// iOS keeps its own flag rather than reporting the display mode.
|
// iOS keeps its own flag rather than reporting the display mode.
|
||||||
(navigator as unknown as { standalone?: boolean }).standalone === true;
|
(navigator as unknown as { standalone?: boolean }).standalone === true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The offer, if the browser made one before the app was running.
|
||||||
|
*
|
||||||
|
* `beforeinstallprompt` fires once, early, and is never fired again. A listener
|
||||||
|
* added from a component is routinely too late — which is why the install button
|
||||||
|
* never appeared even on a correctly served site. `index.html` catches it at the
|
||||||
|
* top of the document and leaves it here.
|
||||||
|
*/
|
||||||
|
const stashed = (): BeforeInstallPromptEvent | null =>
|
||||||
|
(window as unknown as { __inknoteInstallPrompt?: BeforeInstallPromptEvent }).__inknoteInstallPrompt ?? null;
|
||||||
|
|
||||||
export function usePwaInstall(): PwaInstall {
|
export function usePwaInstall(): PwaInstall {
|
||||||
const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(null);
|
const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(stashed);
|
||||||
const [installed, setInstalled] = useState(isStandalone);
|
const [installed, setInstalled] = useState(isStandalone);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,14 +48,20 @@ export function usePwaInstall(): PwaInstall {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setEvent(e as BeforeInstallPromptEvent);
|
setEvent(e as BeforeInstallPromptEvent);
|
||||||
};
|
};
|
||||||
|
// Fired by the catcher in index.html when the offer arrives after mount.
|
||||||
|
const onStashed = () => setEvent(stashed());
|
||||||
const onInstalled = () => {
|
const onInstalled = () => {
|
||||||
setInstalled(true);
|
setInstalled(true);
|
||||||
setEvent(null);
|
setEvent(null);
|
||||||
};
|
};
|
||||||
window.addEventListener("beforeinstallprompt", onPrompt);
|
window.addEventListener("beforeinstallprompt", onPrompt);
|
||||||
|
window.addEventListener("inknote:installable", onStashed);
|
||||||
window.addEventListener("appinstalled", onInstalled);
|
window.addEventListener("appinstalled", onInstalled);
|
||||||
|
// One more look, in case it landed between the first render and this effect.
|
||||||
|
setEvent((current) => current ?? stashed());
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("beforeinstallprompt", onPrompt);
|
window.removeEventListener("beforeinstallprompt", onPrompt);
|
||||||
|
window.removeEventListener("inknote:installable", onStashed);
|
||||||
window.removeEventListener("appinstalled", onInstalled);
|
window.removeEventListener("appinstalled", onInstalled);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -55,7 +72,10 @@ export function usePwaInstall(): PwaInstall {
|
|||||||
const { outcome } = await event.userChoice;
|
const { outcome } = await event.userChoice;
|
||||||
// The captured event is single-use; drop it either way so the button does
|
// The captured event is single-use; drop it either way so the button does
|
||||||
// not linger after a decision.
|
// not linger after a decision.
|
||||||
if (outcome === "accepted") setEvent(null);
|
if (outcome === "accepted") {
|
||||||
|
setEvent(null);
|
||||||
|
(window as unknown as { __inknoteInstallPrompt?: unknown }).__inknoteInstallPrompt = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* An app can only be installed from a secure origin. Served over plain HTTP
|
/* An app can only be installed from a secure origin. Served over plain HTTP
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ interface ServerNotebook {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
cover_index?: number;
|
cover_index?: number;
|
||||||
|
cover_pattern?: number;
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
max_versions?: number | null;
|
max_versions?: number | null;
|
||||||
sort_mode?: string | null;
|
sort_mode?: string | null;
|
||||||
@@ -409,6 +410,7 @@ export async function fullSync(): Promise<void> {
|
|||||||
id: nb.id,
|
id: nb.id,
|
||||||
name: nb.name,
|
name: nb.name,
|
||||||
cover: nb.cover_index || 0,
|
cover: nb.cover_index || 0,
|
||||||
|
coverPattern: nb.cover_pattern || 0,
|
||||||
sortOrder: nb.sort_order || 0,
|
sortOrder: nb.sort_order || 0,
|
||||||
maxVersions: nb.max_versions ?? null,
|
maxVersions: nb.max_versions ?? null,
|
||||||
sortMode: (nb.sort_mode as Notebook["sortMode"]) ?? null,
|
sortMode: (nb.sort_mode as Notebook["sortMode"]) ?? null,
|
||||||
@@ -484,13 +486,17 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta {
|
|||||||
|
|
||||||
/** Every kind of change that can wait offline. */
|
/** Every kind of change that can wait offline. */
|
||||||
type QueuedAction =
|
type QueuedAction =
|
||||||
| { action: "createNotebook"; data: { localId: string; name: string; cover: number; sortOrder: number } }
|
| {
|
||||||
|
action: "createNotebook";
|
||||||
|
data: { localId: string; name: string; cover: number; coverPattern?: number; sortOrder: number };
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
action: "updateNotebook";
|
action: "updateNotebook";
|
||||||
data: {
|
data: {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
cover?: number;
|
cover?: number;
|
||||||
|
coverPattern?: number;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
maxVersions?: number | null;
|
maxVersions?: number | null;
|
||||||
sortMode?: Notebook["sortMode"];
|
sortMode?: Notebook["sortMode"];
|
||||||
@@ -562,12 +568,22 @@ async function processEntry(entry: QueueEntry): Promise<void> {
|
|||||||
switch (a.action) {
|
switch (a.action) {
|
||||||
case "createNotebook": {
|
case "createNotebook": {
|
||||||
const d = a.data;
|
const d = a.data;
|
||||||
const r = await api.post<{ id: string }>("/notebooks", { name: d.name, coverIndex: d.cover });
|
const r = await api.post<{ id: string }>("/notebooks", {
|
||||||
|
name: d.name,
|
||||||
|
coverIndex: d.cover,
|
||||||
|
coverPattern: d.coverPattern || 0,
|
||||||
|
});
|
||||||
if (r.id !== d.localId) {
|
if (r.id !== d.localId) {
|
||||||
// The server assigned a different id — repoint everything that referenced
|
// The server assigned a different id — repoint everything that referenced
|
||||||
// the temporary local one.
|
// the temporary local one.
|
||||||
await db.deleteNotebook(d.localId);
|
await db.deleteNotebook(d.localId);
|
||||||
await db.putNotebook({ id: r.id, name: d.name, cover: d.cover, sortOrder: d.sortOrder || 0 });
|
await db.putNotebook({
|
||||||
|
id: r.id,
|
||||||
|
name: d.name,
|
||||||
|
cover: d.cover,
|
||||||
|
coverPattern: d.coverPattern || 0,
|
||||||
|
sortOrder: d.sortOrder || 0,
|
||||||
|
});
|
||||||
for (const n of await db.getAllNotes()) {
|
for (const n of await db.getAllNotes()) {
|
||||||
if (n.folderId === d.localId) await db.putNote({ ...n, folderId: r.id });
|
if (n.folderId === d.localId) await db.putNote({ ...n, folderId: r.id });
|
||||||
}
|
}
|
||||||
@@ -581,6 +597,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
|
|||||||
await api.put(`/notebooks/${a.data.id}`, {
|
await api.put(`/notebooks/${a.data.id}`, {
|
||||||
name: a.data.name,
|
name: a.data.name,
|
||||||
coverIndex: a.data.cover,
|
coverIndex: a.data.cover,
|
||||||
|
coverPattern: a.data.coverPattern,
|
||||||
sortOrder: a.data.sortOrder,
|
sortOrder: a.data.sortOrder,
|
||||||
...(a.data.maxVersions !== undefined ? { maxVersions: a.data.maxVersions } : {}),
|
...(a.data.maxVersions !== undefined ? { maxVersions: a.data.maxVersions } : {}),
|
||||||
...(a.data.sortMode !== undefined ? { sortMode: a.data.sortMode } : {}),
|
...(a.data.sortMode !== undefined ? { sortMode: a.data.sortMode } : {}),
|
||||||
@@ -698,11 +715,25 @@ export async function getNotebooks(): Promise<Notebook[]> {
|
|||||||
return db.getAllNotebooks();
|
return db.getAllNotebooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createNotebook({ name, cover }: { name: string; cover: number }): Promise<Notebook> {
|
export async function createNotebook({
|
||||||
|
name,
|
||||||
|
cover,
|
||||||
|
coverPattern = 0,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
cover: number;
|
||||||
|
coverPattern?: number;
|
||||||
|
}): Promise<Notebook> {
|
||||||
if (_online && api.token) {
|
if (_online && api.token) {
|
||||||
try {
|
try {
|
||||||
const r = await api.post<{ id: string }>("/notebooks", { name, coverIndex: cover });
|
const r = await api.post<{ id: string }>("/notebooks", { name, coverIndex: cover, coverPattern });
|
||||||
const nb: Notebook = { id: r.id, name, cover, sortOrder: (await db.getAllNotebooks()).length };
|
const nb: Notebook = {
|
||||||
|
id: r.id,
|
||||||
|
name,
|
||||||
|
cover,
|
||||||
|
coverPattern,
|
||||||
|
sortOrder: (await db.getAllNotebooks()).length,
|
||||||
|
};
|
||||||
await db.putNotebook(nb);
|
await db.putNotebook(nb);
|
||||||
notifyData("notebook", nb);
|
notifyData("notebook", nb);
|
||||||
return nb;
|
return nb;
|
||||||
@@ -716,13 +747,14 @@ export async function createNotebook({ name, cover }: { name: string; cover: num
|
|||||||
id: lid,
|
id: lid,
|
||||||
name,
|
name,
|
||||||
cover,
|
cover,
|
||||||
|
coverPattern,
|
||||||
sortOrder: (await db.getAllNotebooks()).length,
|
sortOrder: (await db.getAllNotebooks()).length,
|
||||||
_pendingCreate: true,
|
_pendingCreate: true,
|
||||||
};
|
};
|
||||||
await db.putNotebook(nb);
|
await db.putNotebook(nb);
|
||||||
await enqueue({
|
await enqueue({
|
||||||
action: "createNotebook",
|
action: "createNotebook",
|
||||||
data: { localId: lid, name, cover, sortOrder: nb.sortOrder },
|
data: { localId: lid, name, cover, coverPattern, sortOrder: nb.sortOrder },
|
||||||
});
|
});
|
||||||
notifyData("notebook", nb);
|
notifyData("notebook", nb);
|
||||||
notifyStatus("pending");
|
notifyStatus("pending");
|
||||||
@@ -742,6 +774,7 @@ export async function updateNotebook(id: string, updates: Partial<Notebook>): Pr
|
|||||||
await api.put(`/notebooks/${id}`, {
|
await api.put(`/notebooks/${id}`, {
|
||||||
name: updates.name,
|
name: updates.name,
|
||||||
coverIndex: updates.cover,
|
coverIndex: updates.cover,
|
||||||
|
coverPattern: updates.coverPattern,
|
||||||
sortOrder: updates.sortOrder,
|
sortOrder: updates.sortOrder,
|
||||||
// null is meaningful here — it clears the notebook's own setting — so it
|
// null is meaningful here — it clears the notebook's own setting — so it
|
||||||
// has to travel, while undefined must stay away and change nothing.
|
// has to travel, while undefined must stay away and change nothing.
|
||||||
@@ -760,6 +793,7 @@ export async function updateNotebook(id: string, updates: Partial<Notebook>): Pr
|
|||||||
id,
|
id,
|
||||||
name: updates.name,
|
name: updates.name,
|
||||||
cover: updates.cover,
|
cover: updates.cover,
|
||||||
|
coverPattern: updates.coverPattern,
|
||||||
sortOrder: updates.sortOrder,
|
sortOrder: updates.sortOrder,
|
||||||
maxVersions: updates.maxVersions,
|
maxVersions: updates.maxVersions,
|
||||||
sortMode: updates.sortMode,
|
sortMode: updates.sortMode,
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export interface Notebook {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
cover: number;
|
cover: number;
|
||||||
|
/** Pattern laid over the colour, chosen separately from it. */
|
||||||
|
coverPattern?: number;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
/** Versions kept per note here; null means the server's default. */
|
/** Versions kept per note here; null means the server's default. */
|
||||||
maxVersions?: number | null;
|
maxVersions?: number | null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { COVERS } from "./constants.jsx";
|
import { coverBackground, coverStyle } from "./constants.jsx";
|
||||||
|
|
||||||
/* ═══════ NOTEBOOK COVER ═══════ */
|
/* ═══════ NOTEBOOK COVER ═══════ */
|
||||||
export function NotebookCover({
|
export function NotebookCover({
|
||||||
@@ -15,7 +15,10 @@ export function NotebookCover({
|
|||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
}) {
|
}) {
|
||||||
const cover = COVERS[notebook.cover % COVERS.length];
|
// Colour and pattern are two choices; the spine and accent come from the
|
||||||
|
// colour, the face from both.
|
||||||
|
const cover = coverStyle(notebook.cover, notebook.coverPattern);
|
||||||
|
const face = coverBackground(notebook.cover, notebook.coverPattern);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -114,7 +117,7 @@ export function NotebookCover({
|
|||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
background: cover.bg,
|
background: face,
|
||||||
borderRadius: "2px 8px 8px 2px",
|
borderRadius: "2px 8px 8px 2px",
|
||||||
boxShadow: isActive
|
boxShadow: isActive
|
||||||
? `0 4px 20px rgba(0,0,0,0.3),0 0 0 3px ${cover.accent}88`
|
? `0 4px 20px rgba(0,0,0,0.3),0 0 0 3px ${cover.accent}88`
|
||||||
|
|||||||
@@ -1,9 +1,87 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { COVERS } from "./constants.jsx";
|
import { COVERS, PATTERNS, coverBackground, coverParts } from "./constants.jsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choosing a cover: its colour, then the pattern over it.
|
||||||
|
*
|
||||||
|
* Two decisions rather than one list. Offering every combination as its own
|
||||||
|
* entry meant fifteen colours times a dozen patterns in a grid nobody could take
|
||||||
|
* in — and only the handful someone had thought to write out. Each pattern is
|
||||||
|
* previewed over the colour that is currently chosen, so what is on screen is
|
||||||
|
* the cover as it will be, not a sample of the pattern on its own.
|
||||||
|
*/
|
||||||
|
function CoverChooser({ T, cover, pattern, onCover, onPattern }) {
|
||||||
|
const swatch = (selected) => ({
|
||||||
|
width: "100%",
|
||||||
|
aspectRatio: "3/4",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: selected ? `3px solid ${T.active}` : "2px solid transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 22 }}>
|
||||||
|
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Cover colour</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(5,1fr)",
|
||||||
|
gap: 8,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: "auto",
|
||||||
|
paddingRight: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{COVERS.map((c, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
data-cover-option={i}
|
||||||
|
onClick={() => onCover(i)}
|
||||||
|
style={{ ...swatch(coverParts(cover, pattern).cover === i), background: c.bg }}
|
||||||
|
>
|
||||||
|
<div style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 4, background: c.spine }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, color: T.muted, margin: "14px 0 8px" }}>Pattern</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(5,1fr)",
|
||||||
|
gap: 8,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: "auto",
|
||||||
|
paddingRight: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{PATTERNS.map((p, i) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
data-pattern-option={i}
|
||||||
|
title={p.label}
|
||||||
|
onClick={() => onPattern(i)}
|
||||||
|
style={{
|
||||||
|
...swatch(coverParts(cover, pattern).pattern === i),
|
||||||
|
// Previewed over the colour in hand, because that is the cover.
|
||||||
|
background: coverBackground(cover, i),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxVersions }) {
|
export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxVersions }) {
|
||||||
const [name, setName] = useState(notebook.name);
|
const [name, setName] = useState(notebook.name);
|
||||||
const [sel, setSel] = useState(notebook.cover);
|
/* Colour and pattern are separate choices now. A notebook saved when they
|
||||||
|
were one list holds an index into that list, so it is read back as the pair
|
||||||
|
it was drawn as rather than repainting itself. */
|
||||||
|
const [sel, setSel] = useState(() => coverParts(notebook.cover, notebook.coverPattern).cover);
|
||||||
|
const [pat, setPat] = useState(() => coverParts(notebook.cover, notebook.coverPattern).pattern);
|
||||||
// Empty means "use the server's default", which is why it is a string here and
|
// Empty means "use the server's default", which is why it is a string here and
|
||||||
// not a number with a stand-in value.
|
// not a number with a stand-in value.
|
||||||
const [versions, setVersions] = useState(notebook.maxVersions == null ? "" : String(notebook.maxVersions));
|
const [versions, setVersions] = useState(notebook.maxVersions == null ? "" : String(notebook.maxVersions));
|
||||||
@@ -11,7 +89,7 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxV
|
|||||||
const save = () => {
|
const save = () => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
const n = versions.trim() === "" ? null : Math.max(1, Math.round(Number(versions)));
|
const n = versions.trim() === "" ? null : Math.max(1, Math.round(Number(versions)));
|
||||||
onSave({ name: name.trim(), cover: sel, maxVersions: Number.isFinite(n) ? n : null });
|
onSave({ name: name.trim(), cover: sel, coverPattern: pat, maxVersions: Number.isFinite(n) ? n : null });
|
||||||
};
|
};
|
||||||
const T = useMemo(
|
const T = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -112,44 +190,13 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxV
|
|||||||
for the server's default{defaultMaxVersions ? `, which is ${defaultMaxVersions}` : ""}.
|
for the server's default{defaultMaxVersions ? `, which is ${defaultMaxVersions}` : ""}.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: 22 }}>
|
<CoverChooser
|
||||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Cover style</div>
|
T={T}
|
||||||
<div
|
cover={sel}
|
||||||
style={{
|
pattern={pat}
|
||||||
display: "grid",
|
onCover={setSel}
|
||||||
gridTemplateColumns: "repeat(5,1fr)",
|
onPattern={setPat}
|
||||||
gap: 8,
|
/>
|
||||||
/* Scrolls rather than growing. With patterns the list is long
|
|
||||||
enough that the dialog would run past the bottom of a laptop
|
|
||||||
screen and take the Create button with it. */
|
|
||||||
maxHeight: 268,
|
|
||||||
overflowY: "auto",
|
|
||||||
paddingRight: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{COVERS.map((c, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
data-cover-option={i}
|
|
||||||
onClick={() => setSel(i)}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
aspectRatio: "3/4",
|
|
||||||
borderRadius: 8,
|
|
||||||
background: c.bg,
|
|
||||||
border: sel === i ? `3px solid ${T.active}` : "2px solid transparent",
|
|
||||||
cursor: "pointer",
|
|
||||||
position: "relative",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 4, background: c.spine }}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
|
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -192,6 +239,7 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxV
|
|||||||
export function CreateNotebookModal({ onClose, onCreate, dark }) {
|
export function CreateNotebookModal({ onClose, onCreate, dark }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [sel, setSel] = useState(0);
|
const [sel, setSel] = useState(0);
|
||||||
|
const [pat, setPat] = useState(0);
|
||||||
const T = useMemo(
|
const T = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
bg: dark ? "#161b22" : "#fff",
|
bg: dark ? "#161b22" : "#fff",
|
||||||
@@ -258,47 +306,16 @@ export function CreateNotebookModal({ onClose, onCreate, dark }) {
|
|||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && name.trim()) onCreate({ name: name.trim(), cover: sel });
|
if (e.key === "Enter" && name.trim()) onCreate({ name: name.trim(), cover: sel, coverPattern: pat });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginBottom: 22 }}>
|
<CoverChooser
|
||||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Choose cover style</div>
|
T={T}
|
||||||
<div
|
cover={sel}
|
||||||
style={{
|
pattern={pat}
|
||||||
display: "grid",
|
onCover={setSel}
|
||||||
gridTemplateColumns: "repeat(5,1fr)",
|
onPattern={setPat}
|
||||||
gap: 8,
|
/>
|
||||||
/* Scrolls rather than growing. With patterns the list is long
|
|
||||||
enough that the dialog would run past the bottom of a laptop
|
|
||||||
screen and take the Create button with it. */
|
|
||||||
maxHeight: 268,
|
|
||||||
overflowY: "auto",
|
|
||||||
paddingRight: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{COVERS.map((c, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
data-cover-option={i}
|
|
||||||
onClick={() => setSel(i)}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
aspectRatio: "3/4",
|
|
||||||
borderRadius: 8,
|
|
||||||
background: c.bg,
|
|
||||||
border: sel === i ? `3px solid ${T.active}` : "2px solid transparent",
|
|
||||||
cursor: "pointer",
|
|
||||||
position: "relative",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 4, background: c.spine }}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
|
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -317,7 +334,7 @@ export function CreateNotebookModal({ onClose, onCreate, dark }) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (name.trim()) onCreate({ name: name.trim(), cover: sel });
|
if (name.trim()) onCreate({ name: name.trim(), cover: sel, coverPattern: pat });
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: "8px 20px",
|
padding: "8px 20px",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { COVERS } from "./constants.jsx";
|
import { coverBackground } from "./constants.jsx";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asks which notebook something should go into.
|
* Asks which notebook something should go into.
|
||||||
@@ -141,7 +141,7 @@ export function NotebookPicker({ notebooks, title, confirmLabel = "Continue", on
|
|||||||
height: 10,
|
height: 10,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
background: COVERS[nb.cover % COVERS.length].bg,
|
background: coverBackground(nb.cover, nb.coverPattern),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { COVERS, DEFAULT_FOLDERS } from "./constants.jsx";
|
import { DEFAULT_FOLDERS, coverBackground } from "./constants.jsx";
|
||||||
import { I } from "./icons.jsx";
|
import { I } from "./icons.jsx";
|
||||||
import { FONT_UI } from "./theme";
|
import { FONT_UI } from "./theme";
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ export function Sidebar({
|
|||||||
width: 20,
|
width: 20,
|
||||||
height: 14,
|
height: 14,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
background: COVERS[nb.cover % COVERS.length].bg,
|
background: coverBackground(nb.cover, nb.coverPattern),
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
|
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
|
||||||
}}
|
}}
|
||||||
|
|||||||
+102
-88
@@ -40,94 +40,6 @@ export const COVERS = [
|
|||||||
{ bg: "linear-gradient(135deg,#1f1c2c,#928dab)", spine: "#15131f", accent: "#b39ddb" },
|
{ bg: "linear-gradient(135deg,#1f1c2c,#928dab)", spine: "#15131f", accent: "#b39ddb" },
|
||||||
{ bg: "linear-gradient(135deg,#654ea3,#eaafc8)", spine: "#4e3c7e", accent: "#f8bbd0" },
|
{ bg: "linear-gradient(135deg,#654ea3,#eaafc8)", spine: "#4e3c7e", accent: "#f8bbd0" },
|
||||||
|
|
||||||
/* ── Patterned ──
|
|
||||||
*
|
|
||||||
* A cover is stored as an *index* into this list, so these are appended and
|
|
||||||
* never reordered: renumbering them would silently repaint every notebook
|
|
||||||
* anyone already has.
|
|
||||||
*
|
|
||||||
* Drawn entirely with CSS gradient layers — no image files, nothing to
|
|
||||||
* upload, nothing to fetch, and they stay crisp at any size and work offline
|
|
||||||
* like everything else here. `background` takes a stack of layers, topmost
|
|
||||||
* first, so each of these is a pattern laid over the colour beneath it.
|
|
||||||
*
|
|
||||||
* The patterns are deliberately faint. A cover is read at the size of a thumb
|
|
||||||
* with a title across it, and anything louder fights the title. */
|
|
||||||
|
|
||||||
// Ruled paper — the one that actually looks like a notebook.
|
|
||||||
{
|
|
||||||
bg: "repeating-linear-gradient(0deg,rgba(255,255,255,.10) 0 1px,transparent 1px 17px), linear-gradient(135deg,#1b2735,#2c5364)",
|
|
||||||
spine: "#101a24",
|
|
||||||
accent: "#80cbc4",
|
|
||||||
},
|
|
||||||
// Graph paper.
|
|
||||||
{
|
|
||||||
bg: "repeating-linear-gradient(0deg,rgba(255,255,255,.09) 0 1px,transparent 1px 20px), repeating-linear-gradient(90deg,rgba(255,255,255,.09) 0 1px,transparent 1px 20px), linear-gradient(135deg,#243b2f,#3e7a5e)",
|
|
||||||
spine: "#16281f",
|
|
||||||
accent: "#a5d6a7",
|
|
||||||
},
|
|
||||||
// Diagonal pinstripe.
|
|
||||||
{
|
|
||||||
bg: "repeating-linear-gradient(45deg,rgba(255,255,255,.08) 0 6px,transparent 6px 14px), linear-gradient(135deg,#3a1c71,#5c2a8f)",
|
|
||||||
spine: "#2a1352",
|
|
||||||
accent: "#ce93d8",
|
|
||||||
},
|
|
||||||
// Wide diagonal bands.
|
|
||||||
{
|
|
||||||
bg: "repeating-linear-gradient(-45deg,rgba(0,0,0,.16) 0 18px,transparent 18px 36px), linear-gradient(135deg,#f7971e,#ffd200)",
|
|
||||||
spine: "#c2740f",
|
|
||||||
accent: "#fff59d",
|
|
||||||
},
|
|
||||||
// Dots.
|
|
||||||
{
|
|
||||||
bg: "radial-gradient(rgba(255,255,255,.16) 1.4px,transparent 1.5px) 0 0/13px 13px, linear-gradient(135deg,#8b1e3f,#3c1053)",
|
|
||||||
spine: "#5e1029",
|
|
||||||
accent: "#f48fb1",
|
|
||||||
},
|
|
||||||
// Cross-hatch, both diagonals.
|
|
||||||
{
|
|
||||||
bg: "repeating-linear-gradient(45deg,rgba(255,255,255,.07) 0 1px,transparent 1px 11px), repeating-linear-gradient(-45deg,rgba(255,255,255,.07) 0 1px,transparent 1px 11px), linear-gradient(135deg,#232526,#414345)",
|
|
||||||
spine: "#141516",
|
|
||||||
accent: "#b0bec5",
|
|
||||||
},
|
|
||||||
// Linen weave — very fine, reads as texture rather than as lines.
|
|
||||||
{
|
|
||||||
bg: "repeating-linear-gradient(0deg,rgba(255,255,255,.06) 0 1px,transparent 1px 4px), repeating-linear-gradient(90deg,rgba(0,0,0,.06) 0 1px,transparent 1px 4px), linear-gradient(135deg,#6b705c,#a5a58d)",
|
|
||||||
spine: "#4a4e40",
|
|
||||||
accent: "#ddbea9",
|
|
||||||
},
|
|
||||||
// Broad horizontal bands. Two crossed diagonals were meant to be a chevron
|
|
||||||
// and came out as noisy plaid — at thumbnail size a pattern has to be one
|
|
||||||
// idea, not two.
|
|
||||||
{
|
|
||||||
bg: "repeating-linear-gradient(0deg,rgba(0,0,0,.20) 0 13px,transparent 13px 26px), linear-gradient(135deg,#0b486b,#f56217)",
|
|
||||||
spine: "#073349",
|
|
||||||
accent: "#ffcc80",
|
|
||||||
},
|
|
||||||
// Light from the top corner, over deep blue — nothing repeating at all.
|
|
||||||
{
|
|
||||||
bg: "radial-gradient(120% 85% at 18% 0%,rgba(255,255,255,.22),transparent 62%), linear-gradient(135deg,#0f2027,#16324f)",
|
|
||||||
spine: "#08161d",
|
|
||||||
accent: "#90caf9",
|
|
||||||
},
|
|
||||||
// Two washes crossing, like ink bleeding into paper.
|
|
||||||
{
|
|
||||||
bg: "radial-gradient(80% 60% at 85% 15%,rgba(255,145,180,.35),transparent 60%), radial-gradient(90% 70% at 10% 90%,rgba(120,200,255,.30),transparent 60%), linear-gradient(135deg,#20143a,#3a2757)",
|
|
||||||
spine: "#150d28",
|
|
||||||
accent: "#f8bbd0",
|
|
||||||
},
|
|
||||||
// A band across the foot, the way a label sits on a ledger.
|
|
||||||
{
|
|
||||||
bg: "linear-gradient(0deg,rgba(0,0,0,.35) 0 22%,transparent 22%), linear-gradient(135deg,#134e5e,#71b280)",
|
|
||||||
spine: "#0b3a46",
|
|
||||||
accent: "#b9f6ca",
|
|
||||||
},
|
|
||||||
// Fine vertical rules on near-black — a ledger.
|
|
||||||
{
|
|
||||||
bg: "repeating-linear-gradient(90deg,rgba(255,255,255,.08) 0 1px,transparent 1px 26px), linear-gradient(135deg,#111318,#242a33)",
|
|
||||||
spine: "#0a0c10",
|
|
||||||
accent: "#cfd8dc",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
/* The icon is a key rather than an emoji: the sidebar draws it from the app's
|
/* The icon is a key rather than an emoji: the sidebar draws it from the app's
|
||||||
* own set, so it matches the stroke weight of everything around it and looks
|
* own set, so it matches the stroke weight of everything around it and looks
|
||||||
@@ -137,3 +49,105 @@ export const DEFAULT_FOLDERS = [
|
|||||||
{ id: "starred", name: "Starred", icon: "star" },
|
{ id: "starred", name: "Starred", icon: "star" },
|
||||||
{ id: "trash", name: "Trash", icon: "trash" },
|
{ id: "trash", name: "Trash", icon: "trash" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pattern laid over a cover, chosen separately from its colour.
|
||||||
|
*
|
||||||
|
* Two decisions rather than one list: a cover is a colour *and* a texture, and
|
||||||
|
* offering every combination as its own entry would mean fifteen gradients times
|
||||||
|
* a dozen patterns in a grid nobody could take in. Picking each in turn is both
|
||||||
|
* shorter and gives every combination, not the handful someone wrote out.
|
||||||
|
*
|
||||||
|
* `layers` are CSS background layers laid **over** the gradient — no image
|
||||||
|
* files, nothing to upload, crisp at any size and offline like the rest of the
|
||||||
|
* app. They are deliberately faint: a cover is read at the size of a thumb with
|
||||||
|
* a title across it, and anything louder fights the title. One idea each, too —
|
||||||
|
* two crossed diagonals meant as a chevron came out as noisy plaid at that size.
|
||||||
|
*/
|
||||||
|
export const PATTERNS = [
|
||||||
|
{ id: "plain", label: "Plain", layers: "" },
|
||||||
|
{
|
||||||
|
id: "ruled",
|
||||||
|
label: "Ruled",
|
||||||
|
layers: "repeating-linear-gradient(0deg,rgba(255,255,255,.12) 0 1px,transparent 1px 17px)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "grid",
|
||||||
|
label: "Grid",
|
||||||
|
layers:
|
||||||
|
"repeating-linear-gradient(0deg,rgba(255,255,255,.10) 0 1px,transparent 1px 20px), repeating-linear-gradient(90deg,rgba(255,255,255,.10) 0 1px,transparent 1px 20px)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pinstripe",
|
||||||
|
label: "Pinstripe",
|
||||||
|
layers: "repeating-linear-gradient(45deg,rgba(255,255,255,.09) 0 6px,transparent 6px 14px)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bands",
|
||||||
|
label: "Bands",
|
||||||
|
layers: "repeating-linear-gradient(0deg,rgba(0,0,0,.18) 0 13px,transparent 13px 26px)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dots",
|
||||||
|
label: "Dots",
|
||||||
|
layers: "radial-gradient(rgba(255,255,255,.18) 1.4px,transparent 1.5px) 0 0/13px 13px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hatch",
|
||||||
|
label: "Cross-hatch",
|
||||||
|
layers:
|
||||||
|
"repeating-linear-gradient(45deg,rgba(255,255,255,.08) 0 1px,transparent 1px 11px), repeating-linear-gradient(-45deg,rgba(255,255,255,.08) 0 1px,transparent 1px 11px)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "linen",
|
||||||
|
label: "Linen",
|
||||||
|
layers:
|
||||||
|
"repeating-linear-gradient(0deg,rgba(255,255,255,.07) 0 1px,transparent 1px 4px), repeating-linear-gradient(90deg,rgba(0,0,0,.07) 0 1px,transparent 1px 4px)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ledger",
|
||||||
|
label: "Ledger",
|
||||||
|
layers: "repeating-linear-gradient(90deg,rgba(255,255,255,.09) 0 1px,transparent 1px 26px)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "glow",
|
||||||
|
label: "Corner light",
|
||||||
|
layers: "radial-gradient(120% 85% at 18% 0%,rgba(255,255,255,.20),transparent 62%)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "foot",
|
||||||
|
label: "Foot band",
|
||||||
|
layers: "linear-gradient(0deg,rgba(0,0,0,.34) 0 22%,transparent 22%)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers written before patterns were their own choice.
|
||||||
|
*
|
||||||
|
* Twelve patterned entries were appended to `COVERS` for a day, so a notebook
|
||||||
|
* may still hold an index into them. Rather than let those repaint themselves,
|
||||||
|
* each is read back as the colour-and-pattern pair it was drawn as.
|
||||||
|
*/
|
||||||
|
const LEGACY_COVER = {
|
||||||
|
15: [11, 1], 16: [2, 2], 17: [3, 3], 18: [9, 4], 19: [5, 5],
|
||||||
|
20: [8, 6], 21: [12, 7], 22: [4, 4], 23: [8, 9], 24: [0, 9],
|
||||||
|
25: [2, 10], 26: [8, 8],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The colour and pattern a notebook is actually drawn with. */
|
||||||
|
export function coverParts(cover, pattern) {
|
||||||
|
const legacy = LEGACY_COVER[cover];
|
||||||
|
if (legacy) return { cover: legacy[0], pattern: legacy[1] };
|
||||||
|
return { cover: ((cover % COVERS.length) + COVERS.length) % COVERS.length, pattern: pattern || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CSS `background` for a cover: its pattern laid over its colour. */
|
||||||
|
export function coverBackground(cover, pattern) {
|
||||||
|
const parts = coverParts(cover, pattern);
|
||||||
|
const base = COVERS[parts.cover];
|
||||||
|
const layers = PATTERNS[parts.pattern]?.layers;
|
||||||
|
return layers ? `${layers}, ${base.bg}` : base.bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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];
|
||||||
@@ -347,31 +347,65 @@ The install entry used to appear only when the browser had offered a prompt
|
|||||||
screen at all — no button, no reason, and no way to find out short of knowing how
|
screen at all — no button, no reason, and no way to find out short of knowing how
|
||||||
PWAs work.
|
PWAs work.
|
||||||
|
|
||||||
It is always there now, and when there is no prompt it says which of the fixable
|
**The prompt itself was being missed.** `beforeinstallprompt` fires once, early
|
||||||
causes applies. The common one is worth naming: **a browser will only install a
|
— usually before React has mounted — and is never fired again. The listener lived
|
||||||
|
in a component, so on a correctly served site the event arrived, found nobody
|
||||||
|
listening, and was gone for the session. It is caught at the top of `index.html`
|
||||||
|
now and handed over when the app is ready.
|
||||||
|
|
||||||
|
**And the manifest was being fetched without credentials.** A
|
||||||
|
`<link rel="manifest">` is fetched with credentials omitted unless it says
|
||||||
|
otherwise, so behind a reverse proxy that authenticates in front of the app that
|
||||||
|
one request comes back as the proxy's login page. The browser reads it as an
|
||||||
|
invalid manifest and offers no install — while the app itself works perfectly,
|
||||||
|
because its own requests carry the cookie. The link now carries
|
||||||
|
`crossorigin="use-credentials"`, which is harmless without a proxy.
|
||||||
|
|
||||||
|
The service worker had the matching problem: it cached whatever came back. A
|
||||||
|
lapsed session could be stored as the application itself, and the app would then
|
||||||
|
"work offline" by showing a login screen it could never get past — curable only
|
||||||
|
by clearing the site's data. It now caches only responses that are successful and
|
||||||
|
not redirected, and `Cache.put` is guarded, since it rejects on a redirect and
|
||||||
|
that rejection was going unhandled on every navigation.
|
||||||
|
|
||||||
|
Behind such a proxy, three paths have to reach InkNote intact: `/manifest.json`
|
||||||
|
and `/sw.js` must return the file rather than a login page — a **redirect** on
|
||||||
|
`sw.js` fails registration outright, taking offline with it — and `/ws` needs its
|
||||||
|
WebSocket upgrade passed through, or changes from another device only arrive with
|
||||||
|
the 30-second reconciliation.
|
||||||
|
|
||||||
|
The entry is always there, and when there is no prompt it says which of the
|
||||||
|
fixable causes applies. The common one is worth naming: **a browser will only install a
|
||||||
site served over HTTPS**, and on plain HTTP the service worker never registers
|
site served over HTTPS**, and on plain HTTP the service worker never registers
|
||||||
either — so one cause takes the install *and* offline with it, which otherwise
|
either — so one cause takes the install *and* offline with it, which otherwise
|
||||||
looks like two unrelated mysteries.
|
looks like two unrelated mysteries.
|
||||||
|
|
||||||
### Notebook covers
|
### Notebook covers
|
||||||
|
|
||||||
A cover is stored as an **index** into `COVERS` in
|
**A cover is a colour and a pattern, chosen in turn** — `COVERS` and `PATTERNS`
|
||||||
[`ui/constants.jsx`](frontend/src/ui/constants.jsx). Entries are therefore only
|
in [`ui/constants.jsx`](frontend/src/ui/constants.jsx), stored as
|
||||||
ever appended, never reordered: renumbering them would silently repaint every
|
`notebooks.cover_index` and `notebooks.cover_pattern`.
|
||||||
notebook anyone already has.
|
|
||||||
|
|
||||||
The patterned ones are drawn entirely with CSS gradient layers — no image files,
|
Two decisions rather than one list. Offering every combination as its own entry
|
||||||
nothing to upload, nothing to fetch. They stay crisp at any size and work offline
|
would mean fifteen colours times a dozen patterns in a grid nobody could take in
|
||||||
like the rest of the app. `background` takes a stack of layers, topmost first, so
|
— and in practice only the handful someone had thought to write out. Picking each
|
||||||
each is a pattern laid over the colour beneath it.
|
in turn is shorter *and* gives every combination. Each pattern is previewed over
|
||||||
|
the colour currently chosen, so what is on screen is the cover as it will be.
|
||||||
|
|
||||||
They are deliberately faint: a cover is read at the size of a thumb with a title
|
The patterns are CSS gradient layers laid over the colour — no image files,
|
||||||
across it, and anything louder fights the title. One idea per cover, too — two
|
nothing to upload, nothing to fetch, crisp at any size, offline like the rest of
|
||||||
crossed diagonals meant as a chevron came out as noisy plaid at that size and
|
the app. They are deliberately faint: a cover is read at the size of a thumb with
|
||||||
were replaced with plain bands.
|
a title across it, and anything louder fights the title. One idea each, too — two
|
||||||
|
crossed diagonals meant as a chevron came out as noisy plaid at that size.
|
||||||
|
|
||||||
The grid scrolls. Without that the dialog runs past the bottom of a laptop screen
|
`COVERS` is an index, so entries are only ever appended: renumbering them would
|
||||||
and takes the *Create* button with it, which a test now checks at 1440×800.
|
silently repaint every notebook anyone already has. For one day the patterns
|
||||||
|
*were* twelve appended entries, before they became their own axis; `coverParts()`
|
||||||
|
reads those indices back as the colour-and-pattern pair they were drawn as, so
|
||||||
|
nothing that was chosen then looks different now.
|
||||||
|
|
||||||
|
Both grids scroll. Without that the dialog runs past the bottom of a laptop
|
||||||
|
screen and takes the *Create* button with it, which a test checks at 1440×800.
|
||||||
|
|
||||||
### Browser suites
|
### Browser suites
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user