Files
InkNote/frontend/src/ui/AccountMenu.jsx
T
marcandClaude Opus 5 d74fea4905 Chapters as rows, in two views, in their own colours
Three things Marc asked for, and one of them turned out to be the hinge
the other two hang from.

**A chapter is a row like any other.** Same grip, same drag, same place
in a hand-made arrangement as a note or a separator. That needed notes
and chapters numbered from *one* counter: numbered separately they are
two sequences that cannot be interleaved again, and a chapter dragged
between two notes springs back to the top. Positions therefore have to be
named rather than read off an array index, so POST /notes/reorder now
takes `positions: [{id, order}]` beside the older `ids`, and chapters
have a reorder route of their own.

**Two ways for a chapter to open**, chosen per account beside the
typeface, for the same reason that one lives there: it is a choice about
how your own notes are laid out and it should follow you to the phone.
Beside the list, its notes in a further column the way OneNote lays out
pages — the notebook's own list carrying on to the left, chapter rows and
all. Or unfolded under the chapter's own row, indented against a rule in
its colour. A phone has room for neither and fills the list instead, with
a way back, which is what it did before there was a choice.

**Its own colour**, from eight named ones rather than the full palette:
the point of chapters is that they are different places, and twenty
near-identical blues do not help anyone tell them apart.

The colour is validated on the way in — it is written by a browser, so
only a plain #rrggbb gets through and anything else becomes no colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:20:14 +02:00

527 lines
17 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from "react";
import * as sync from "../syncEngine";
import { I } from "./icons.jsx";
/* ═══════ ACCOUNT MENU ═══════ */
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) {
const choosePreference = async (change) => {
try {
onUserUpdate(await sync.updatePreferences(change));
} catch {
/* the choice is presentation; failing to store it must not block the panel */
}
};
const chooseFont = (noteFont) => choosePreference({ noteFont });
const chooseChapterView = (chapterView) => choosePreference({ chapterView });
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>
{/* Kept on the account rather than in this browser: a choice about
how your own notes are set should follow you to the phone, not
be made again on every device. The export is untouched — this
is presentation and nothing else. */}
<div style={{ marginTop: 22 }}>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 6, display: "block" }}>
Note typeface
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
{[
["serif", "Serif", "'Newsreader',Georgia,serif"],
["sans", "Sans", "'DM Sans',system-ui,sans-serif"],
["mono", "Mono", "ui-monospace,Menlo,Consolas,monospace"],
].map(([value, label, stack]) => {
const active = (user.preferences?.noteFont || "serif") === value;
return (
<button
key={value}
data-note-font={value}
onClick={() => chooseFont(value)}
style={{
padding: "10px 6px",
borderRadius: 10,
border: `1.5px solid ${active ? T.accent : T.border}`,
background: active ? T.accent + "15" : "transparent",
color: active ? T.accent : T.text,
cursor: "pointer",
fontFamily: stack,
fontSize: 13,
}}
>
{label}
</button>
);
})}
</div>
</div>
{/* How a chapter opens. Kept beside the typeface because both are
the same kind of thing: how your own notes are laid out, stored
on the account so the answer follows you to the phone. */}
<div style={{ marginTop: 18 }}>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 6, display: "block" }}>
Chapters open
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8 }}>
{[
["column", "Beside the list", "Its notes in a further column, the way OneNote lays out pages"],
["inline", "Under the chapter", "Its notes unfold in place, below the chapter's own row"],
].map(([value, label, hint]) => {
const active = (user.preferences?.chapterView || "column") === value;
return (
<button
key={value}
data-chapter-view={value}
onClick={() => chooseChapterView(value)}
title={hint}
style={{
padding: "10px 8px",
borderRadius: 10,
border: `1.5px solid ${active ? T.accent : T.border}`,
background: active ? T.accent + "15" : "transparent",
color: active ? T.accent : T.text,
cursor: "pointer",
fontSize: 12.5,
textAlign: "left",
lineHeight: 1.35,
}}
>
{label}
</button>
);
})}
</div>
<div style={{ fontSize: 11, color: T.muted, marginTop: 6, lineHeight: 1.5 }}>
A phone has room for neither, and fills the screen either way — with a way back.
</div>
</div>
</>
)}
{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={onExportAll}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "10px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
cursor: "pointer",
color: T.muted,
fontSize: 13,
fontWeight: 600,
justifyContent: "center",
marginBottom: 8,
}}
>
{I.export} Export all notes
</button>
{user?.isAdmin && (
<button
onClick={onOpenAdmin}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "10px",
borderRadius: 10,
border: `1px solid ${T.accent}33`,
background: T.accent + "0c",
cursor: "pointer",
color: T.accent,
fontSize: 13,
fontWeight: 600,
justifyContent: "center",
marginBottom: 8,
}}
>
{I.users} Administration
</button>
)}
<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>
);
}