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>
218 lines
6.6 KiB
React
218 lines
6.6 KiB
React
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>
|
|
);
|
|
}
|