The app is meant to run in daily private use now, so this pass was mostly
about the ways it could quietly lose data.
Reliability and security:
- the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is
missing; it refuses to start. The old fallback silently made every stored
note unreadable after a restart.
- notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read.
- login is rate limited and compares against a dummy hash when the user does
not exist, so timing does not reveal who is registered.
- a server response issued before a local save is never adopted. This had
already eaten drawing strokes and note links; sync.hasPendingWrites() now
guards every path that takes a body from the server.
Editor and features:
- TipTap replaces the contenteditable editor: tables, task lists, and links
to other notes and notebooks, with a backlinks bar on the target.
- content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open.
- version history: the state before each write is snapshotted, coalesced
into 5-minute windows, 50 versions or 90 days per note.
- full-text search runs in the client, because the server cannot read
encrypted bodies and a server index would defeat the encryption.
- image attachments are stored by SHA-256 and deduplicated; the client
fetches them authenticated and caches the blob, so they work offline.
- drawings are vector strokes in a fixed 1200x1600 page space and now look
the same on every screen (0.0-0.2% deviation across four widths).
- login is by username; existing email accounts migrate automatically.
Operations:
- data lives on host bind mounts, not Docker volumes, so Proxmox Backup
covers it. Back up .env with it — without ENCRYPTION_KEY the dump is
unreadable.
Structure:
- NoteApp.jsx is gone. App.jsx now holds only where the user is; note data,
search, layout, history and drag each live behind a hook, and the four
panes are stateless.
- the whole data layer is TypeScript, with 81 unit tests concentrated on
the places that have actually broken.
- fixed along the way: the browser back button had a dead press, because
restoring a history entry pushed a duplicate one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
73 lines
2.4 KiB
TypeScript
73 lines
2.4 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
|
|
import type { MobileView } from "./useResponsive";
|
|
|
|
export interface NavState {
|
|
folder: string;
|
|
note: string | null;
|
|
view: MobileView;
|
|
}
|
|
|
|
const same = (a: NavState, b: NavState) => a.folder === b.folder && a.note === b.note && a.view === b.view;
|
|
|
|
/**
|
|
* Maps the app's current location onto browser history, so the phone's back
|
|
* gesture steps back through notes and notebooks instead of leaving the app.
|
|
*
|
|
* `apply` is called when the user navigates back and should adopt the state it
|
|
* is handed.
|
|
*/
|
|
export function useNavigationHistory(
|
|
current: NavState,
|
|
isMobile: boolean,
|
|
apply: (state: NavState) => void
|
|
): void {
|
|
const applyRef = useRef(apply);
|
|
applyRef.current = apply;
|
|
const currentRef = useRef(current);
|
|
currentRef.current = current;
|
|
const mobileRef = useRef(isMobile);
|
|
mobileRef.current = isMobile;
|
|
|
|
/** What the topmost history entry represents. */
|
|
const pushed = useRef<NavState>({ folder: "dashboard", note: null, view: "list" });
|
|
|
|
useEffect(() => {
|
|
if (same(pushed.current, current)) return;
|
|
pushed.current = { ...current };
|
|
try {
|
|
window.history.pushState(pushed.current, "");
|
|
} catch {
|
|
/* pushState is rate-limited in some browsers; losing an entry is not fatal */
|
|
}
|
|
}, [current.folder, current.note, current.view]);
|
|
|
|
useEffect(() => {
|
|
const onPop = (e: PopStateEvent) => {
|
|
const state = e.state as Partial<NavState> | null;
|
|
const cur = currentRef.current;
|
|
const view = mobileRef.current ? (state?.view ?? "list") : cur.view;
|
|
|
|
let next: NavState;
|
|
if (state) {
|
|
next = { folder: state.folder || "dashboard", note: state.note ?? null, view };
|
|
} else if (cur.note) {
|
|
// Stepping back past our own entries: close the note, then the notebook.
|
|
next = { folder: cur.folder, note: null, view: mobileRef.current ? "list" : cur.view };
|
|
} else if (cur.folder !== "dashboard") {
|
|
next = { folder: "dashboard", note: null, view: cur.view };
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
// Record where history now stands *before* applying it. Without this the
|
|
// push effect sees a change it did not make and pushes a duplicate entry,
|
|
// which makes the back button bounce between two views forever.
|
|
pushed.current = next;
|
|
applyRef.current(next);
|
|
};
|
|
window.addEventListener("popstate", onPop);
|
|
return () => window.removeEventListener("popstate", onPop);
|
|
}, []);
|
|
}
|