The column sat in the middle, so it moved as the window was resized and the start of every line landed somewhere new. It is against the margin now. The toolbar counts pages in the words the PDF reader uses, with a hairline where each ends. Pages are drawn, never stored: the note stays one document, Markdown has no page, and a check asserts none reaches the stored body. The current page is the one under the middle of the window — measuring from the top claimed "page 1" while you stood at the foot of a two-page note, because a window is shorter than a page. The typeface is a per-account setting rather than a per-browser one, because a choice about how your own notes are set should follow you to the phone. The server takes serif, sans or mono and drops anything else; that column is written by a browser, so it is an input like any other. Alignment is a small extension of our own — one attribute, a parse rule, a render rule — so the schema every stored note is validated against stays in files this project owns. Markdown has no alignment: what CommonMark allows is embedded HTML, so non-left blocks travel as `<div align="…">`, the one form most readers still honour, and the importer puts it back on the paragraphs. Left is never written. The full run caught a crash I had put in: PdfWorkspace used `noteFont` without taking it as a prop, which the build cannot see and the type checker does not reach in .jsx. It killed the notes panel beside a PDF, and `offline` failed with it because the whole app was broken. 224 unit tests, 263 browser checks across twelve suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
484 lines
15 KiB
React
484 lines
15 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 chooseFont = async (noteFont) => {
|
||
try {
|
||
onUserUpdate(await sync.updatePreferences({ noteFont }));
|
||
} catch {
|
||
/* the choice is presentation; failing to store it must not block the panel */
|
||
}
|
||
};
|
||
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>
|
||
</>
|
||
)}
|
||
|
||
{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>
|
||
);
|
||
}
|