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

+375
View File
@@ -0,0 +1,375 @@
import { COVERS, DEFAULT_FOLDERS } from "./constants.jsx";
import { I } from "./icons.jsx";
import { FONT_UI } from "./theme";
const SYNC_LABELS = {
synced: "All synced",
syncing: "Syncing...",
offline: "Offline mode",
pending: "Changes pending",
};
const syncColor = (status, T) => {
if (status === "synced") return T.success;
if (status === "syncing" || status === "pending") return T.warning;
if (status === "offline") return T.muted;
return T.danger;
};
const iconButton = (T) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
color: T.muted,
});
const folderButton = (T, active) => ({
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "8px 12px",
borderRadius: 10,
border: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
textAlign: "left",
background: active ? T.sidebarActive : "transparent",
color: active ? T.accentText : T.textSec,
});
const countBadge = (T, color) => ({
marginLeft: "auto",
fontSize: 11,
color,
background: color === T.danger ? T.danger + "15" : T.accentSoft,
padding: "2px 7px",
borderRadius: 10,
});
/**
* Navigation rail: search, the built-in views, and the notebook list.
*
* On a phone it becomes a full-screen overlay; `onClose` is what the backdrop
* and every navigation action call so it does not stay in the way.
*/
export function Sidebar({
T,
dark,
onToggleDark,
isMobile,
open,
onClose,
searchQuery,
onSearchChange,
activeFolder,
onSelectFolder,
notebooks,
activeNotes,
trashedCount,
onNewNotebook,
onOpenAccount,
onNotebookContextMenu,
dnd,
canInstall,
onInstall,
syncStatus,
online,
}) {
const notesIn = (nbId) => activeNotes.filter((n) => n.folderId === nbId).length;
return (
<div
style={{
position: isMobile ? "fixed" : "relative",
zIndex: isMobile ? 900 : 1,
width: isMobile ? (open ? "100vw" : 0) : open ? 260 : 0,
minWidth: isMobile ? 0 : open ? 260 : 0,
height: isMobile ? "100vh" : "auto",
background: T.sidebar,
borderRight: isMobile ? "none" : `1px solid ${T.border}`,
display: "flex",
flexDirection: "column",
overflow: "hidden",
transition: "all .25s cubic-bezier(.4,0,.2,1)",
fontFamily: FONT_UI,
}}
>
{isMobile && open && (
<div
onClick={onClose}
style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: -1 }}
/>
)}
<div
style={{
width: isMobile ? "85vw" : 260,
maxWidth: 320,
height: "100%",
background: T.sidebar,
display: "flex",
flexDirection: "column",
position: "relative",
zIndex: 1,
}}
>
<div
style={{
padding: "20px 18px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div
style={{
width: 32,
height: 32,
borderRadius: 10,
background: `linear-gradient(135deg,${T.accent},#a855f7)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 16,
}}
>
✏️
</div>
<span style={{ fontWeight: 700, fontSize: 17, color: T.text, letterSpacing: "-0.02em" }}>
InkNote
</span>
</div>
<div style={{ display: "flex", gap: 4 }}>
<button onClick={onToggleDark} style={iconButton(T)}>
{dark ? I.sun : I.moon}
</button>
<button onClick={onOpenAccount} title="Account" style={iconButton(T)}>
{I.user}
</button>
</div>
</div>
<div style={{ padding: "0 14px 12px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderRadius: 10,
background: T.inputBg,
border: `1px solid ${T.borderLight}`,
}}
>
<span style={{ color: T.faint }}>{I.search}</span>
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search notes..."
style={{
border: "none",
background: "transparent",
outline: "none",
flex: 1,
color: T.text,
fontSize: 13,
fontFamily: FONT_UI,
}}
/>
</div>
</div>
<div style={{ padding: "0 10px" }}>
<button
onClick={() => onSelectFolder("dashboard")}
style={{ ...folderButton(T, activeFolder === "dashboard"), padding: "9px 12px", fontWeight: 600 }}
>
<span style={{ fontSize: 15 }}>📚</span>
<span>Dashboard</span>
</button>
</div>
<div style={{ padding: "6px 10px 0" }}>
{DEFAULT_FOLDERS.map((f) => (
<button
key={f.id}
onClick={() => onSelectFolder(f.id)}
style={folderButton(T, activeFolder === f.id)}
>
<span style={{ fontSize: 14 }}>{f.icon}</span>
<span>{f.name}</span>
{f.id === "all" && <span style={countBadge(T, T.faint)}>{activeNotes.length}</span>}
{f.id === "trash" && trashedCount > 0 && (
<span style={countBadge(T, T.danger)}>{trashedCount}</span>
)}
</button>
))}
</div>
<div style={{ padding: "14px 10px 4px" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 12px",
marginBottom: 6,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 600,
color: T.faint,
textTransform: "uppercase",
letterSpacing: "0.08em",
}}
>
Notebooks
</span>
<button
onClick={onNewNotebook}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "none",
background: T.accentSoft,
cursor: "pointer",
color: T.accent,
}}
>
{I.plus}
</button>
</div>
{notebooks.map((nb) => (
<div
key={nb.id}
draggable={!isMobile}
onDragStart={(e) => dnd.onDragStart(e, nb.id)}
onDragOver={(e) => dnd.onDragOver(e, nb.id)}
onDrop={(e) => dnd.onDrop(e, nb.id)}
onDragEnd={dnd.onDragEnd}
onContextMenu={(e) => onNotebookContextMenu(e, nb)}
style={{
display: "flex",
alignItems: "center",
borderRadius: 10,
transition: "all .15s",
background:
dnd.overId === nb.id
? T.accentSoft
: activeFolder === nb.id
? T.sidebarActive
: "transparent",
borderTop: dnd.overId === nb.id ? `2px solid ${T.accent}` : "2px solid transparent",
}}
>
{!isMobile && (
<span
style={{
padding: "0 2px 0 6px",
color: T.faint,
cursor: "grab",
display: "flex",
alignItems: "center",
opacity: 0.5,
}}
>
{I.grip}
</span>
)}
<button
onClick={() => onSelectFolder(nb.id)}
style={{
display: "flex",
alignItems: "center",
gap: 10,
flex: 1,
padding: "7px 10px 7px " + (isMobile ? "10px" : "4px"),
borderRadius: 10,
border: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
textAlign: "left",
background: "transparent",
color: activeFolder === nb.id ? T.accentText : T.textSec,
}}
>
<div
style={{
width: 20,
height: 14,
borderRadius: 3,
background: COVERS[nb.cover % COVERS.length].bg,
flexShrink: 0,
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}}
/>
<span>{nb.name}</span>
<span style={{ marginLeft: "auto", fontSize: 11, color: T.faint }}>{notesIn(nb.id)}</span>
</button>
</div>
))}
</div>
<div style={{ marginTop: "auto", padding: "12px 14px", borderTop: `1px solid ${T.borderLight}` }}>
{canInstall && (
<button
onClick={onInstall}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "8px 12px",
borderRadius: 10,
border: `1px solid ${T.accent}44`,
background: T.accentSoft,
cursor: "pointer",
color: T.accent,
fontSize: 12,
fontWeight: 600,
marginBottom: 8,
}}
>
{I.download}
Install App
</button>
)}
<div style={{ display: "flex", alignItems: "center", gap: 6, color: T.muted, fontSize: 12 }}>
<span style={{ color: syncColor(syncStatus, T) }}>●</span>
<span>{SYNC_LABELS[syncStatus] || "Sync error"}</span>
{!online && (
<span
style={{
fontSize: 10,
padding: "2px 6px",
borderRadius: 6,
background: T.faint + "22",
color: T.faint,
marginLeft: "auto",
}}
>
offline
</span>
)}
</div>
</div>
</div>
</div>
);
}