Files
InkNote/frontend/src/ui/AccountMenu.jsx
T
marcandClaude Opus 5 2611d87f8c Export notes as Markdown or PDF
The reason to build this is not a missing feature — it is that trusting a
program you maintain alone with years of notes should not mean the notes
can only ever be read by that program.

Reachable from a note's toolbar, a notebook's context menu, and Account →
Alle Notizen exportieren.

Markdown carries over everything with an equivalent: headings, lists,
quotes, code, tables, and task state as `- [x]`. A link to another note in
the same export becomes a relative link to its file; a link to a note
outside it degrades to the plain title rather than leaving a dead
reference. Notes with images or a drawing arrive as a ZIP so the files
travel along, and a multi-note export gets a README index.

PDF goes through the browser's print dialog rather than a bundled PDF
library — the browser already has a good one, and it is the only format
where drawings sit inside the document. Markdown gets them as a PNG
beside the text, with a line saying so, because dropping them silently
would be the wrong kind of quiet.

Two decisions worth recording:

- the ZIP writer is eighty lines here rather than a dependency. Note text
  is small and images are already compressed, so compression bought
  nothing, and stored entries are a handful of fixed-layout records.
- Markdown escaping only covers what changes meaning mid-line, plus a
  leading list or heading marker. Escaping every character Markdown ever
  uses turns a sentence into `Hallo\. Alles gut\!`, which defeats the
  point of choosing a readable format.

42 unit tests on the serialiser and the archive format, plus a browser
suite that unpacks a real download and reads the bytes back.

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

439 lines
13 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 [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} Alle Notizen exportieren
</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>
);
}