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,217 @@
|
||||
import { I } from "../../ui/icons.jsx";
|
||||
import { fmtDate } from "../../ui/format";
|
||||
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
|
||||
|
||||
/** The middle column: every note in the selected folder, newest or best match first. */
|
||||
export function NoteList({
|
||||
T,
|
||||
isMobile,
|
||||
title,
|
||||
notes,
|
||||
activeNote,
|
||||
isTrashView,
|
||||
trashedCount,
|
||||
snippetFor,
|
||||
onToggleSidebar,
|
||||
onSelectNote,
|
||||
onNoteContextMenu,
|
||||
onCreateNote,
|
||||
onEmptyTrash,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: isMobile ? "100%" : 300,
|
||||
minWidth: isMobile ? 0 : 300,
|
||||
flex: isMobile ? 1 : undefined,
|
||||
borderRight: isMobile ? "none" : `1px solid ${T.border}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: T.bg,
|
||||
fontFamily: FONT_UI,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 16px 10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: T.muted,
|
||||
}}
|
||||
>
|
||||
{I.menu}
|
||||
</button>
|
||||
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{!isTrashView && (
|
||||
<button
|
||||
onClick={onCreateNote}
|
||||
title="New Note"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: T.accent,
|
||||
cursor: "pointer",
|
||||
color: "#fff",
|
||||
boxShadow: `0 2px 8px ${T.accent}44`,
|
||||
}}
|
||||
>
|
||||
{I.plus}
|
||||
</button>
|
||||
)}
|
||||
{isTrashView && trashedCount > 0 && (
|
||||
<button
|
||||
onClick={onEmptyTrash}
|
||||
title="Empty Trash"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "6px 12px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: T.danger,
|
||||
cursor: "pointer",
|
||||
color: "#fff",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Empty
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
|
||||
{notes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: T.faint,
|
||||
gap: 10,
|
||||
padding: 30,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 36 }}>{isTrashView ? "🗑️" : "📝"}</div>
|
||||
<div style={{ fontSize: 13, textAlign: "center" }}>
|
||||
{isTrashView ? "Trash is empty" : "No notes yet"}
|
||||
</div>
|
||||
{!isTrashView && (
|
||||
<button
|
||||
onClick={onCreateNote}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "10px 20px",
|
||||
borderRadius: 12,
|
||||
border: "none",
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{I.plus} New Note
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
notes.map((note) => {
|
||||
const snippet = snippetFor(note.id);
|
||||
return (
|
||||
<button
|
||||
key={note.id}
|
||||
onClick={() => onSelectNote(note.id)}
|
||||
onContextMenu={(e) => onNoteContextMenu(e, note)}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
padding: "12px 14px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
||||
background: activeNote === note.id ? T.accentSoft : "transparent",
|
||||
cursor: "pointer",
|
||||
marginBottom: 2,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 3, width: "100%" }}
|
||||
>
|
||||
<span style={{ fontSize: 13 }}>{note.trashed ? "🗑️" : "📝"}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{note.title}
|
||||
</span>
|
||||
{note.starred && !note.trashed && (
|
||||
<span style={{ color: "#f0b429", fontSize: 11 }}>⭐</span>
|
||||
)}
|
||||
</div>
|
||||
{snippet && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: T.textSec,
|
||||
lineHeight: 1.45,
|
||||
marginTop: 1,
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{snippet}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 10, color: T.faint, marginTop: 3 }}>{fmtDate(note.updatedAt)}</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user