It was a page-shaped box that was not page-coloured: the same fill as everything around it with a hairline border, so it read as an empty panel. Now it is warm off-white in daylight and a raised surface at night — deliberately not a white rectangle, since a page that glows brighter than the room is what makes an app unusable in the evening. The line ran to 84 characters, past the width at which the eye reliably finds the start of the next line, and that was the single biggest reason the surface read badly. Measured, not guessed: 620px of sheet with a 56px margin gives about 73. It is centred now — it used to sit 28px from the left and stop short of the right — with almost square corners instead of a dialog's 14px radius. Blocks on the sheet are tinted from paper rather than from the app background, or they read as sitting underneath it. Five German strings survived the earlier pass, "Alle Notizen exportieren" among them: that sweep filtered on umlauts, so sentences without one went straight through. Swept again by word. 218 unit tests, 244 browser checks across eleven suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
439 lines
13 KiB
React
439 lines
13 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 [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={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>
|
||
);
|
||
}
|