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>
376 lines
11 KiB
React
376 lines
11 KiB
React
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>
|
|
);
|
|
}
|