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:
marcandClaude Opus 5 committed 2026-08-07 17:00:14 +02:00
1 parent d97591b242
commit 92d525b3ab
71 files changed
+14354 -1826

No files matched your search

+394
View File
@@ -0,0 +1,394 @@
import { useMemo, useState } from "react";
import * as sync from "../syncEngine";
import { I } from "./icons.jsx";
/* ═══════ ACCOUNT MENU ═══════ */
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, dark }) {
const [tab, setTab] = useState("profile"); // profile | username | password
const [displayName, setDisplayName] = useState(user?.displayName || "");
const [newUsername, setNewUsername] = useState(user?.username || "");
const [usernamePass, setUsernamePass] = useState("");
const [curPass, setCurPass] = useState("");
const [newPass, setNewPass] = useState("");
const [newPass2, setNewPass2] = useState("");
const [msg, setMsg] = useState("");
const [err, setErr] = useState("");
const [saving, setSaving] = useState(false);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
danger: dark ? "#f85149" : "#cf222e",
success: dark ? "#3fb950" : "#1a7f37",
inputBg: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
const inp = {
width: "100%",
padding: "10px 14px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.inputBg,
color: T.text,
fontSize: 14,
outline: "none",
boxSizing: "border-box",
marginBottom: 10,
fontFamily: "'DM Sans',sans-serif",
};
const tbtn = (active) => ({
padding: "7px 14px",
borderRadius: 8,
border: "none",
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
background: active ? T.accent + "22" : "transparent",
color: active ? T.accent : T.muted,
});
const clear = () => {
setMsg("");
setErr("");
};
const saveProfile = async () => {
if (!displayName.trim()) return;
clear();
setSaving(true);
try {
const u = await sync.updateProfile(displayName.trim());
onUserUpdate(u);
setMsg("Display name updated");
} catch (e) {
setErr(e.message);
}
setSaving(false);
};
const saveUsername = async () => {
if (!newUsername || !usernamePass) return;
clear();
setSaving(true);
try {
const u = await sync.updateUsername(newUsername.trim(), usernamePass);
onUserUpdate(u);
setUsernamePass("");
setMsg("Username updated");
} catch (e) {
setErr(e.message);
}
setSaving(false);
};
const savePassword = async () => {
if (!curPass || !newPass) return;
if (newPass !== newPass2) {
setErr("Passwords don't match");
return;
}
clear();
setSaving(true);
try {
await sync.updatePassword(curPass, newPass);
setCurPass("");
setNewPass("");
setNewPass2("");
setMsg("Password changed");
} catch (e) {
setErr(e.message);
}
setSaving(false);
};
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
>
<div
style={{
background: T.bg,
borderRadius: 20,
padding: 0,
width: 440,
maxWidth: "92vw",
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
border: `1px solid ${T.border}`,
overflow: "hidden",
}}
onClick={(e) => e.stopPropagation()}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "18px 22px",
borderBottom: `1px solid ${T.border}`,
}}
>
<h3
style={{
margin: 0,
fontSize: 17,
fontWeight: 600,
color: T.text,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
Account
</h3>
<button
onClick={onClose}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
color: T.muted,
}}
>
{I.x}
</button>
</div>
<div
style={{ display: "flex", gap: 4, padding: "12px 22px 0", borderBottom: `1px solid ${T.border}` }}
>
<button
onClick={() => {
setTab("profile");
clear();
}}
style={tbtn(tab === "profile")}
>
Profile
</button>
<button
onClick={() => {
setTab("username");
clear();
}}
style={tbtn(tab === "username")}
>
Username
</button>
<button
onClick={() => {
setTab("password");
clear();
}}
style={tbtn(tab === "password")}
>
Password
</button>
</div>
<div style={{ padding: "18px 22px 22px" }}>
{msg && (
<div
style={{
color: T.success,
fontSize: 12,
marginBottom: 10,
padding: "8px 12px",
borderRadius: 8,
background: T.success + "15",
}}
>
{msg}
</div>
)}
{err && (
<div
style={{
color: T.danger,
fontSize: 12,
marginBottom: 10,
padding: "8px 12px",
borderRadius: 8,
background: T.danger + "12",
}}
>
{err}
</div>
)}
{tab === "profile" && (
<>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
style={inp}
onKeyDown={(e) => {
if (e.key === "Enter") saveProfile();
}}
/>
<button
onClick={saveProfile}
disabled={saving}
style={{
width: "100%",
padding: "10px",
borderRadius: 10,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
opacity: saving ? 0.6 : 1,
}}
>
{saving ? "Saving..." : "Save Changes"}
</button>
</>
)}
{tab === "username" && (
<>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
New Username
</label>
<input
type="text"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
style={inp}
/>
<div style={{ fontSize: 11, color: T.muted, marginTop: -6, marginBottom: 10 }}>
3–32 characters · letters, digits, . _ -
</div>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
Confirm Password
</label>
<input
type="password"
value={usernamePass}
onChange={(e) => setUsernamePass(e.target.value)}
style={inp}
onKeyDown={(e) => {
if (e.key === "Enter") saveUsername();
}}
/>
<button
onClick={saveUsername}
disabled={saving}
style={{
width: "100%",
padding: "10px",
borderRadius: 10,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
opacity: saving ? 0.6 : 1,
}}
>
{saving ? "Saving..." : "Update Username"}
</button>
</>
)}
{tab === "password" && (
<>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
Current Password
</label>
<input
type="password"
value={curPass}
onChange={(e) => setCurPass(e.target.value)}
style={inp}
/>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
New Password
</label>
<input
type="password"
value={newPass}
onChange={(e) => setNewPass(e.target.value)}
style={inp}
/>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
Confirm New Password
</label>
<input
type="password"
value={newPass2}
onChange={(e) => setNewPass2(e.target.value)}
style={inp}
onKeyDown={(e) => {
if (e.key === "Enter") savePassword();
}}
/>
<button
onClick={savePassword}
disabled={saving}
style={{
width: "100%",
padding: "10px",
borderRadius: 10,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
opacity: saving ? 0.6 : 1,
}}
>
{saving ? "Saving..." : "Change Password"}
</button>
</>
)}
</div>
<div style={{ padding: "0 22px 18px" }}>
<button
onClick={onLogout}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "10px",
borderRadius: 10,
border: `1px solid ${T.danger}33`,
background: T.danger + "08",
cursor: "pointer",
color: T.danger,
fontSize: 13,
fontWeight: 600,
justifyContent: "center",
}}
>
{I.logout} Sign Out
</button>
</div>
</div>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { useMemo, useState } from "react";
import * as sync from "../syncEngine";
/* ═══════ AUTH SCREEN ═══════ */
export function AuthScreen({ onLogin, dark }) {
const [isLogin, setIsLogin] = useState(true);
const [username, setUsername] = useState("");
const [pass, setPass] = useState("");
const [name, setName] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const T = useMemo(
() => ({
bg: dark ? "#0d1117" : "#f6f8fa",
card: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
danger: dark ? "#f85149" : "#cf222e",
}),
[dark]
);
const submit = async () => {
const u = username.trim();
if (!u || !pass) {
setErr("Username and password required");
return;
}
if (!isLogin && !/^[a-zA-Z0-9._-]{3,32}$/.test(u)) {
setErr("Username must be 3-32 chars: letters, digits, . _ -");
return;
}
setLoading(true);
setErr("");
try {
const user = isLogin ? await sync.login(u, pass) : await sync.register(u, pass, name || undefined);
onLogin(user);
} catch (e) {
setErr(e.message === "AUTH" ? "Invalid credentials" : e.message);
} finally {
setLoading(false);
}
};
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: T.bg,
fontFamily: "'DM Sans',sans-serif",
}}
>
<div
style={{
background: T.card,
borderRadius: 24,
padding: 40,
width: 400,
maxWidth: "90vw",
boxShadow: "0 20px 60px rgba(0,0,0,0.15)",
border: `1px solid ${T.border}`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 28 }}>
<div
style={{
width: 42,
height: 42,
borderRadius: 14,
background: `linear-gradient(135deg,${T.accent},#a855f7)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 22,
}}
>
✏️
</div>
<div>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: T.text,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
InkNote
</div>
<div style={{ fontSize: 12, color: T.muted }}>
{isLogin ? "Sign in to sync your notes" : "Create your account"}
</div>
</div>
</div>
{!isLogin && (
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Display name"
style={{
width: "100%",
padding: "12px 16px",
borderRadius: 12,
border: `1px solid ${T.border}`,
background: T.bg,
color: T.text,
fontSize: 14,
outline: "none",
marginBottom: 12,
boxSizing: "border-box",
}}
/>
)}
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
autoFocus
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoComplete="username"
style={{
width: "100%",
padding: "12px 16px",
borderRadius: 12,
border: `1px solid ${T.border}`,
background: T.bg,
color: T.text,
fontSize: 14,
outline: "none",
marginBottom: 12,
boxSizing: "border-box",
}}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
/>
<input
type="password"
value={pass}
onChange={(e) => setPass(e.target.value)}
placeholder="Password"
autoComplete={isLogin ? "current-password" : "new-password"}
style={{
width: "100%",
padding: "12px 16px",
borderRadius: 12,
border: `1px solid ${T.border}`,
background: T.bg,
color: T.text,
fontSize: 14,
outline: "none",
marginBottom: 16,
boxSizing: "border-box",
}}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
/>
{err && (
<div
style={{
color: T.danger,
fontSize: 12,
marginBottom: 12,
padding: "8px 12px",
borderRadius: 8,
background: T.danger + "12",
}}
>
{err}
</div>
)}
<button
onClick={submit}
disabled={loading}
style={{
width: "100%",
padding: "12px",
borderRadius: 12,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
marginBottom: 16,
opacity: loading ? 0.6 : 1,
}}
>
{loading ? "..." : isLogin ? "Sign In" : "Create Account"}
</button>
<div style={{ textAlign: "center", fontSize: 13, color: T.muted }}>
{isLogin ? "Don't have an account? " : "Already have an account? "}
<button
onClick={() => {
setIsLogin(!isLogin);
setErr("");
}}
style={{
background: "none",
border: "none",
color: T.accent,
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
textDecoration: "underline",
}}
>
{isLogin ? "Sign Up" : "Sign In"}
</button>
</div>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { I } from "./icons.jsx";
/** Phone-only return arrow; desktop keeps list and editor side by side. */
export function BackButton({ T, label = "Back", onClick }) {
return (
<button
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 10px",
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
color: T.accent,
fontSize: 13,
fontWeight: 600,
}}
>
{I.back}
{label}
</button>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useMemo, useState } from "react";
/* ═══════ BACKLINKS ═══════ */
/* Notes that point at the one currently open. The reverse direction is not part
* of the note itself, so it can only come from the server's link index.
*
* Sits as a bar under the editor rather than after the page: a page is a fixed
* 4:3 sheet, so anything placed below it starts a full screen out of sight. */
export function BacklinksPanel({ backlinks, onOpenNote, dark }) {
const [open, setOpen] = useState(false);
if (!backlinks || backlinks.length === 0) return null;
const T = {
border: dark ? "#30363d" : "#d0d7de",
muted: dark ? "#8b949e" : "#656d76",
surface: dark ? "#161b22" : "#f6f8fa",
accent: dark ? "#388bfd" : "#0969da",
};
return (
<div
style={{
borderTop: `1px solid ${T.border}`,
background: T.surface,
flexShrink: 0,
maxHeight: "38vh",
display: "flex",
flexDirection: "column",
}}
>
<button
onClick={() => setOpen((o) => !o)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "9px 16px",
border: "none",
background: "transparent",
cursor: "pointer",
color: T.muted,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "left",
flexShrink: 0,
}}
>
<span
style={{
display: "inline-block",
transform: open ? "rotate(90deg)" : "none",
transition: "transform .15s",
}}
>
▸
</span>
<span>
Linked from {backlinks.length} {backlinks.length === 1 ? "note" : "notes"}
</span>
</button>
{open && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 2,
padding: "0 10px 10px",
overflowY: "auto",
}}
>
{backlinks.map((b) => (
<button
key={b.id}
onClick={() => onOpenNote?.(b.id)}
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
width: "100%",
padding: "7px 9px",
border: "none",
borderRadius: 8,
background: "transparent",
cursor: "pointer",
textAlign: "left",
}}
>
<span
style={{
fontSize: 13,
color: T.accent,
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{b.title || "Untitled"}
</span>
{b.notebook_name && (
<span style={{ fontSize: 11, color: T.muted, flexShrink: 0 }}>in {b.notebook_name}</span>
)}
</button>
))}
</div>
)}
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useMemo } from "react";
/* ═══════ Shared Components ═══════ */
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onCancel, dark }) {
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
danger: dark ? "#f85149" : "#cf222e",
}),
[dark]
);
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={onCancel}
>
<div
style={{
background: T.bg,
borderRadius: 16,
padding: 26,
width: 380,
maxWidth: "90vw",
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
border: `1px solid ${T.border}`,
}}
onClick={(e) => e.stopPropagation()}
>
<div
style={{
fontSize: 17,
fontWeight: 600,
color: T.text,
marginBottom: 8,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
{title}
</div>
<div
style={{
fontSize: 13,
color: T.muted,
lineHeight: 1.6,
marginBottom: 22,
fontFamily: "'DM Sans',sans-serif",
}}
>
{message}
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button
onClick={onCancel}
style={{
padding: "8px 20px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
}}
>
Cancel
</button>
<button
onClick={onConfirm}
style={{
padding: "8px 20px",
borderRadius: 10,
border: "none",
background: T.danger,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
}}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useMemo, useRef, useState } from "react";
export function ContextMenu({ x, y, items, onClose, dark }) {
const T = useMemo(
() => ({
bg: dark ? "#1c2128" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
hover: dark ? "#262c36" : "#f3f4f6",
danger: dark ? "#f85149" : "#cf222e",
}),
[dark]
);
useEffect(() => {
const h = () => onClose();
window.addEventListener("click", h);
window.addEventListener("contextmenu", h);
return () => {
window.removeEventListener("click", h);
window.removeEventListener("contextmenu", h);
};
}, [onClose]);
return (
<div
style={{
position: "fixed",
left: Math.min(x, window.innerWidth - 220),
top: Math.min(y, window.innerHeight - 200),
zIndex: 3000,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 12,
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
padding: 4,
minWidth: 190,
fontFamily: "'DM Sans',sans-serif",
}}
onClick={(e) => e.stopPropagation()}
>
{items.map((item, i) =>
item.divider ? (
<div key={i} style={{ height: 1, background: T.border, margin: "4px 6px" }} />
) : item.submenu ? (
<SubMenu key={i} item={item} T={T} onClose={onClose} />
) : (
<button
key={i}
onClick={() => {
item.action();
onClose();
}}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "8px 12px",
borderRadius: 8,
border: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
background: "transparent",
color: item.danger ? T.danger : T.text,
textAlign: "left",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = T.hover)}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
{item.label}
</button>
)
)}
</div>
);
}
function SubMenu({ item, T, onClose }) {
const [open, setOpen] = useState(false);
return (
<div
style={{ position: "relative" }}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "8px 12px",
borderRadius: 8,
fontSize: 13,
fontWeight: 500,
color: T.text,
cursor: "pointer",
background: open ? T.hover : "transparent",
}}
>
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
{item.label}
<span style={{ marginLeft: "auto", fontSize: 10, opacity: 0.5 }}>▸</span>
</div>
{open && (
<div
style={{
position: "absolute",
left: "100%",
top: 0,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 10,
boxShadow: "0 8px 20px rgba(0,0,0,0.2)",
padding: 4,
minWidth: 160,
zIndex: 10,
}}
>
{item.submenu.map((sub, j) => (
<button
key={j}
onClick={() => {
sub.action();
onClose();
}}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "7px 12px",
borderRadius: 6,
border: "none",
cursor: "pointer",
fontSize: 12,
fontWeight: 500,
background: "transparent",
color: T.text,
textAlign: "left",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = T.hover)}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
{sub.colorDot && (
<div
style={{ width: 12, height: 8, borderRadius: 2, background: sub.colorDot, flexShrink: 0 }}
/>
)}
{sub.label}
</button>
))}
</div>
)}
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
import { useState } from "react";
import { COVERS } from "./constants.jsx";
/* ═══════ NOTEBOOK COVER ═══════ */
export function NotebookCover({
notebook,
noteCount,
onClick,
onContextMenu,
isActive,
isDragOver,
draggable,
onDragStart,
onDragEnd,
onDragOver,
onDrop,
}) {
const cover = COVERS[notebook.cover % COVERS.length];
const [hovered, setHovered] = useState(false);
return (
<button
onClick={onClick}
onContextMenu={onContextMenu}
draggable={draggable}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDrop={onDrop}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
background: "none",
border: "none",
cursor: "pointer",
transition: "transform .22s cubic-bezier(.4,0,.2,1),opacity .2s",
transform: hovered ? "translateY(-6px) scale(1.03)" : isDragOver ? "scale(1.06)" : "none",
opacity: isDragOver ? 0.6 : 1,
}}
>
<div style={{ position: "relative", width: 156, height: 210, perspective: 800 }}>
<div
style={{
position: "absolute",
bottom: -5,
left: 10,
right: 10,
height: 14,
background: "rgba(0,0,0,0.25)",
borderRadius: "50%",
filter: "blur(8px)",
opacity: hovered ? 0.5 : 0.3,
}}
/>
<div
style={{
position: "absolute",
left: 0,
top: 2,
bottom: 2,
width: 18,
background: cover.spine,
borderRadius: "4px 0 0 4px",
boxShadow: "inset -2px 0 4px rgba(0,0,0,0.3)",
zIndex: 2,
}}
>
{[12, 18].map((t) => (
<div
key={t}
style={{
position: "absolute",
top: t,
left: 3,
right: 3,
height: 1,
background: "rgba(255,255,255,0.1)",
}}
/>
))}
{[12, 18].map((t) => (
<div
key={"b" + t}
style={{
position: "absolute",
bottom: t,
left: 3,
right: 3,
height: 1,
background: "rgba(255,255,255,0.1)",
}}
/>
))}
</div>
<div
style={{
position: "absolute",
right: 2,
top: 8,
bottom: 8,
width: 5,
background: "linear-gradient(90deg,#e8e0d4,#f5f0e8,#e8e0d4)",
borderRadius: "0 2px 2px 0",
zIndex: 1,
}}
/>
<div
style={{
position: "absolute",
left: 14,
top: 0,
right: 0,
bottom: 0,
background: cover.bg,
borderRadius: "2px 8px 8px 2px",
boxShadow: isActive
? `0 4px 20px rgba(0,0,0,0.3),0 0 0 3px ${cover.accent}88`
: "0 4px 16px rgba(0,0,0,0.2)",
overflow: "hidden",
zIndex: 3,
}}
>
<div
style={{
position: "absolute",
inset: 0,
background:
"repeating-linear-gradient(45deg,transparent,transparent 30px,rgba(255,255,255,0.015) 30px,rgba(255,255,255,0.015) 60px)",
}}
/>
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 3,
background: cover.accent,
opacity: 0.7,
}}
/>
<div
style={{
position: "absolute",
top: 28,
left: "50%",
transform: "translateX(-50%)",
width: 50,
height: 50,
borderRadius: "50%",
border: `1.5px solid ${cover.accent}44`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{ width: 30, height: 30, borderRadius: "50%", border: `1px solid ${cover.accent}33` }}
/>
</div>
<div
style={{
position: "absolute",
top: 90,
left: 8,
right: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
<div
style={{
width: "100%",
padding: "6px 4px",
borderTop: `1px solid ${cover.accent}33`,
borderBottom: `1px solid ${cover.accent}33`,
textAlign: "center",
}}
>
<span
style={{
color: "#fff",
fontSize: notebook.name.length > 12 ? 12 : 14,
fontWeight: 600,
fontFamily: "'Newsreader',Georgia,serif",
textShadow: "0 1px 4px rgba(0,0,0,0.4)",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
wordBreak: "break-word",
}}
>
{notebook.name}
</span>
</div>
<div style={{ display: "flex", gap: 3 }}>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{ width: 2.5, height: 2.5, borderRadius: "50%", background: cover.accent + "55" }}
/>
))}
</div>
</div>
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 3,
background: cover.accent,
opacity: 0.7,
}}
/>
</div>
</div>
<div style={{ textAlign: "center", maxWidth: 156 }}>
<div
style={{
fontSize: 13,
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{notebook.name}
</div>
<div style={{ fontSize: 11, opacity: 0.5, marginTop: 1 }}>
{noteCount} {noteCount === 1 ? "note" : "notes"}
</div>
</div>
</button>
);
}
+273
View File
@@ -0,0 +1,273 @@
import { useMemo, useState } from "react";
import { COVERS } from "./constants.jsx";
export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
const [name, setName] = useState(notebook.name);
const [sel, setSel] = useState(notebook.cover);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
active: dark ? "#388bfd" : "#0969da",
}),
[dark]
);
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
>
<div
style={{
background: T.bg,
borderRadius: 20,
padding: 30,
width: 460,
maxWidth: "92vw",
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
border: `1px solid ${T.border}`,
}}
onClick={(e) => e.stopPropagation()}
>
<h3
style={{
margin: "0 0 22px",
color: T.text,
fontSize: 18,
fontWeight: 600,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
Edit Notebook
</h3>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
style={{
width: "100%",
padding: "10px 14px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: dark ? "#0d1117" : "#f6f8fa",
color: T.text,
fontSize: 14,
outline: "none",
marginBottom: 16,
boxSizing: "border-box",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && name.trim()) onSave({ name: name.trim(), cover: sel });
}}
/>
<div style={{ marginBottom: 22 }}>
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Cover style</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5,1fr)", gap: 8 }}>
{COVERS.map((c, i) => (
<button
key={i}
onClick={() => setSel(i)}
style={{
width: "100%",
aspectRatio: "3/4",
borderRadius: 8,
background: c.bg,
border: sel === i ? `3px solid ${T.active}` : "2px solid transparent",
cursor: "pointer",
position: "relative",
overflow: "hidden",
}}
>
<div
style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 4, background: c.spine }}
/>
</button>
))}
</div>
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button
onClick={onClose}
style={{
padding: "8px 20px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
}}
>
Cancel
</button>
<button
onClick={() => {
if (name.trim()) onSave({ name: name.trim(), cover: sel });
}}
style={{
padding: "8px 20px",
borderRadius: 10,
border: "none",
background: T.active,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
}}
>
Save
</button>
</div>
</div>
</div>
);
}
export function CreateNotebookModal({ onClose, onCreate, dark }) {
const [name, setName] = useState("");
const [sel, setSel] = useState(0);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
active: dark ? "#388bfd" : "#0969da",
}),
[dark]
);
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
>
<div
style={{
background: T.bg,
borderRadius: 20,
padding: 30,
width: 460,
maxWidth: "92vw",
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
border: `1px solid ${T.border}`,
}}
onClick={(e) => e.stopPropagation()}
>
<h3
style={{
margin: "0 0 22px",
color: T.text,
fontSize: 18,
fontWeight: 600,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
New Notebook
</h3>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Notebook name..."
autoFocus
style={{
width: "100%",
padding: "10px 14px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: dark ? "#0d1117" : "#f6f8fa",
color: T.text,
fontSize: 14,
outline: "none",
marginBottom: 16,
boxSizing: "border-box",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && name.trim()) onCreate({ name: name.trim(), cover: sel });
}}
/>
<div style={{ marginBottom: 22 }}>
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Choose cover style</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5,1fr)", gap: 8 }}>
{COVERS.map((c, i) => (
<button
key={i}
onClick={() => setSel(i)}
style={{
width: "100%",
aspectRatio: "3/4",
borderRadius: 8,
background: c.bg,
border: sel === i ? `3px solid ${T.active}` : "2px solid transparent",
cursor: "pointer",
position: "relative",
overflow: "hidden",
}}
>
<div
style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 4, background: c.spine }}
/>
</button>
))}
</div>
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button
onClick={onClose}
style={{
padding: "8px 20px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
}}
>
Cancel
</button>
<button
onClick={() => {
if (name.trim()) onCreate({ name: name.trim(), cover: sel });
}}
style={{
padding: "8px 20px",
borderRadius: 10,
border: "none",
background: T.active,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
}}
>
Create
</button>
</div>
</div>
</div>
);
}
+375
View File
@@ -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>
);
}
+47
View File
@@ -0,0 +1,47 @@
/** Shared visual constants: palette, notebook covers, sidebar folders. */
export const COLORS = [
"#0d1117",
"#e94560",
"#0f3460",
"#533483",
"#e07c24",
"#21bf73",
"#fd5e53",
"#3282b8",
"#ff6b6b",
"#4ecdc4",
"#f7dc6f",
"#bb8fce",
"#85c1e9",
"#f8c471",
"#82e0aa",
"#f1948a",
"#aeb6bf",
"#ffffff",
"#f39c12",
"#000000",
];
export const COVERS = [
{ bg: "linear-gradient(135deg,#0f0c29,#302b63,#24243e)", spine: "#1a1540", accent: "#6c63ff" },
{ bg: "linear-gradient(135deg,#1a1a2e,#16213e,#0f3460)", spine: "#0a1628", accent: "#4fc3f7" },
{ bg: "linear-gradient(135deg,#134e5e,#71b280)", spine: "#0e3d4a", accent: "#a5d6a7" },
{ bg: "linear-gradient(135deg,#8e2de2,#4a00e0)", spine: "#3a0099", accent: "#ce93d8" },
{ bg: "linear-gradient(135deg,#fc4a1a,#f7b733)", spine: "#c43d15", accent: "#ffe082" },
{ bg: "linear-gradient(135deg,#c94b4b,#4b134f)", spine: "#3d0f40", accent: "#f48fb1" },
{ bg: "linear-gradient(135deg,#11998e,#38ef7d)", spine: "#0d7a71", accent: "#b9f6ca" },
{ bg: "linear-gradient(135deg,#373b44,#4286f4)", spine: "#2a2e35", accent: "#90caf9" },
{ bg: "linear-gradient(135deg,#141e30,#243b55)", spine: "#0e1520", accent: "#78909c" },
{ bg: "linear-gradient(135deg,#f12711,#f5af19)", spine: "#c01f0d", accent: "#fff59d" },
{ bg: "linear-gradient(135deg,#2c3e50,#3498db)", spine: "#1e2b38", accent: "#81d4fa" },
{ bg: "linear-gradient(135deg,#0f2027,#203a43,#2c5364)", spine: "#0a171c", accent: "#80cbc4" },
{ bg: "linear-gradient(135deg,#aa4b6b,#6b6b83,#3b8d99)", spine: "#7a3550", accent: "#f48fb1" },
{ bg: "linear-gradient(135deg,#1f1c2c,#928dab)", spine: "#15131f", accent: "#b39ddb" },
{ bg: "linear-gradient(135deg,#654ea3,#eaafc8)", spine: "#4e3c7e", accent: "#f8bbd0" },
];
export const DEFAULT_FOLDERS = [
{ id: "all", name: "All Notes", icon: "📋" },
{ id: "starred", name: "Starred", icon: "⭐" },
{ id: "trash", name: "Trash", icon: "🗑️" },
];
+8
View File
@@ -0,0 +1,8 @@
/** Timestamp shown on note cards and list rows. */
export const fmtDate = (d: string): string =>
new Date(d).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
+469
View File
@@ -0,0 +1,469 @@
/** The app's icon set. Inline SVG so they inherit `currentColor`. */
export const I = {
plus: (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
),
search: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
),
bold: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
</svg>
),
italic: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="19" y1="4" x2="10" y2="4" />
<line x1="14" y1="20" x2="5" y2="20" />
<line x1="15" y1="4" x2="9" y2="20" />
</svg>
),
underline: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3" />
<line x1="4" y1="21" x2="20" y2="21" />
</svg>
),
list: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="4" cy="6" r="1" fill="currentColor" />
<circle cx="4" cy="12" r="1" fill="currentColor" />
<circle cx="4" cy="18" r="1" fill="currentColor" />
</svg>
),
listOrd: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="10" y1="6" x2="21" y2="6" />
<line x1="10" y1="12" x2="21" y2="12" />
<line x1="10" y1="18" x2="21" y2="18" />
<text x="2" y="8" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">
1
</text>
<text x="2" y="14" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">
2
</text>
<text x="2" y="20" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">
3
</text>
</svg>
),
check: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<rect x="3" y="3" width="18" height="18" rx="3" />
<path d="M9 12l2 2 4-4" />
</svg>
),
trash: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
),
star: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
),
starFill: (
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
),
pen: (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M12 19l7-7 3 3-7 7-3-3z" />
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z" />
<path d="M2 2l7.586 7.586" />
<circle cx="11" cy="11" r="2" />
</svg>
),
eraser: (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M20 20H7L3 16l9-9 8 8-4 4" />
<path d="M6 11l8 8" />
</svg>
),
highlight: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M15.5 2.5L22 9l-6 6-7-7 6.5-5.5z" />
<path d="M9 8l-5 5 4 4 5-5" />
<path d="M4 13l-2 7 7-2" />
</svg>
),
undo: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
),
redo: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
),
sun: (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
),
moon: (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
),
menu: (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
),
text: (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<polyline points="4 7 4 4 20 4 20 7" />
<line x1="9" y1="20" x2="15" y2="20" />
<line x1="12" y1="4" x2="12" y2="20" />
</svg>
),
draw: (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
</svg>
),
edit: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
),
move: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
<polyline points="10 17 15 12 10 7" />
<line x1="15" y1="12" x2="3" y2="12" />
</svg>
),
restore: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
),
grip: (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="2" />
<circle cx="15" cy="5" r="2" />
<circle cx="9" cy="12" r="2" />
<circle cx="15" cy="12" r="2" />
<circle cx="9" cy="19" r="2" />
<circle cx="15" cy="19" r="2" />
</svg>
),
logout: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
),
user: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
x: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
),
history: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 3v5h5" />
<path d="M3.05 13A9 9 0 1 0 6 5.3L3 8" />
<path d="M12 7v5l4 2" />
</svg>
),
save: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
),
download: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
),
back: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
),
};
+80
View File
@@ -0,0 +1,80 @@
/**
* The application palette.
*
* Colours live here rather than inline so that a change to, say, the danger red
* happens in one place. Modal components still carry small local palettes of
* their own — their key names mean different things (a modal's `bg` is a raised
* surface, the app's `bg` is the page behind it), so they are deliberately not
* folded in here.
*/
export interface Theme {
bg: string;
sidebar: string;
sidebarActive: string;
card: string;
toolbar: string;
border: string;
borderLight: string;
text: string;
textSec: string;
muted: string;
faint: string;
accent: string;
accentSoft: string;
accentText: string;
danger: string;
success: string;
warning: string;
inputBg: string;
dashBg: string;
}
const DARK: Theme = {
bg: "#0d1117",
sidebar: "#010409",
sidebarActive: "#161b22",
card: "#161b22",
toolbar: "#161b22",
border: "#30363d",
borderLight: "#21262d",
text: "#e6edf3",
textSec: "#c9d1d9",
muted: "#8b949e",
faint: "#484f58",
accent: "#388bfd",
accentSoft: "#388bfd22",
accentText: "#58a6ff",
danger: "#f85149",
success: "#3fb950",
warning: "#d29922",
inputBg: "#0d1117",
dashBg: "#0d1117",
};
const LIGHT: Theme = {
bg: "#f6f8fa",
sidebar: "#ffffff",
sidebarActive: "#eef2ff",
card: "#ffffff",
toolbar: "#ffffff",
border: "#d0d7de",
borderLight: "#e5e7eb",
text: "#1f2328",
textSec: "#424a53",
muted: "#656d76",
faint: "#afb8c1",
accent: "#0969da",
accentSoft: "#0969da18",
accentText: "#0550ae",
danger: "#cf222e",
success: "#1a7f37",
warning: "#9a6700",
inputBg: "#f6f8fa",
dashBg: "#f0f2f5",
};
export const makeTheme = (dark: boolean): Theme => (dark ? DARK : LIGHT);
export const FONT_UI = "'DM Sans',sans-serif";
export const FONT_SERIF = "'Newsreader',Georgia,serif";