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,234 @@
import { COVERS } from "../../ui/constants.jsx";
import { I } from "../../ui/icons.jsx";
import { NotebookCover } from "../../ui/NotebookCover.jsx";
import { fmtDate } from "../../ui/format";
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
const RECENT_COUNT = 6;
/** Landing view: every notebook as a cover, plus the notes touched most recently. */
export function Dashboard({
T,
isMobile,
notebooks,
activeNotes,
activeFolder,
onToggleSidebar,
onOpenNotebook,
onOpenNote,
onNewNotebook,
onNotebookContextMenu,
dnd,
}) {
return (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
background: T.dashBg,
fontFamily: FONT_UI,
overflow: "auto",
}}
>
<div
style={{
padding: isMobile ? "20px 20px 16px" : "28px 36px 20px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button
onClick={onToggleSidebar}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 36,
borderRadius: 10,
border: "none",
background: "transparent",
cursor: "pointer",
color: T.muted,
}}
>
{I.menu}
</button>
<h1
style={{
margin: 0,
fontSize: isMobile ? 20 : 24,
fontWeight: 700,
color: T.text,
fontFamily: FONT_SERIF,
}}
>
My Notebooks
</h1>
</div>
</div>
<div
style={{
padding: isMobile ? "0 16px 24px" : "0 36px 32px",
display: "grid",
gridTemplateColumns: isMobile
? "repeat(auto-fill,minmax(140px,1fr))"
: "repeat(auto-fill,minmax(180px,1fr))",
gap: isMobile ? "20px 16px" : "32px 28px",
justifyItems: "center",
}}
>
{notebooks.map((nb) => (
<NotebookCover
key={nb.id}
notebook={nb}
noteCount={activeNotes.filter((n) => n.folderId === nb.id).length}
onClick={() => onOpenNotebook(nb.id)}
onContextMenu={(e) => onNotebookContextMenu(e, nb)}
isActive={activeFolder === nb.id}
isDragOver={dnd.overId === nb.id}
draggable={!isMobile}
onDragStart={(e) => dnd.onDragStart(e, nb.id)}
onDragEnd={dnd.onDragEnd}
onDragOver={(e) => dnd.onDragOver(e, nb.id)}
onDrop={(e) => dnd.onDrop(e, nb.id)}
/>
))}
<button
onClick={onNewNotebook}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
background: "none",
border: "none",
cursor: "pointer",
}}
>
<div
style={{
width: isMobile ? 130 : 156,
height: isMobile ? 175 : 210,
borderRadius: 12,
border: `2px dashed ${T.border}`,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 8,
color: T.faint,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: "50%",
background: T.accentSoft,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: T.accent,
}}
>
{I.plus}
</div>
<span style={{ fontSize: 12, fontWeight: 500 }}>Add Notebook</span>
</div>
</button>
</div>
<div style={{ padding: isMobile ? "0 16px 24px" : "0 36px 36px" }}>
<h2
style={{
fontSize: 16,
fontWeight: 600,
color: T.text,
marginBottom: 14,
fontFamily: FONT_SERIF,
}}
>
Recent Notes
</h2>
<div
style={{
display: "grid",
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill,minmax(260px,1fr))",
gap: 12,
}}
>
{activeNotes.slice(0, RECENT_COUNT).map((note) => {
const nb = notebooks.find((b) => b.id === note.folderId);
return (
<button
key={note.id}
onClick={() => onOpenNote(note)}
style={{
display: "flex",
flexDirection: "column",
padding: "14px 16px",
borderRadius: 12,
border: `1px solid ${T.border}`,
background: T.card,
cursor: "pointer",
textAlign: "left",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6, width: "100%" }}
>
{nb && (
<div
style={{
width: 14,
height: 10,
borderRadius: 2,
background: COVERS[nb.cover % COVERS.length].bg,
flexShrink: 0,
}}
/>
)}
<span
style={{
fontSize: 13,
fontWeight: 600,
color: T.text,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{note.title}
</span>
{note.starred && <span style={{ color: "#f0b429", fontSize: 11 }}>⭐</span>}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6 }}>
<span style={{ fontSize: 11, color: T.faint }}>{fmtDate(note.updatedAt)}</span>
{nb && (
<span
style={{
fontSize: 10,
color: T.faint,
background: T.accentSoft,
padding: "1px 6px",
borderRadius: 6,
}}
>
{nb.name}
</span>
)}
</div>
</button>
);
})}
</div>
</div>
</div>
);
}