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,221 @@
|
||||
import { I } from "../../ui/icons.jsx";
|
||||
import { BackButton } from "../../ui/BackButton.jsx";
|
||||
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
|
||||
import { NoteEditor } from "./NoteEditor.jsx";
|
||||
|
||||
const toolbarButton = (T, color) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color,
|
||||
});
|
||||
|
||||
/** Shown on desktop when nothing is selected; on a phone the list fills the screen instead. */
|
||||
function EmptyState({ T, isTrashView, onCreateNote }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: T.bg,
|
||||
color: T.faint,
|
||||
fontFamily: FONT_UI,
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 20,
|
||||
background: T.accentSoft,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 32,
|
||||
}}
|
||||
>
|
||||
✏️
|
||||
</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: T.muted }}>Select a note to edit</div>
|
||||
{!isTrashView && (
|
||||
<button
|
||||
onClick={onCreateNote}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "10px 22px",
|
||||
borderRadius: 12,
|
||||
border: "none",
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{I.plus} New Note
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A trashed note can be read and restored, but not edited. */
|
||||
function TrashedNote({ T, isMobile, note, onBack, onRestore, onPermanentDelete }) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: T.bg, fontFamily: FONT_UI }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "12px 16px",
|
||||
borderBottom: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
{isMobile && <BackButton T={T} onClick={onBack} />}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
fontFamily: FONT_SERIF,
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{note.title}
|
||||
</span>
|
||||
<button
|
||||
onClick={onRestore}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button onClick={onPermanentDelete} style={toolbarButton(T, T.danger)}>
|
||||
{I.trash}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: "20px 24px", color: T.muted, fontStyle: "italic", fontSize: 14 }}>
|
||||
Trashed notes are read-only. Restore to edit.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The right-hand pane: title bar plus the rich-text editor.
|
||||
*
|
||||
* `restoreEpoch` is part of the editor's key so that restoring an old version
|
||||
* remounts it on the restored body instead of trying to merge into the live
|
||||
* document.
|
||||
*/
|
||||
export function EditorPane({
|
||||
T,
|
||||
dark,
|
||||
isMobile,
|
||||
note,
|
||||
isTrashView,
|
||||
restoreEpoch,
|
||||
backlinks,
|
||||
linkProps,
|
||||
onBack,
|
||||
onUpdate,
|
||||
onToggleStar,
|
||||
onTrash,
|
||||
onRestore,
|
||||
onPermanentDelete,
|
||||
onOpenHistory,
|
||||
onCreateNote,
|
||||
}) {
|
||||
if (!note)
|
||||
return isMobile ? null : <EmptyState T={T} isTrashView={isTrashView} onCreateNote={onCreateNote} />;
|
||||
|
||||
if (isTrashView)
|
||||
return (
|
||||
<TrashedNote
|
||||
T={T}
|
||||
isMobile={isMobile}
|
||||
note={note}
|
||||
onBack={onBack}
|
||||
onRestore={onRestore}
|
||||
onPermanentDelete={onPermanentDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: T.bg, fontFamily: FONT_UI }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 16px",
|
||||
borderBottom: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
{isMobile && <BackButton T={T} onClick={onBack} />}
|
||||
<input
|
||||
type="text"
|
||||
value={note.title}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
outline: "none",
|
||||
fontSize: isMobile ? 15 : 17,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
fontFamily: FONT_SERIF,
|
||||
minWidth: 0,
|
||||
}}
|
||||
/>
|
||||
<button onClick={onOpenHistory} title="Version history" style={toolbarButton(T, T.faint)}>
|
||||
{I.history}
|
||||
</button>
|
||||
<button onClick={onToggleStar} style={toolbarButton(T, note.starred ? "#f0b429" : T.faint)}>
|
||||
{note.starred ? I.starFill : I.star}
|
||||
</button>
|
||||
<button onClick={onTrash} style={toolbarButton(T, T.danger)}>
|
||||
{I.trash}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
<NoteEditor
|
||||
key={`${note.id}:${restoreEpoch}`}
|
||||
note={note}
|
||||
onUpdate={onUpdate}
|
||||
dark={dark}
|
||||
onOpenNote={linkProps.openLinkTarget}
|
||||
searchLinkTargets={linkProps.searchLinkTargets}
|
||||
resolveLinkLabel={linkProps.resolveLinkLabel}
|
||||
backlinks={backlinks}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user