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>
527 lines
17 KiB
React
527 lines
17 KiB
React
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>
|
||
);
|
||
}
|