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:
2026-09-18 08:19:36 +07:00
parent 2917c034ed
commit 6bbf77860b
15 changed files with 908 additions and 79 deletions
+47 -9
View File
@@ -3,6 +3,7 @@ import { api, type Photo, type PhotoSlot, type User } from './api';
import { useI18n } from './i18n/I18nProvider';
import { FONT_SETS, useTheme } from './theme/ThemeProvider';
import { AuthModal } from './ui/AuthModal';
import { Avatar } from './ui/Avatar';
// Landing page, built to the landing blueprint: the 10-section funnel. The
// marketing prose is bilingual and lives here next to the markup as `Txt`
@@ -104,7 +105,9 @@ export function Landing() {
const { mode, fonts, setMode, setFonts } = useTheme();
const [menu, setMenu] = useState(false);
const [themeMenu, setThemeMenu] = useState(false);
const [accountMenu, setAccountMenu] = useState(false);
const tools = useRef<HTMLSpanElement>(null);
const account = useRef<HTMLSpanElement>(null);
const [presetId, setPresetId] = useState(PRESETS[0].id);
const [temp, setTemp] = useState(0);
const [sat, setSat] = useState(100);
@@ -199,6 +202,23 @@ export function Landing() {
return () => document.removeEventListener('mousedown', away);
}, [themeMenu]);
// The account popover closes the same way, and signing out puts the chip back
// to its signed-out state without a reload.
useEffect(() => {
if (!accountMenu) return;
const away = (e: MouseEvent) => {
if (!account.current?.contains(e.target as Node)) setAccountMenu(false);
};
document.addEventListener('mousedown', away);
return () => document.removeEventListener('mousedown', away);
}, [accountMenu]);
const signOut = useCallback(async () => {
await api.logout().catch(() => undefined);
setUser(null);
setAccountMenu(false);
}, []);
// The simulator's output: three knobs, one CSS filter string, applied live.
const labFilter = `sepia(${(Math.abs(temp) / 260).toFixed(3)}) hue-rotate(${(-temp * 0.45).toFixed(1)}deg) saturate(${sat}%) contrast(${con}%)`;
@@ -294,14 +314,32 @@ export function Landing() {
signed in the same slot names the account, so the session is
visible on the page that took the credentials. */}
{user ? (
<a
className="lp-btn lp-sm"
data-key="nav-account"
href={user.admin ? '/admin' : '/app'}
title={user.email}
>
{user.email.split('@')[0]}
</a>
<span className="menu-wrap" ref={account}>
<button
type="button"
className="lp-btn lp-sm lp-account"
data-key="nav-account"
title={user.email}
aria-expanded={accountMenu}
onClick={() => setAccountMenu((v) => !v)}
>
<Avatar user={user} small />
{user.email.split('@')[0]}
</button>
{accountMenu ? (
<div className="popover lp-theme-pop lp-acc-pop">
<p className="hint">{user.email}</p>
{user.admin ? (
<a className="btn" data-key="nav-admin" href="/admin">{t('nav.admin')}</a>
) : (
<a className="btn" data-key="nav-profile" href="/profile">{t('nav.profile')}</a>
)}
<button type="button" className="btn" data-key="nav-logout" onClick={signOut}>
{t('nav.logout')}
</button>
</div>
) : null}
</span>
) : (
<button type="button" className="lp-btn lp-sm" data-key="nav-login" onClick={() => setAuth('login')}>
{t('nav.login')}
@@ -319,7 +357,7 @@ export function Landing() {
{nav.map((entry) => <a key={entry.href} href={entry.href} onClick={() => setMenu(false)}>{c(entry.label)}</a>)}
<a href="/app" onClick={() => setMenu(false)}>{c({ en: 'Open studio', vi: 'Mở studio' })}</a>
{user ? (
<a className="lp-btn" data-key="sheet-account" href={user.admin ? '/admin' : '/app'} onClick={() => setMenu(false)}>
<a className="lp-btn" data-key="sheet-account" href={user.admin ? '/admin' : '/profile'} onClick={() => setMenu(false)}>
{user.email.split('@')[0]}
</a>
) : (