Files
InkNote/frontend/src/ui/NotebookPicker.jsx
T
marcandClaude Opus 5 8ca1ed6e06 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>
2026-08-12 02:20:37 +02:00

192 lines
5.6 KiB
React

import { useEffect, useMemo, useState } from "react";
import { coverBackground } from "./constants.jsx";
/**
* Asks which notebook something should go into.
*
* "All Notes", "Starred" and the dashboard are views, not places — a note
* created while one of them is open has no notebook to belong to. The app used
* to answer that silently with `notebooks[0]`, so a note or an imported PDF
* landed in whichever notebook happened to sort first. Nothing was lost, but
* finding it again meant knowing that rule.
*
* The last choice is remembered, so the common case — always the same notebook —
* is one Enter away, while the answer stays visible instead of being guessed.
*/
const LAST_KEY = "inknote_last_notebook";
export const rememberNotebook = (id) => {
try {
localStorage.setItem(LAST_KEY, id);
} catch {
/* private mode: the default simply is not remembered */
}
};
const recalledNotebook = () => {
try {
return localStorage.getItem(LAST_KEY);
} catch {
return null;
}
};
export function NotebookPicker({ notebooks, title, confirmLabel = "Continue", onPick, onClose, dark }) {
const remembered = recalledNotebook();
const [selected, setSelected] = useState(
() => notebooks.find((nb) => nb.id === remembered)?.id || notebooks[0]?.id || null
);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
row: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
useEffect(() => {
const onKey = (e) => {
if (e.key === "Escape") onClose();
if (e.key === "Enter" && selected) confirm();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
const confirm = () => {
if (!selected) return;
rememberNotebook(selected);
onPick(selected);
};
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: T.bg,
borderRadius: 16,
padding: 24,
width: 400,
maxWidth: "92vw",
border: `1px solid ${T.border}`,
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
fontFamily: "'DM Sans',sans-serif",
}}
>
<div
style={{
fontSize: 17,
fontWeight: 600,
color: T.text,
marginBottom: 4,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
{title}
</div>
<div style={{ fontSize: 12, color: T.muted, marginBottom: 14 }}>Which notebook should it go in?</div>
<div
role="listbox"
aria-label="Notebook"
style={{ maxHeight: 280, overflowY: "auto", display: "flex", flexDirection: "column", gap: 4 }}
>
{notebooks.map((nb) => {
const active = nb.id === selected;
return (
<button
key={nb.id}
role="option"
aria-selected={active}
data-notebook-option={nb.id}
onClick={() => setSelected(nb.id)}
onDoubleClick={confirm}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "9px 12px",
borderRadius: 10,
border: `1px solid ${active ? T.accent + "66" : "transparent"}`,
background: active ? T.accent + "1a" : T.row,
color: T.text,
cursor: "pointer",
fontSize: 13,
textAlign: "left",
}}
>
<span
style={{
width: 10,
height: 10,
borderRadius: 3,
flexShrink: 0,
background: coverBackground(nb.cover, nb.coverPattern),
}}
/>
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{nb.name}
</span>
{nb.id === remembered && <span style={{ fontSize: 10, color: T.muted }}>last used</span>}
</button>
);
})}
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 18 }}>
<button
onClick={onClose}
style={{
padding: "8px 18px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
}}
>
Cancel
</button>
<button
onClick={confirm}
disabled={!selected}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: selected ? T.accent : T.border,
color: "#fff",
cursor: selected ? "pointer" : "default",
fontSize: 13,
fontWeight: 600,
}}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}