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

+563
View File
@@ -0,0 +1,563 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import * as sync from "./syncEngine";
import { useNoteData } from "./hooks/useNoteData";
import { useSearchIndex } from "./hooks/useSearchIndex";
import { useResponsive } from "./hooks/useResponsive";
import { usePwaInstall } from "./hooks/usePwaInstall";
import { useNotebookDnd } from "./hooks/useNotebookDnd";
import { useNavigationHistory } from "./hooks/useNavigationHistory";
import { COVERS, DEFAULT_FOLDERS } from "./ui/constants.jsx";
import { I } from "./ui/icons.jsx";
import { makeTheme, FONT_UI } from "./ui/theme";
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
import { ContextMenu } from "./ui/ContextMenu.jsx";
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
import { AccountMenu } from "./ui/AccountMenu.jsx";
import { AuthScreen } from "./ui/AuthScreen.jsx";
import { Sidebar } from "./ui/Sidebar.jsx";
import { Dashboard } from "./features/notebooks/Dashboard.jsx";
import { NoteList } from "./features/notes/NoteList.jsx";
import { EditorPane } from "./features/notes/EditorPane.jsx";
import { HistoryModal } from "./features/notes/HistoryModal.jsx";
import { DrawingLayer } from "./features/drawing/DrawingLayer.jsx";
import { CANON_W, CANON_H, PREVIEW_DRAWING_W } from "./features/drawing/strokes.js";
/** Views that are not a notebook and therefore cannot hold a new note. */
const VIRTUAL_FOLDERS = ["all", "starred", "trash", "dashboard"];
const EMPTY_BODY = { content: "", drawingData: null, drawingStrokes: null };
/**
* Root of the application.
*
* Holds the things that describe *where the user is* — the open notebook, the
* open note, the search box, whichever dialog is up — and hands the data itself
* to `useNoteData`. Rendering is delegated to the three panes below.
*/
export default function App() {
const [dark, setDark] = useState(true);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [syncStatus, setSyncStatus] = useState("synced");
const [activeFolder, setActiveFolder] = useState("dashboard");
const [activeNote, setActiveNote] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
const [showNewNb, setShowNewNb] = useState(false);
const [editingNb, setEditingNb] = useState(null);
const [showAccount, setShowAccount] = useState(false);
const [historyFor, setHistoryFor] = useState(null);
const [ctxMenu, setCtxMenu] = useState(null);
const [confirmDialog, setConfirmDialog] = useState(null);
// Bumped on a version restore to force a fresh editor on the restored body.
const [restoreEpoch, setRestoreEpoch] = useState(0);
const T = useMemo(() => makeTheme(dark), [dark]);
const { isMobile, sidebarOpen, setSidebarOpen, mobileView, setMobileView } = useResponsive();
const { canInstall, promptInstall } = usePwaInstall();
const data = useNoteData(user, activeNote);
const { notes, notebooks, noteContents, backlinks } = data;
const search = useSearchIndex(user, notes, searchQuery);
const dnd = useNotebookDnd(data.reorderNotebooks);
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
/* ── Session ── */
useEffect(() => {
(async () => {
try {
const u = await sync.restoreSession();
if (u) {
setUser(u);
await data.reload();
try {
await sync.fullSync();
await data.reload();
} catch (e) {
if (e.message === "AUTH") setUser(null);
}
}
} catch {
/* a broken session must not block the app from starting */
}
setLoading(false);
})();
}, []);
const onLogin = async (u) => {
setUser(u);
try {
await sync.fullSync();
} catch {
/* first sync can fail offline; local data still loads */
}
await data.reload();
};
const onLogout = async () => {
await sync.logout();
setUser(null);
data.reset();
setActiveNote(null);
setActiveFolder("dashboard");
};
/* ── Navigation ── */
const openFolder = useCallback(
(id) => {
setActiveFolder(id);
setActiveNote(null);
if (isMobile) {
setSidebarOpen(false);
setMobileView("list");
}
},
[isMobile, setSidebarOpen, setMobileView]
);
const openNote = useCallback(
(id) => {
setActiveNote(id);
if (isMobile) setMobileView("editor");
},
[isMobile, setMobileView]
);
const closeNote = useCallback(() => {
setActiveNote(null);
if (isMobile) setMobileView("list");
}, [isMobile, setMobileView]);
// Adopts a location the user reached with the browser's back button.
const applyNavState = useCallback(
(s) => {
setActiveFolder(s.folder);
setActiveNote(s.note);
setMobileView(s.view);
},
[setMobileView]
);
useNavigationHistory({ folder: activeFolder, note: activeNote, view: mobileView }, isMobile, applyNavState);
/* ── Derived note lists ── */
const activeNotes = useMemo(() => notes.filter((n) => !n.trashed), [notes]);
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
const isTrashView = activeFolder === "trash";
const filteredNotes = useMemo(() => {
let list;
if (activeFolder === "trash") list = trashedNotes;
else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred);
else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes;
else list = activeNotes.filter((n) => n.folderId === activeFolder);
if (searchQuery.trim()) {
// A search reads every note's text, so results keep the index's relevance
// order instead of being re-sorted by date.
if (search.results) {
const rank = new Map(search.results.map((h, i) => [h.id, i]));
return list.filter((n) => rank.has(n.id)).sort((a, b) => rank.get(a.id) - rank.get(b.id));
}
// Until the index answers, fall back to matching titles only.
const q = searchQuery.toLowerCase();
return list.filter((n) => n.title.toLowerCase().includes(q));
}
return [...list].sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
}, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes]);
const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null;
// The editor opens as soon as the metadata is known; the body fills in when
// it arrives, rather than blocking on a fetch.
const openNoteData = noteMeta ? { ...noteMeta, ...EMPTY_BODY, ...noteContents[activeNote] } : null;
const folderName = useMemo(() => {
const builtIn = DEFAULT_FOLDERS.find((f) => f.id === activeFolder);
if (builtIn) return builtIn.name;
return notebooks.find((n) => n.id === activeFolder)?.name || "Notes";
}, [activeFolder, notebooks]);
/* ── Note links ── */
// Candidates for the @-menu. Notes rank above notebooks, trashed notes never
// appear, and the open note cannot link to itself.
const searchLinkTargets = useCallback(
(query) => {
const q = (query || "").toLowerCase().trim();
const match = (name) => !q || name.toLowerCase().includes(q);
return [
...notes
.filter((n) => !n.trashed && n.id !== activeNote && match(n.title || "Untitled"))
.slice(0, 8)
.map((n) => ({ id: n.id, label: n.title || "Untitled", kind: "note" })),
...notebooks
.filter((nb) => match(nb.name))
.slice(0, 4)
.map((nb) => ({ id: nb.id, label: nb.name, kind: "notebook" })),
];
},
[notes, notebooks, activeNote]
);
// A link stores the label it had when inserted; this keeps it showing the
// target's current title, and returns null once the target is gone.
const resolveLinkLabel = useCallback(
(id) => {
const n = notes.find((x) => x.id === id && !x.trashed);
if (n) return n.title || "Untitled";
return notebooks.find((x) => x.id === id)?.name ?? null;
},
[notes, notebooks]
);
const openLinkTarget = useCallback(
(id, kind) => {
if (kind === "notebook") return openFolder(id);
const target = notes.find((n) => n.id === id);
if (!target) return;
if (target.folderId) setActiveFolder(target.folderId);
openNote(id);
},
[notes, openFolder, openNote]
);
const linkProps = useMemo(
() => ({ openLinkTarget, searchLinkTargets, resolveLinkLabel }),
[openLinkTarget, searchLinkTargets, resolveLinkLabel]
);
/* ── Actions ── */
const createNote = async () => {
const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder;
if (!folderId) return;
const note = await data.createNote(folderId);
if (activeFolder === "dashboard") setActiveFolder(folderId);
openNote(note.id);
};
/** Runs a destructive action behind a confirmation prompt. */
const confirm = (dialog) =>
setConfirmDialog({
...dialog,
onConfirm: async () => {
await dialog.onConfirm();
setConfirmDialog(null);
},
});
const trashNote = (id) =>
confirm({
title: "Move to Trash",
message: `Move "${notes.find((n) => n.id === id)?.title}" to trash?`,
confirmLabel: "Move to Trash",
onConfirm: async () => {
await data.trashNote(id);
if (activeNote === id) closeNote();
},
});
const restoreNote = async (id) => {
await data.restoreNote(id);
if (activeNote === id) closeNote();
};
const permanentDeleteNote = (id) =>
confirm({
title: "Delete Permanently",
message: `Permanently delete "${notes.find((n) => n.id === id)?.title}"? This cannot be undone.`,
onConfirm: async () => {
await data.permanentDeleteNote(id);
if (activeNote === id) closeNote();
},
});
const emptyTrash = () =>
confirm({
title: "Empty Trash",
message: `Permanently delete all ${trashedNotes.length} trashed notes?`,
onConfirm: async () => {
await data.emptyTrash();
setActiveNote(null);
},
});
const createNotebook = async (fields) => {
const nb = await data.createNotebook(fields);
setShowNewNb(false);
setActiveFolder(nb.id);
};
const updateNotebook = async (id, updates) => {
await data.updateNotebook(id, updates);
setEditingNb(null);
};
const deleteNotebook = (id) => {
const nb = notebooks.find((n) => n.id === id);
const count = activeNotes.filter((n) => n.folderId === id).length;
confirm({
title: "Delete Notebook",
message: `Delete "${nb?.name}"${count > 0 ? ` and trash its ${count} notes` : ""} permanently?`,
onConfirm: async () => {
await data.deleteNotebook(id);
if (activeFolder === id) setActiveFolder("dashboard");
},
});
};
/* ── Context menus ── */
const notebookContextMenu = (e, nb) => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({
x: e.clientX,
y: e.clientY,
items: [
{ label: "Edit", icon: I.edit, action: () => setEditingNb(nb) },
{ divider: true },
{ label: "Delete Notebook", icon: I.trash, danger: true, action: () => deleteNotebook(nb.id) },
],
});
};
/** "Move to..." lists every notebook except the one the note is already in. */
const moveSubmenu = (note) => {
const targets = notebooks
.filter((nb) => nb.id !== note.folderId)
.map((nb) => ({
label: nb.name,
colorDot: COVERS[nb.cover % COVERS.length].bg,
action: () => data.moveNote(note.id, nb.id),
}));
return targets.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
};
const noteContextMenu = (e, note) => {
e.preventDefault();
e.stopPropagation();
const items = note.trashed
? [
{ label: "Restore", icon: I.restore, action: () => restoreNote(note.id) },
{ divider: true },
{
label: "Delete Permanently",
icon: I.trash,
danger: true,
action: () => permanentDeleteNote(note.id),
},
]
: [
{
label: note.starred ? "Unstar" : "Star",
icon: note.starred ? I.starFill : I.star,
action: () => data.toggleStar(note.id),
},
...moveSubmenu(note),
{ divider: true },
{ label: "Move to Trash", icon: I.trash, danger: true, action: () => trashNote(note.id) },
];
setCtxMenu({ x: e.clientX, y: e.clientY, items });
};
/* ── Render ── */
if (loading)
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
background: T.bg,
color: T.text,
fontFamily: FONT_UI,
fontSize: 16,
}}
>
Loading...
</div>
);
if (!user) return <AuthScreen onLogin={onLogin} dark={dark} />;
// On a phone the list and the editor take turns; on a desktop both are shown.
const showList = !(isMobile && mobileView === "editor" && activeNote);
const showEditor = !isMobile || mobileView === "editor";
return (
<div
style={{
display: "flex",
height: "100vh",
width: "100vw",
overflow: "hidden",
background: T.bg,
color: T.text,
fontFamily: FONT_UI,
}}
>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=DM+Sans:wght@400;500;600;700&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:${T.borderLight};border-radius:3px}
button:hover{opacity:0.88}
input::placeholder{color:${T.faint}}
[contenteditable]:empty:before{content:'Start writing...';color:${T.faint};pointer-events:none}
[draggable]{user-select:none}
@media(max-width:767px){
::-webkit-scrollbar{display:none}
body{-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none}
}
`}</style>
<Sidebar
T={T}
dark={dark}
onToggleDark={() => setDark(!dark)}
isMobile={isMobile}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
activeFolder={activeFolder}
onSelectFolder={openFolder}
notebooks={notebooks}
activeNotes={activeNotes}
trashedCount={trashedNotes.length}
onNewNotebook={() => setShowNewNb(true)}
onOpenAccount={() => setShowAccount(true)}
onNotebookContextMenu={notebookContextMenu}
dnd={dnd}
canInstall={canInstall}
onInstall={promptInstall}
syncStatus={syncStatus}
online={sync.isOnline()}
/>
{activeFolder === "dashboard" ? (
<Dashboard
T={T}
isMobile={isMobile}
notebooks={notebooks}
activeNotes={activeNotes}
activeFolder={activeFolder}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onOpenNotebook={openFolder}
onOpenNote={(note) => {
setActiveFolder(note.folderId);
openNote(note.id);
}}
onNewNotebook={() => setShowNewNb(true)}
onNotebookContextMenu={notebookContextMenu}
dnd={dnd}
/>
) : (
<>
{showList && (
<NoteList
T={T}
isMobile={isMobile}
title={folderName}
notes={filteredNotes}
activeNote={activeNote}
isTrashView={isTrashView}
trashedCount={trashedNotes.length}
snippetFor={search.snippetFor}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onSelectNote={openNote}
onNoteContextMenu={noteContextMenu}
onCreateNote={createNote}
onEmptyTrash={emptyTrash}
/>
)}
{showEditor && (
<EditorPane
T={T}
dark={dark}
isMobile={isMobile}
note={openNoteData}
isTrashView={isTrashView}
restoreEpoch={restoreEpoch}
backlinks={backlinks}
linkProps={linkProps}
onBack={closeNote}
onUpdate={(u) => data.updateNote(activeNote, u)}
onToggleStar={() => data.toggleStar(activeNote)}
onTrash={() => trashNote(activeNote)}
onRestore={() => restoreNote(activeNote)}
onPermanentDelete={() => permanentDeleteNote(activeNote)}
onOpenHistory={() => setHistoryFor(activeNote)}
onCreateNote={createNote}
/>
)}
</>
)}
{showNewNb && (
<CreateNotebookModal onClose={() => setShowNewNb(false)} onCreate={createNotebook} dark={dark} />
)}
{editingNb && (
<EditNotebookModal
notebook={editingNb}
onClose={() => setEditingNb(null)}
onSave={(u) => updateNotebook(editingNb.id, u)}
dark={dark}
/>
)}
{showAccount && (
<AccountMenu
user={user}
onClose={() => setShowAccount(false)}
onLogout={() => {
setShowAccount(false);
onLogout();
}}
onUserUpdate={setUser}
dark={dark}
/>
)}
{historyFor && (
<HistoryModal
noteId={historyFor}
noteTitle={notes.find((n) => n.id === historyFor)?.title}
dark={dark}
onClose={() => setHistoryFor(null)}
onRestored={(content) => {
data.setNoteContent(historyFor, content);
setRestoreEpoch((e) => e + 1);
}}
renderDrawing={(strokes) => (
<DrawingLayer
strokes={strokes.strokes || []}
width={PREVIEW_DRAWING_W}
height={Math.round((PREVIEW_DRAWING_W * CANON_H) / CANON_W)}
zIndex={1}
/>
)}
/>
)}
{confirmDialog && (
<ConfirmDialog
title={confirmDialog.title}
message={confirmDialog.message}
confirmLabel={confirmDialog.confirmLabel}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
dark={dark}
/>
)}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenu.items}
onClose={() => setCtxMenu(null)}
dark={dark}
/>
)}
</div>
);
}