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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: "🗑️" },
|
||||
];
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user