TipTap editor, version history, search, attachments — and a cleanup

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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-07 17:00:14 +02:00
1 parent d97591b242
commit 92d525b3ab
71 files changed
+14354 -1826

No files matched your search

@@ -0,0 +1,72 @@
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);
}, []);
}
+302
View File
@@ -0,0 +1,302 @@
import { useCallback, useEffect, useRef, useState } from "react";
import * as sync from "../syncEngine";
import * as searchIndex from "../search";
import { sameStrokeDoc } from "../features/drawing/strokes.js";
import type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
/** How often the whole account is reconciled with the server. */
const FULL_SYNC_MS = 30_000;
/** How often the open note is re-read, so a second device's edits show up. */
const ACTIVE_NOTE_POLL_MS = 6_000;
/** After this long without a body arriving, show an empty editor rather than a spinner. */
const CONTENT_TIMEOUT_MS = 5_000;
const EMPTY_CONTENT: NoteContent = { content: "", drawingData: null, drawingStrokes: null };
export interface NoteEdit {
title?: string;
content?: string;
/** Ids of notes and notebooks the body links to; stored server-side for backlinks. */
links?: string[];
drawingStrokes?: StrokeDoc | null;
}
export interface NoteData {
notes: NoteMeta[];
notebooks: Notebook[];
noteContents: Record<string, NoteContent>;
/** Notes linking to the currently open one. */
backlinks: Backlink[];
reload: () => Promise<void>;
reset: () => void;
createNote: (folderId: string) => Promise<NoteMeta>;
updateNote: (id: string, edit: NoteEdit) => void;
setNoteContent: (id: string, content: NoteContent) => void;
toggleStar: (id: string) => Promise<void>;
trashNote: (id: string) => Promise<void>;
restoreNote: (id: string) => Promise<void>;
permanentDeleteNote: (id: string) => Promise<void>;
emptyTrash: () => Promise<void>;
moveNote: (id: string, folderId: string) => Promise<void>;
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
deleteNotebook: (id: string) => Promise<void>;
reorderNotebooks: (dragId: string, targetId: string) => void;
}
/**
* Owns everything the app knows about notes and notebooks: the local copy, the
* mutations that change it, and the background work that keeps it current.
*
* Every mutation writes locally first and hands the change to the sync engine,
* so the UI stays responsive and offline edits are never lost. Confirmation
* prompts are deliberately *not* here — destructive intent is a question for
* the interface, not the store.
*/
export function useNoteData(user: User | null, activeNote: string | null): NoteData {
const [notes, setNotes] = useState<NoteMeta[]>([]);
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
const [noteContents, setNoteContents] = useState<Record<string, NoteContent>>({});
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
const notesRef = useRef(notes);
notesRef.current = notes;
const notebooksRef = useRef(notebooks);
notebooksRef.current = notebooks;
const contentsRef = useRef(noteContents);
contentsRef.current = noteContents;
const reload = useCallback(async () => {
setNotebooks(await sync.getNotebooks());
setNotes(await sync.getNotes());
}, []);
const reset = useCallback(() => {
setNotes([]);
setNotebooks([]);
setNoteContents({});
setBacklinks([]);
}, []);
/* ── Background upkeep ── */
// The sync engine tells us when it has written to IndexedDB behind our back.
// Loaded bodies are kept as they are: replacing them here would make the
// editor flicker on every poll.
useEffect(() => sync.onDataChange(reload), [reload]);
useEffect(() => {
const i = setInterval(() => {
if (sync.isOnline()) void sync.fullSync().then(reload).catch(noop);
}, FULL_SYNC_MS);
return () => clearInterval(i);
}, [reload]);
/* ── The open note's body ── */
const hasContent = activeNote != null && noteContents[activeNote] !== undefined;
// Fetched on demand: on a cold start every body is missing, and loading them
// all up front would be slow and mostly wasted.
useEffect(() => {
if (!activeNote || hasContent) return;
let cancelled = false;
sync
.loadNoteContent(activeNote)
.then((c) => {
if (cancelled) return;
setNoteContents((p) => ({ ...p, [activeNote]: c }));
const title = notesRef.current.find((n) => n.id === activeNote)?.title || "";
searchIndex.setEntry(activeNote, title, c.content);
})
.catch(() => {
if (!cancelled) setNoteContents((p) => ({ ...p, [activeNote]: EMPTY_CONTENT }));
});
return () => {
cancelled = true;
};
}, [activeNote, hasContent]);
// Hard fallback: an unreachable server must not leave the editor stuck.
useEffect(() => {
if (!activeNote || hasContent) return;
const t = setTimeout(() => {
setNoteContents((p) => (p[activeNote] ? p : { ...p, [activeNote]: EMPTY_CONTENT }));
}, CONTENT_TIMEOUT_MS);
return () => clearTimeout(t);
}, [activeNote, hasContent]);
// Keep the open note fresh across devices.
useEffect(() => {
if (!user || !activeNote) return;
const i = setInterval(() => {
if (!sync.isOnline()) return;
// Never adopt a response while this note has writes on the way out: it
// was requested before them and would undo what was just typed.
if (sync.hasPendingWrites(activeNote)) return;
sync
.loadNoteContent(activeNote, { forceRemote: true })
.then((c) => {
setNoteContents((p) => {
const prev = p[activeNote];
if (
prev &&
prev.content === c.content &&
prev.drawingData === c.drawingData &&
sameStrokeDoc(prev.drawingStrokes, c.drawingStrokes)
)
return p;
return { ...p, [activeNote]: c };
});
})
.catch(noop);
}, ACTIVE_NOTE_POLL_MS);
return () => clearInterval(i);
}, [user, activeNote]);
const activeBody = activeNote ? noteContents[activeNote]?.content : undefined;
useEffect(() => {
if (!activeNote || !user) {
setBacklinks([]);
return;
}
let cancelled = false;
sync
.getBacklinks(activeNote)
.then((r) => {
if (!cancelled) setBacklinks(r);
})
.catch(noop);
return () => {
cancelled = true;
};
}, [activeNote, user, activeBody]);
/* ── Notes ── */
const createNote = useCallback(async (folderId: string) => {
const note = await sync.createNote(folderId);
setNotes((p) => [note, ...p]);
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
return note;
}, []);
const updateNote = useCallback((id: string, edit: NoteEdit) => {
if (edit.title !== undefined) {
const title = edit.title;
const updatedAt = new Date().toISOString();
setNotes((p) => p.map((n) => (n.id === id ? { ...n, title, updatedAt } : n)));
void sync.updateNoteTitle(id, title);
searchIndex.setEntry(id, title, contentsRef.current[id]?.content);
}
if (edit.content !== undefined) {
const content = edit.content;
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), content } }));
void sync.updateNoteContent(id, content, edit.links);
searchIndex.setEntry(id, notesRef.current.find((n) => n.id === id)?.title || "", content);
}
if (edit.drawingStrokes !== undefined) {
const drawingStrokes = edit.drawingStrokes;
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), drawingStrokes } }));
void sync.updateNoteDrawing(id, drawingStrokes);
}
}, []);
/** Replaces a body wholesale — used when restoring an older version. */
const setNoteContent = useCallback((id: string, content: NoteContent) => {
setNoteContents((p) => ({ ...p, [id]: content }));
}, []);
const toggleStar = useCallback(async (id: string) => {
const starred = await sync.toggleNoteStar(id);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, starred } : n)));
}, []);
const trashNote = useCallback(async (id: string) => {
await sync.trashNote(id);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: true } : n)));
}, []);
const restoreNote = useCallback(async (id: string) => {
await sync.restoreNoteLocal(id);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: false } : n)));
}, []);
const permanentDeleteNote = useCallback(async (id: string) => {
await sync.permanentDeleteNoteLocal(id);
searchIndex.removeEntry(id);
setNotes((p) => p.filter((n) => n.id !== id));
}, []);
const emptyTrash = useCallback(async () => {
await sync.emptyTrashLocal();
for (const n of notesRef.current) if (n.trashed) searchIndex.removeEntry(n.id);
setNotes((p) => p.filter((n) => !n.trashed));
}, []);
const moveNote = useCallback(async (id: string, folderId: string) => {
await sync.moveNote(id, folderId);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId } : n)));
}, []);
/* ── Notebooks ── */
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
const nb = await sync.createNotebook(fields);
setNotebooks((p) => [...p, nb]);
return nb;
}, []);
const updateNotebook = useCallback(async (id: string, updates: Partial<Notebook>) => {
await sync.updateNotebook(id, updates);
setNotebooks((p) => p.map((nb) => (nb.id === id ? { ...nb, ...updates } : nb)));
}, []);
/** Deleting a notebook trashes its notes rather than destroying them. */
const deleteNotebook = useCallback(async (id: string) => {
await sync.deleteNotebookLocal(id);
setNotes((p) => p.map((n) => (n.folderId === id ? { ...n, trashed: true } : n)));
setNotebooks((p) => p.filter((nb) => nb.id !== id));
}, []);
const reorderNotebooks = useCallback((dragId: string, targetId: string) => {
const list = [...notebooksRef.current];
const from = list.findIndex((n) => n.id === dragId);
const to = list.findIndex((n) => n.id === targetId);
if (from < 0 || to < 0 || from === to) return;
const [moved] = list.splice(from, 1);
if (!moved) return;
list.splice(to, 0, moved);
const reordered = list.map((n, i) => ({ ...n, sortOrder: i }));
setNotebooks(reordered);
void sync.reorderNotebooks(reordered.map((n) => n.id));
}, []);
return {
notes,
notebooks,
noteContents,
backlinks,
reload,
reset,
createNote,
updateNote,
setNoteContent,
toggleStar,
trashNote,
restoreNote,
permanentDeleteNote,
emptyTrash,
moveNote,
createNotebook,
updateNotebook,
deleteNotebook,
reorderNotebooks,
};
}
const noop = () => {};
+48
View File
@@ -0,0 +1,48 @@
import { useCallback, useRef, useState } from "react";
import type { DragEvent } from "react";
export interface NotebookDnd {
/** Notebook currently under the pointer, for the drop indicator. */
overId: string | null;
onDragStart: (e: DragEvent, id: string) => void;
onDragOver: (e: DragEvent, id: string) => void;
onDrop: (e: DragEvent, id: string) => void;
onDragEnd: () => void;
}
/**
* Drag-to-reorder for the notebook list, shared by the sidebar and the
* dashboard so a notebook can be dragged in either place.
*/
export function useNotebookDnd(reorder: (dragId: string, targetId: string) => void): NotebookDnd {
const [overId, setOverId] = useState<string | null>(null);
// The dragged id is not rendered, so a ref avoids a re-render per dragover.
const dragId = useRef<string | null>(null);
const onDragStart = useCallback((e: DragEvent, id: string) => {
dragId.current = id;
e.dataTransfer.effectAllowed = "move";
}, []);
const onDragOver = useCallback((e: DragEvent, id: string) => {
e.preventDefault();
if (dragId.current && dragId.current !== id) setOverId(id);
}, []);
const clear = useCallback(() => {
dragId.current = null;
setOverId(null);
}, []);
const onDrop = useCallback(
(e: DragEvent, targetId: string) => {
e.preventDefault();
const from = dragId.current;
if (from && from !== targetId) reorder(from, targetId);
clear();
},
[reorder, clear]
);
return { overId, onDragStart, onDragOver, onDrop, onDragEnd: clear };
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
/** Chrome's non-standard install event. Not in lib.dom, so it is declared here. */
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export interface PwaInstall {
/** Non-null only while the browser is willing to show an install prompt. */
canInstall: boolean;
promptInstall: () => Promise<void>;
}
/**
* Captures the browser's install offer so the app can present it at a sensible
* moment instead of letting the browser choose one.
*/
export function usePwaInstall(): PwaInstall {
const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(null);
useEffect(() => {
const onPrompt = (e: Event) => {
e.preventDefault();
setEvent(e as BeforeInstallPromptEvent);
};
window.addEventListener("beforeinstallprompt", onPrompt);
return () => window.removeEventListener("beforeinstallprompt", onPrompt);
}, []);
const promptInstall = async () => {
if (!event) return;
await event.prompt();
const { outcome } = await event.userChoice;
// The captured event is single-use; drop it either way so the button does
// not linger after a decision.
if (outcome === "accepted") setEvent(null);
};
return { canInstall: event !== null, promptInstall };
}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
const MOBILE_BREAKPOINT = 768;
export type MobileView = "list" | "editor";
export interface Responsive {
isMobile: boolean;
sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void;
/** On a phone, list and editor share the screen; this says which one has it. */
mobileView: MobileView;
setMobileView: (view: MobileView) => void;
}
/**
* Tracks whether we are on a phone-sized screen and keeps the sidebar's
* open/closed state consistent with it: on a desktop the sidebar is always
* available, on a phone it is an overlay that starts closed.
*/
export function useResponsive(): Responsive {
const [isMobile, setIsMobile] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > MOBILE_BREAKPOINT);
const [mobileView, setMobileView] = useState<MobileView>("list");
useEffect(() => {
const onResize = () => {
const mobile = window.innerWidth < MOBILE_BREAKPOINT;
setIsMobile(mobile);
// Growing past the breakpoint must not leave the sidebar hidden with no
// way to bring it back.
if (!mobile) setSidebarOpen(true);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return { isMobile, sidebarOpen, setSidebarOpen, mobileView, setMobileView };
}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useMemo, useRef, useState } from "react";
import * as sync from "../syncEngine";
import * as searchIndex from "../search";
import type { NoteMeta, SearchHit, User } from "../types";
const DEBOUNCE_MS = 120;
/** Refresh visible results every N notes while the backfill is still running. */
const PROGRESS_EVERY = 10;
export interface SearchState {
/** null means "not searching"; an empty array means "searched, found nothing". */
results: SearchHit[] | null;
/** Excerpt around the match, empty when the note is not a hit. */
snippetFor: (id: string) => string;
}
/**
* Full-text search over the local copy of every note.
*
* The server stores bodies encrypted and cannot index them, so the index is
* built here. Bodies normally reach the client only when a note is opened, so
* the missing ones are backfilled in the background after login.
*/
export function useSearchIndex(user: User | null, notes: NoteMeta[], query: string): SearchState {
const [results, setResults] = useState<SearchHit[] | null>(null);
const [version, setVersion] = useState(0);
const running = useRef(false);
// Read by the backfill so it sees current titles without being torn down and
// restarted every time a note changes.
const notesRef = useRef(notes);
notesRef.current = notes;
useEffect(() => {
// Waits for the note list: starting before the first sync would find
// nothing to fetch and never run again.
if (!user || notes.length === 0 || running.current) return;
running.current = true;
let cancelled = false;
const signal = { aborted: false };
void (async () => {
await searchIndex.rebuild();
if (!cancelled) setVersion((v) => v + 1);
await sync.prefetchAllContent({
signal,
onProgress: ({ id, content, fetched }) => {
const note = notesRef.current.find((n) => n.id === id);
searchIndex.setEntry(id, note?.title || "", content);
if (fetched % PROGRESS_EVERY === 0 && !cancelled) setVersion((v) => v + 1);
},
});
if (!cancelled) setVersion((v) => v + 1);
running.current = false;
})();
return () => {
cancelled = true;
signal.aborted = true;
};
}, [user, notes.length]);
useEffect(() => {
const q = query.trim();
if (!q) {
setResults(null);
return;
}
// Debounced so typing does not re-scan the index on every keystroke.
const t = setTimeout(() => setResults(searchIndex.search(q)), DEBOUNCE_MS);
return () => clearTimeout(t);
}, [query, version]);
const snippets = useMemo(() => {
const m = new Map<string, string>();
for (const r of results || []) if (r.snippet) m.set(r.id, r.snippet);
return m;
}, [results]);
const snippetFor = useMemo(() => (id: string) => snippets.get(id) || "", [snippets]);
return { results, snippetFor };
}