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:
1 parent
d97591b242
commit
92d525b3ab
71 files changed
+14354
-1826
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user