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:
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user