added ability to change user info

This commit is contained in:
onysd 2026-08-05 21:19:50 +03:00
parent cddd341bb2
commit 40d49d5e97
22 changed files with 799 additions and 26 deletions

View file

@ -0,0 +1,88 @@
import { ImagePlus, Loader2, Upload, X } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { Alert } from "./ui";
// AccountAvatarModal uploads a new profile photo for an account. It follows
// CreateStickerSetModal's shape (a single reason field + direct execute,
// rather than ActionButton's JSON dry-run/confirm flow) since the payload is
// a multipart file upload, not a plain JSON body.
export function AccountAvatarModal({ userID, onClose, onDone }: { userID: number; onClose: () => void; onDone: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [previewURL, setPreviewURL] = useState("");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!file) {
setPreviewURL("");
return;
}
const url = URL.createObjectURL(file);
setPreviewURL(url);
return () => URL.revokeObjectURL(url);
}, [file]);
async function submit() {
if (!file) {
setError("Choose an image file first.");
return;
}
if (!reason.trim()) {
setError("Please enter an operation reason");
return;
}
setBusy(true);
setError("");
try {
const form = new FormData();
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, user_id: userID }));
form.set("file", file, file.name);
const result = await api.setAccountAvatar(form);
if (result.error) {
setError(result.error);
return;
}
onDone();
onClose();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Change avatar"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Account"}</div>
<h2>{"Change avatar"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
{previewURL ? <img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} /> : <ImagePlus size={22} />}
<span className="gift-file-copy"><span className="gift-field-label">{"New avatar"}</span><strong>{file ? file.name : "Choose a JPEG, PNG, or WebP image"}</strong></span>
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
</label>
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this avatar is being changed"} onChange={(event) => setReason(event.target.value)} /></label>
{error && <Alert>{error}</Alert>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
<button className="btn primary" type="button" onClick={submit} disabled={busy}>
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
{"Upload avatar"}
</button>
</div>
</section>
</div>,
document.body
);
}

View file

@ -39,19 +39,24 @@ export function Avatar({
firstName,
lastName,
username = "",
size = 34
size = 34,
refreshKey
}: {
userID: number;
firstName: string;
lastName: string;
username?: string;
size?: number;
// refreshKey busts the browser's cached image (Cache-Control: max-age=300)
// right after an admin-driven avatar change, so the new photo shows up
// immediately instead of the stale cached one.
refreshKey?: number | string;
}) {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
}, [userID]);
}, [userID, refreshKey]);
if (failed) {
const [from, to] = avatarGradient(userID);
@ -68,7 +73,7 @@ export function Avatar({
return (
<img
className="avatar-photo-img"
src={`/api/accounts/${userID}/avatar`}
src={`/api/accounts/${userID}/avatar${refreshKey !== undefined ? `?v=${encodeURIComponent(String(refreshKey))}` : ""}`}
alt=""
loading="lazy"
style={{ width: size, height: size }}

View file

@ -1,4 +1,4 @@
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
import { AtSign, LifeBuoy, Mail, Palette, Phone, Settings2, Smile, UserRound } from "lucide-react";
import { useEffect, useState } from "react";
import { ActionButton } from "./ActionButton";
import { toInt } from "../lib/format";
@ -47,6 +47,92 @@ export function UsernameAction({ idKey, id, path, current, onDone }: {
);
}
// ProfileNameAction force-sets a user's first and last name.
export function ProfileNameAction({ id, path, currentFirstName, currentLastName, onDone }: {
id: number;
path: string;
currentFirstName: string;
currentLastName: string;
onDone: () => void;
}) {
const [firstName, setFirstName] = useState(currentFirstName);
const [lastName, setLastName] = useState(currentLastName);
return (
<div className="attr-block">
<label className="duration-field">
<span>{"First name"}</span>
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="First name" />
</label>
<label className="duration-field">
<span>{"Last name"}</span>
<input value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Last name" />
</label>
<ActionButton
label={"Set name"}
icon={<UserRound size={15} />}
tone="neutral"
path={path}
payload={() => ({ user_id: id, first_name: firstName.trim(), last_name: lastName.trim() })}
onDone={onDone}
/>
</div>
);
}
// PhoneAction force-sets a user's phone number. The backend rejects a value
// already tied to another account.
export function PhoneAction({ id, path, current, onDone }: {
id: number;
path: string;
current: string;
onDone: () => void;
}) {
const [phone, setPhone] = useState(current);
return (
<div className="attr-block">
<label className="duration-field">
<span>{"Phone number"}</span>
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="15551234567" />
</label>
<ActionButton
label={"Set phone"}
icon={<Phone size={15} />}
tone="warn"
path={path}
payload={() => ({ user_id: id, phone: phone.trim() })}
onDone={onDone}
/>
</div>
);
}
// LoginEmailAction sets, or (left empty) clears, the login/signup email
// factor. The backend rejects an email already tied to another account.
export function LoginEmailAction({ id, path, current, onDone }: {
id: number;
path: string;
current: string;
onDone: () => void;
}) {
const [email, setEmail] = useState(current);
return (
<div className="attr-block">
<label className="duration-field">
<span>{"Login email"}</span>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@example.com (empty clears it)" type="email" />
</label>
<ActionButton
label={email.trim() ? "Set login email" : "Clear login email"}
icon={<Mail size={15} />}
tone="warn"
path={path}
payload={() => ({ user_id: id, email: email.trim() })}
onDone={onDone}
/>
</div>
);
}
// ColorAction sets or clears a name/profile color (Layer 228 peer color).
export function ColorAction({ idKey, id, path, onDone }: {
idKey: IDKey;