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