6bbf77860b
- an account can carry a picture: POST /api/auth/avatar (raw bytes, sniffed, replaces and unlinks the old file) and the public GET /api/users/:id/avatar. It rides wherever the account is named — the landing chip, the studio TopBar, the profile form. - new /profile page for members, sharing one Profile form (picture, email, password) with the admin drawer. - /admin is now one bordered frame whose left column is Profile / User account / Pictures / Close. Pictures lists every photo in the system with the slot that shows it; User account lists each account's name, email, picture and contribution count. - account control opens a menu: Admin page + Log out for an admin, Profile + Log out for a member.
131 lines
3.9 KiB
TypeScript
131 lines
3.9 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { api, type User } from '../api';
|
|
import { useI18n } from '../i18n/I18nProvider';
|
|
import { Avatar } from './Avatar';
|
|
|
|
// The signed-in account editing itself: picture, email, password. The API
|
|
// re-verifies the current password and the login limiter caps guessing, so this
|
|
// form holds no secret. Shared by the /admin settings drawer and /profile.
|
|
export function Profile({ onSaved, onUser }: { onSaved: (note: string) => void; onUser?: (user: User) => void }) {
|
|
const { t } = useI18n();
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [email, setEmail] = useState('');
|
|
const [current, setCurrent] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const filePick = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
api.me().then((r) => {
|
|
setUser(r.user);
|
|
setEmail(r.user?.email ?? '');
|
|
}).catch(() => undefined);
|
|
}, []);
|
|
|
|
const keep = (u: User) => {
|
|
setUser(u);
|
|
setEmail(u.email);
|
|
onUser?.(u);
|
|
};
|
|
|
|
const pick = async (file: File) => {
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
keep((await api.uploadAvatar(file)).user);
|
|
onSaved(t('adm.saved'));
|
|
} catch (e2) {
|
|
setErr(e2 instanceof Error ? e2.message : String(e2));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const submit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
const r = await api.updateProfile({
|
|
email: email.trim(),
|
|
currentPassword: current,
|
|
...(password ? { password } : {}),
|
|
});
|
|
keep(r.user);
|
|
setPassword('');
|
|
setCurrent('');
|
|
onSaved(t('adm.saved'));
|
|
} catch (e2) {
|
|
setErr(e2 instanceof Error ? e2.message : String(e2));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form className="adm-form" data-key="adm-profile" onSubmit={submit}>
|
|
<p className="hint">{t('adm.profileHint')}</p>
|
|
<div className="avatar-row">
|
|
<Avatar user={user} />
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
data-key="adm-avatar-pick"
|
|
disabled={busy}
|
|
onClick={() => filePick.current?.click()}
|
|
>
|
|
{t('adm.changeAvatar')}
|
|
</button>
|
|
<input
|
|
ref={filePick}
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/webp"
|
|
hidden
|
|
data-key="adm-avatar-file"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = '';
|
|
if (file) void pick(file);
|
|
}}
|
|
/>
|
|
</div>
|
|
<label className="field-label" htmlFor="adm-prof-email">{t('adm.newEmail')}</label>
|
|
<input
|
|
id="adm-prof-email"
|
|
className="field"
|
|
type="email"
|
|
required
|
|
data-key="adm-profile-email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
<label className="field-label" htmlFor="adm-prof-current">{t('adm.currentPassword')}</label>
|
|
<input
|
|
id="adm-prof-current"
|
|
className="field"
|
|
type="password"
|
|
required
|
|
data-key="adm-profile-current"
|
|
value={current}
|
|
onChange={(e) => setCurrent(e.target.value)}
|
|
/>
|
|
<label className="field-label" htmlFor="adm-prof-password">{t('adm.newPassword')}</label>
|
|
<input
|
|
id="adm-prof-password"
|
|
className="field"
|
|
type="password"
|
|
minLength={8}
|
|
placeholder="••••••••"
|
|
data-key="adm-profile-password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
{err ? <p className="err">{err}</p> : null}
|
|
<button type="submit" className="btn primary" data-key="adm-profile-save" disabled={busy}>
|
|
{busy ? t('auth.busy') : t('adm.save')}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|