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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user