Files
InkNote/frontend/src/features/notes/NoteList.jsx
T
marcandClaude Opus 5 92d525b3ab 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>
2026-08-07 17:00:14 +02:00

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>
);
}