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

+262
View File
@@ -0,0 +1,262 @@
/* Editor content styling.
*
* The rest of the app styles inline, but ProseMirror renders its own DOM, so
* these nodes can only be reached from a stylesheet. Colours come from custom
* properties the editor container sets per theme. */
.note-editor {
--ne-text: #1f2328;
--ne-muted: #656d76;
--ne-faint: #afb8c1;
--ne-border: #d0d7de;
--ne-accent: #0969da;
--ne-surface: #ffffff;
--ne-subtle: #f6f8fa;
}
.note-editor[data-theme="dark"] {
--ne-text: #e6edf3;
--ne-muted: #8b949e;
--ne-faint: #484f58;
--ne-border: #30363d;
--ne-accent: #388bfd;
--ne-surface: #0d1117;
--ne-subtle: #161b22;
}
.note-editor .ProseMirror {
outline: none;
color: var(--ne-text);
font-family: "Newsreader", Georgia, serif;
font-size: 15px;
line-height: 1.7;
padding: 20px 24px;
min-height: 100%;
word-break: break-word;
}
.note-editor .ProseMirror > * + * {
margin-top: 0.6em;
}
.note-editor .ProseMirror p {
margin: 0;
}
.note-editor .ProseMirror h1 {
font-size: 1.7em;
font-weight: 700;
line-height: 1.3;
margin-top: 0.8em;
}
.note-editor .ProseMirror h2 {
font-size: 1.4em;
font-weight: 700;
line-height: 1.3;
margin-top: 0.7em;
}
.note-editor .ProseMirror h3 {
font-size: 1.15em;
font-weight: 700;
line-height: 1.4;
margin-top: 0.6em;
}
.note-editor .ProseMirror ul,
.note-editor .ProseMirror ol {
padding-left: 1.4em;
}
.note-editor .ProseMirror li p {
margin: 0;
}
.note-editor .ProseMirror blockquote {
border-left: 3px solid var(--ne-border);
padding-left: 12px;
color: var(--ne-muted);
}
.note-editor .ProseMirror code {
background: var(--ne-subtle);
border: 1px solid var(--ne-border);
border-radius: 4px;
padding: 1px 5px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.88em;
}
.note-editor .ProseMirror pre {
background: var(--ne-subtle);
border: 1px solid var(--ne-border);
border-radius: 10px;
padding: 12px 14px;
overflow-x: auto;
}
.note-editor .ProseMirror pre code {
background: none;
border: none;
padding: 0;
}
.note-editor .ProseMirror hr {
border: none;
border-top: 1px solid var(--ne-border);
margin: 1.2em 0;
}
.note-editor .ProseMirror img {
max-width: 100%;
height: auto;
border-radius: 8px;
display: block;
}
.note-editor .ProseMirror img.note-image {
background: var(--ne-subtle);
min-height: 24px;
}
/* An attachment whose bytes could not be fetched — visible, not a blank gap */
.note-editor .ProseMirror img[data-missing="true"] {
border: 1px dashed var(--ne-border);
padding: 12px;
min-width: 120px;
}
.note-editor .ProseMirror a {
color: var(--ne-accent);
}
/* Placeholder on the first empty line */
.note-editor .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--ne-faint);
float: left;
height: 0;
pointer-events: none;
}
/* ── Tables ── */
.note-editor .ProseMirror table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
margin: 0.8em 0;
overflow: hidden;
}
.note-editor .ProseMirror td,
.note-editor .ProseMirror th {
border: 1px solid var(--ne-border);
padding: 7px 10px;
vertical-align: top;
position: relative;
min-width: 1em;
}
.note-editor .ProseMirror th {
background: var(--ne-subtle);
font-weight: 600;
text-align: left;
}
.note-editor .ProseMirror .selectedCell::after {
content: "";
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--ne-accent) 18%, transparent);
pointer-events: none;
}
/* Drag handle between columns */
.note-editor .ProseMirror .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
background: var(--ne-accent);
pointer-events: none;
}
.note-editor .ProseMirror.resize-cursor {
cursor: col-resize;
}
/* ── Task lists ── */
.note-editor .ProseMirror ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.note-editor .ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 8px;
}
.note-editor .ProseMirror ul[data-type="taskList"] li > label {
flex: 0 0 auto;
margin-top: 0.35em;
user-select: none;
}
.note-editor .ProseMirror ul[data-type="taskList"] li > div {
flex: 1 1 auto;
}
.note-editor .ProseMirror ul[data-type="taskList"] input[type="checkbox"] {
accent-color: var(--ne-accent);
width: 15px;
height: 15px;
cursor: pointer;
}
.note-editor .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div {
color: var(--ne-muted);
text-decoration: line-through;
}
/* ── Note links ── */
.note-editor .ProseMirror a.note-link,
.note-link {
display: inline-flex;
align-items: baseline;
gap: 3px;
padding: 1px 6px;
border-radius: 6px;
background: color-mix(in srgb, var(--ne-accent) 14%, transparent);
color: var(--ne-accent);
text-decoration: none;
font-family: "DM Sans", system-ui, sans-serif;
font-size: 0.9em;
white-space: nowrap;
cursor: pointer;
}
.note-editor .ProseMirror a.note-link:hover {
background: color-mix(in srgb, var(--ne-accent) 26%, transparent);
}
/* A link whose target is gone — still readable, visibly broken */
.note-editor .ProseMirror a.note-link[data-missing="true"] {
background: color-mix(in srgb, #cf222e 14%, transparent);
color: #cf222e;
text-decoration: line-through;
}
/* ── @-mention popup ── */
.note-link-popup {
position: fixed;
z-index: 3000;
max-height: 240px;
overflow-y: auto;
padding: 5px;
border-radius: 12px;
border: 1px solid var(--popup-border, #d0d7de);
background: var(--popup-bg, #ffffff);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22);
font-family: "DM Sans", system-ui, sans-serif;
}
.note-link-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--popup-text, #1f2328);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.note-link-option.is-selected {
background: var(--popup-hover, #eaeef2);
}
.note-link-option .note-link-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-link-empty {
padding: 9px 11px;
font-size: 12px;
color: var(--popup-muted, #656d76);
}