web: account avatars, member /profile, framed admin panel
- 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.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user