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:
+162
-50
@@ -1,15 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, type AdminPhoto, type PhotoSlot } from './api';
|
||||
import { api, type AdminPhoto, type AdminUser, type PhotoSlot } from './api';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import { Profile } from './ui/Profile';
|
||||
import type { MsgKey } from './i18n/vi';
|
||||
|
||||
// Curating screen: list every contributed photo, upload, delete, and say where
|
||||
// each one may surface on the landing page. It holds no privilege of its own —
|
||||
// the API answers 403 unless the signed-in account is on the ADMIN_EMAILS
|
||||
// allowlist, so this page is only a viewer.
|
||||
// Curating screen: one bordered frame, a left column of what can be managed and
|
||||
// the pane for the chosen section.
|
||||
// Profile — the signed-in account's own picture/email/password
|
||||
// User account — every account: its name, email, picture and contribution count
|
||||
// Pictures — every photo in the system, with the slot that shows it
|
||||
// Close — leaves the frame and goes back to the landing page
|
||||
// The frame holds no privilege of its own — the API answers 403 unless the
|
||||
// signed-in account is on the ADMIN_EMAILS allowlist, so this is only a viewer.
|
||||
// ponytail: no pagination. The upload quota caps the table at a handful of
|
||||
// rows per account; add a page cursor when the strip outgrows one screen.
|
||||
type State = 'loading' | 'guest' | 'forbidden' | 'ready';
|
||||
type Tab = 'profile' | 'users' | 'pictures';
|
||||
|
||||
// The four places a photo can be drawn from. `strip` feeds the community reel;
|
||||
// each live slot shows one photo of its set, picked at random per page load.
|
||||
@@ -20,10 +26,20 @@ const SLOTS: { id: PhotoSlot; key: MsgKey }[] = [
|
||||
{ id: 'qr', key: 'adm.slotQr' },
|
||||
];
|
||||
|
||||
// The left column, in the order it reads.
|
||||
const MENU: { id: Tab; key: MsgKey }[] = [
|
||||
{ id: 'profile', key: 'adm.tabProfile' },
|
||||
{ id: 'users', key: 'adm.tabUsers' },
|
||||
{ id: 'pictures', key: 'adm.tabPictures' },
|
||||
];
|
||||
|
||||
export function Admin() {
|
||||
const { t } = useI18n();
|
||||
const [state, setState] = useState<State>('loading');
|
||||
const [photos, setPhotos] = useState<AdminPhoto[]>([]);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [tab, setTab] = useState<Tab>('pictures');
|
||||
const [owner, setOwner] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const filePick = useRef<HTMLInputElement>(null);
|
||||
@@ -35,8 +51,9 @@ export function Admin() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await api.adminListPhotos();
|
||||
setPhotos(r.photos);
|
||||
const [p, u] = await Promise.all([api.adminListPhotos(), api.adminListUsers()]);
|
||||
setPhotos(p.photos);
|
||||
setUsers(u.users);
|
||||
setState('ready');
|
||||
} catch {
|
||||
setState('forbidden');
|
||||
@@ -61,17 +78,22 @@ export function Admin() {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshPhotos = async () => {
|
||||
const [p, u] = await Promise.all([api.adminListPhotos(), api.adminListUsers()]);
|
||||
setPhotos(p.photos);
|
||||
setUsers(u.users);
|
||||
};
|
||||
|
||||
const upload = (file: File) =>
|
||||
run(async () => {
|
||||
await api.uploadPhoto(file);
|
||||
const r = await api.adminListPhotos();
|
||||
setPhotos(r.photos);
|
||||
await refreshPhotos();
|
||||
});
|
||||
|
||||
const remove = (id: number) =>
|
||||
run(async () => {
|
||||
await api.adminDeletePhoto(id);
|
||||
setPhotos((prev) => prev.filter((p) => p.id !== id));
|
||||
await refreshPhotos();
|
||||
});
|
||||
|
||||
const setSlot = (id: number, slot: PhotoSlot) =>
|
||||
@@ -84,11 +106,18 @@ export function Admin() {
|
||||
if (!window.confirm(t('adm.clearConfirm', { n: photos.length }))) return;
|
||||
return run(async () => {
|
||||
const r = await api.adminClearPhotos();
|
||||
setPhotos([]);
|
||||
await refreshPhotos();
|
||||
setNote(t('adm.removed', { n: r.removed }));
|
||||
});
|
||||
};
|
||||
|
||||
const showOwner = (email: string) => {
|
||||
setOwner(email);
|
||||
setTab('pictures');
|
||||
};
|
||||
|
||||
const shown = owner ? photos.filter((p) => p.email === owner) : photos;
|
||||
|
||||
return (
|
||||
<div className="adm">
|
||||
<header className="adm-bar">
|
||||
@@ -144,49 +173,132 @@ export function Admin() {
|
||||
{state === 'forbidden' ? <p className="err adm-note">{t('adm.notAdmin')}</p> : null}
|
||||
|
||||
{state === 'ready' ? (
|
||||
<>
|
||||
<p className="hint adm-sub">{t('adm.subtitle')}</p>
|
||||
<p className="hint adm-sub">{t('adm.slotHint')}</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{state === 'ready' && photos.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
|
||||
|
||||
{state === 'ready' && photos.length > 0 ? (
|
||||
<div className="adm-grid">
|
||||
{photos.map((p) => (
|
||||
<article className="adm-card" key={p.id} data-key={`adm-photo-${p.id}`}>
|
||||
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
||||
<div className="adm-meta">
|
||||
<b>#{p.id}</b>
|
||||
<span>{t('adm.owner')}: {p.email}</span>
|
||||
<span>{t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()}</span>
|
||||
<span>{t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime}</span>
|
||||
</div>
|
||||
<label className="hint adm-slot">
|
||||
{t('adm.slot')}
|
||||
<select
|
||||
data-key={`adm-slot-${p.id}`}
|
||||
value={p.slot}
|
||||
disabled={busy}
|
||||
onChange={(e) => void setSlot(p.id, e.target.value as PhotoSlot)}
|
||||
>
|
||||
{SLOTS.map((s) => (
|
||||
<option key={s.id} value={s.id}>{t(s.key)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="adm-frame" data-key="adm-frame">
|
||||
<nav className="adm-side">
|
||||
<span className="adm-side-title">{t('adm.settings')}</span>
|
||||
{MENU.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className="btn adm-danger"
|
||||
data-key={`adm-del-${p.id}`}
|
||||
disabled={busy}
|
||||
onClick={() => void remove(p.id)}
|
||||
className={`adm-side-item${tab === entry.id ? ' on' : ''}`}
|
||||
data-key={`adm-nav-${entry.id}`}
|
||||
aria-current={tab === entry.id}
|
||||
onClick={() => setTab(entry.id)}
|
||||
>
|
||||
{t('adm.delete')}
|
||||
{t(entry.key)}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
))}
|
||||
<a className="adm-side-item" data-key="adm-close" href="/">{t('adm.close')}</a>
|
||||
</nav>
|
||||
|
||||
<div className="adm-pane">
|
||||
{tab === 'pictures' ? (
|
||||
<>
|
||||
<p className="hint adm-sub">{t('adm.subtitle')}</p>
|
||||
<p className="hint adm-sub">{t('adm.slotHint')}</p>
|
||||
{owner ? (
|
||||
<p className="hint adm-sub adm-filter">
|
||||
{owner}
|
||||
<button type="button" className="btn" data-key="adm-filter-all" onClick={() => setOwner(null)}>
|
||||
{t('adm.showAll')}
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
{shown.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
|
||||
<div className="adm-grid">
|
||||
{shown.map((p) => (
|
||||
<article className="adm-card" key={p.id} data-key={`adm-photo-${p.id}`}>
|
||||
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
||||
<div className="adm-meta">
|
||||
<b>#{p.id}</b>
|
||||
<span>{t('adm.owner')}: {p.email}</span>
|
||||
<span>{t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()}</span>
|
||||
<span>{t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime}</span>
|
||||
</div>
|
||||
<label className="hint adm-slot">
|
||||
{t('adm.slot')}
|
||||
<select
|
||||
data-key={`adm-slot-${p.id}`}
|
||||
value={p.slot}
|
||||
disabled={busy}
|
||||
onChange={(e) => void setSlot(p.id, e.target.value as PhotoSlot)}
|
||||
>
|
||||
{SLOTS.map((s) => (
|
||||
<option key={s.id} value={s.id}>{t(s.key)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn adm-danger"
|
||||
data-key={`adm-del-${p.id}`}
|
||||
disabled={busy}
|
||||
onClick={() => void remove(p.id)}
|
||||
>
|
||||
{t('adm.delete')}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{tab === 'users' ? (
|
||||
users.length === 0 ? <p className="hint adm-note">{t('adm.noUsers')}</p> : (
|
||||
<div className="adm-table-wrap">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('adm.userPicture')}</th>
|
||||
<th>{t('adm.userName')}</th>
|
||||
<th>{t('adm.userEmail')}</th>
|
||||
<th>{t('adm.userRole')}</th>
|
||||
<th>{t('adm.userPhotos')}</th>
|
||||
<th>{t('adm.userJoined')}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} data-key={`adm-user-${u.id}`}>
|
||||
<td>
|
||||
{/* The account's own picture, straight from the
|
||||
public avatar route — nothing to fetch. */}
|
||||
{u.avatar ? (
|
||||
<img className="adm-face" src={u.avatar} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className="adm-face adm-face-empty">{(u.email[0] ?? '?').toUpperCase()}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{u.email.split('@')[0]}</td>
|
||||
<td>{u.email}</td>
|
||||
<td>
|
||||
<span className={`adm-badge${u.admin ? ' on' : ''}`}>
|
||||
{u.admin ? t('adm.roleAdmin') : t('adm.roleMember')}
|
||||
</span>
|
||||
</td>
|
||||
<td>{u.photos}</td>
|
||||
<td>{new Date(u.createdAt).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key={`adm-user-photos-${u.id}`}
|
||||
onClick={() => showOwner(u.email)}
|
||||
>
|
||||
{t('adm.viewPhotos')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{tab === 'profile' ? <Profile onSaved={(msg) => setNote(msg)} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, type User } from './api';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import { Profile } from './ui/Profile';
|
||||
import { Avatar } from './ui/Avatar';
|
||||
|
||||
// The member's own page: the same form the admin drawer holds, reachable
|
||||
// without the moderation screen. No router — main.tsx picks it by pathname.
|
||||
export function ProfilePage() {
|
||||
const { t } = useI18n();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.me()
|
||||
.then((r) => setUser(r.user))
|
||||
.catch(() => undefined)
|
||||
.finally(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="adm">
|
||||
<header className="adm-bar">
|
||||
<a className="adm-brand" href="/">
|
||||
<img src="/assets/RecipesCamIcon.png" alt="" width="26" height="26" />
|
||||
<span>Recipes<em>Cam</em></span>
|
||||
</a>
|
||||
<b className="adm-title">{t('profile.title')}</b>
|
||||
<span className="adm-spacer" />
|
||||
<Avatar user={user} />
|
||||
<a className="btn ghost" data-key="profile-home" href="/">{t('nav.back')}</a>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
data-key="profile-logout"
|
||||
onClick={async () => {
|
||||
await api.logout().catch(() => undefined);
|
||||
window.location.assign('/');
|
||||
}}
|
||||
>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{note ? <p className="hint adm-note">{note}</p> : null}
|
||||
|
||||
{user ? (
|
||||
<Profile onSaved={setNote} onUser={setUser} />
|
||||
) : loaded ? (
|
||||
<p className="adm-note">
|
||||
<a className="btn primary" href="/?auth=login">{t('nav.login')}</a>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,10 @@ export interface User {
|
||||
// True when the account is on the API's ADMIN_EMAILS allowlist. The server
|
||||
// re-checks it on every admin route; this only drives what the UI offers.
|
||||
admin?: boolean;
|
||||
// A ready-to-use picture URL (`/api/users/<id>/avatar?v=<file>`), or null.
|
||||
// The version segment is the file's own name, so a replacement is never
|
||||
// served from cache.
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
export interface SavedRecipe {
|
||||
@@ -39,6 +43,17 @@ export interface AdminPhoto extends Photo {
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
// One account as /api/admin/users reports it. `avatar` is the ready-made URL
|
||||
// (or null), same shape as on the signed-in user.
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
photos: number;
|
||||
admin: boolean;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
credentials: 'same-origin',
|
||||
@@ -46,12 +61,23 @@ async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
...init,
|
||||
});
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
const body = text ? JSON.parse(text) : {};
|
||||
const body = await readJson(res);
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
// A gateway error (nginx's 502/504 page) arrives as HTML, and the JSON parser's
|
||||
// "Unexpected token '<'" says nothing useful — degrade to the status instead.
|
||||
async function readJson(res: Response): Promise<{ error?: string } & Record<string, unknown>> {
|
||||
const text = await res.text();
|
||||
if (!text) return {};
|
||||
try {
|
||||
return JSON.parse(text) as { error?: string };
|
||||
} catch {
|
||||
return { error: `HTTP ${res.status}` };
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// null user = signed out; the API answers 200 either way.
|
||||
me: () => call<{ user: User | null }>('/auth/me'),
|
||||
@@ -78,16 +104,35 @@ export const api = {
|
||||
headers: { 'content-type': file.type },
|
||||
body: file,
|
||||
});
|
||||
const text = await res.text();
|
||||
const body = text ? JSON.parse(text) : {};
|
||||
const body = await readJson(res);
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
return body as { photo: Photo };
|
||||
return body as unknown as { photo: Photo };
|
||||
},
|
||||
photoUrl: (id: number) => `/api/photos/${id}/file`,
|
||||
|
||||
// Admin only: every account, with how many photos it owns.
|
||||
adminListUsers: () => call<{ users: AdminUser[] }>('/admin/users'),
|
||||
adminListPhotos: () => call<{ photos: AdminPhoto[] }>('/admin/photos'),
|
||||
adminDeletePhoto: (id: number) => call<void>(`/admin/photos/${id}`, { method: 'DELETE' }),
|
||||
adminClearPhotos: () => call<{ removed: number }>('/admin/photos', { method: 'DELETE' }),
|
||||
adminSetPhotoSlot: (id: number, slot: PhotoSlot) =>
|
||||
call<{ id: number; slot: PhotoSlot }>(`/admin/photos/${id}`, { method: 'PATCH', body: JSON.stringify({ slot }) }),
|
||||
|
||||
// Own profile. The API wants `currentPassword` on every edit, even an email-only one.
|
||||
updateProfile: (body: { email?: string; password?: string; currentPassword: string }) =>
|
||||
call<{ user: User }>('/auth/me', { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
|
||||
// The profile picture, raw bytes like a photo. Replacing it deletes the old
|
||||
// file, so the returned User carries a new `?v=` and nothing goes stale.
|
||||
uploadAvatar: async (file: File) => {
|
||||
const res = await fetch('/api/auth/avatar', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': file.type },
|
||||
body: file,
|
||||
});
|
||||
const body = await readJson(res);
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
return body as unknown as { user: User };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -113,6 +113,7 @@ export const en: Dict = {
|
||||
'photo.failed': 'Upload failed: {msg}',
|
||||
|
||||
'nav.admin': 'Admin',
|
||||
'nav.profile': 'Profile',
|
||||
'adm.title': 'Strip moderation',
|
||||
'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.',
|
||||
'adm.upload': 'ADD A PHOTO',
|
||||
@@ -134,6 +135,31 @@ export const en: Dict = {
|
||||
'adm.clearConfirm': 'Delete all {n} contributions? This cannot be undone.',
|
||||
'adm.removed': 'Deleted {n} photos.',
|
||||
'adm.back': 'Back to home',
|
||||
'adm.settings': 'SETTINGS',
|
||||
'adm.tabPictures': 'Pictures',
|
||||
'adm.tabUsers': 'User account',
|
||||
'adm.tabProfile': 'Profile',
|
||||
'adm.close': 'Close',
|
||||
'adm.userPicture': 'Picture',
|
||||
'adm.userName': 'Name',
|
||||
'adm.userEmail': 'Email',
|
||||
'adm.userRole': 'Role',
|
||||
'adm.userPhotos': 'Photos',
|
||||
'adm.userJoined': 'Joined',
|
||||
'adm.roleAdmin': 'Admin',
|
||||
'adm.roleMember': 'Member',
|
||||
'adm.viewPhotos': 'VIEW PHOTOS',
|
||||
'adm.noUsers': 'No accounts yet.',
|
||||
'adm.showAll': 'ALL',
|
||||
'adm.profileHint': 'Picture, email and password of the signed-in account. Changing the email or password needs the current password.',
|
||||
'adm.changeAvatar': 'CHANGE PICTURE',
|
||||
'adm.currentPassword': 'Current password',
|
||||
'adm.newEmail': 'New email',
|
||||
'adm.newPassword': 'New password',
|
||||
'adm.save': 'SAVE',
|
||||
'adm.saved': 'Profile saved.',
|
||||
|
||||
'profile.title': 'My profile',
|
||||
|
||||
'err.noWebgl':
|
||||
'The browser refused a WebGL context — this build needs WebGL to process photos (the CPU path is thousands of times slower).',
|
||||
|
||||
@@ -15,6 +15,7 @@ export const vi = {
|
||||
'nav.language': 'Ngôn ngữ',
|
||||
'nav.back': 'Về trang chủ',
|
||||
'nav.admin': 'Quản trị',
|
||||
'nav.profile': 'Hồ sơ',
|
||||
'nav.guest': 'Khách',
|
||||
|
||||
'act.undo': 'HOÀN TÁC',
|
||||
@@ -137,6 +138,31 @@ export const vi = {
|
||||
'adm.clearConfirm': 'Xoá toàn bộ {n} ảnh đóng góp? Không hoàn tác được.',
|
||||
'adm.removed': 'Đã xoá {n} ảnh.',
|
||||
'adm.back': 'Về trang chủ',
|
||||
'adm.settings': 'CÀI ĐẶT',
|
||||
'adm.tabPictures': 'Ảnh',
|
||||
'adm.tabUsers': 'Người dùng',
|
||||
'adm.tabProfile': 'Hồ sơ',
|
||||
'adm.close': 'Đóng',
|
||||
'adm.userPicture': 'Ảnh',
|
||||
'adm.userName': 'Tên',
|
||||
'adm.userEmail': 'Email',
|
||||
'adm.userRole': 'Quyền',
|
||||
'adm.userPhotos': 'Số ảnh',
|
||||
'adm.userJoined': 'Ngày tạo',
|
||||
'adm.roleAdmin': 'Quản trị',
|
||||
'adm.roleMember': 'Thành viên',
|
||||
'adm.viewPhotos': 'XEM ẢNH',
|
||||
'adm.noUsers': 'Chưa có tài khoản nào.',
|
||||
'adm.showAll': 'TẤT CẢ',
|
||||
'adm.profileHint': 'Ảnh đại diện, email và mật khẩu của tài khoản đang đăng nhập. Đổi email hoặc mật khẩu cần mật khẩu hiện tại.',
|
||||
'adm.changeAvatar': 'ĐỔI ẢNH ĐẠI DIỆN',
|
||||
'adm.currentPassword': 'Mật khẩu hiện tại',
|
||||
'adm.newEmail': 'Email mới',
|
||||
'adm.newPassword': 'Mật khẩu mới',
|
||||
'adm.save': 'LƯU',
|
||||
'adm.saved': 'Đã lưu hồ sơ.',
|
||||
|
||||
'profile.title': 'Hồ sơ của tôi',
|
||||
|
||||
'err.noWebgl':
|
||||
'Trình duyệt không cấp được WebGL — bản dựng này cần WebGL để xử lý ảnh (chạy CPU chậm gấp hàng nghìn lần).',
|
||||
|
||||
@@ -8,12 +8,21 @@ import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { Landing } from './Landing';
|
||||
import { Workspace } from './App';
|
||||
import { Admin } from './Admin';
|
||||
import { ProfilePage } from './ProfilePage';
|
||||
|
||||
// Three routes, no router: the landing page, the workspace and the strip
|
||||
// moderation screen. nginx serves index.html for all of them (SPA fallback),
|
||||
// so this is just a pathname check.
|
||||
// Four routes, no router: the landing page, the workspace, the strip moderation
|
||||
// screen and the member's own profile. nginx serves index.html for all of them
|
||||
// (SPA fallback), so this is just a pathname check.
|
||||
const path = window.location.pathname;
|
||||
const page = path.startsWith('/app') ? <Workspace /> : path.startsWith('/admin') ? <Admin /> : <Landing />;
|
||||
const page = path.startsWith('/app') ? (
|
||||
<Workspace />
|
||||
) : path.startsWith('/admin') ? (
|
||||
<Admin />
|
||||
) : path.startsWith('/profile') ? (
|
||||
<ProfilePage />
|
||||
) : (
|
||||
<Landing />
|
||||
);
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('#root missing');
|
||||
|
||||
|
||||
@@ -337,9 +337,99 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.adm-danger { color: var(--danger); border-color: var(--border); }
|
||||
.adm-danger:hover:not(:disabled) { border-color: var(--danger); }
|
||||
|
||||
/* Settings drawer: the tabs that organise the page, plus the three panels. */
|
||||
/* The management frame: a bordered box whose left column is the menu and whose
|
||||
right side is the pane for the chosen section. */
|
||||
.adm-frame {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-elev);
|
||||
overflow: hidden;
|
||||
}
|
||||
.adm-side {
|
||||
flex: 0 0 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 10px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-sunken);
|
||||
}
|
||||
.adm-side-title {
|
||||
padding: 0 8px 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.adm-side-item {
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adm-side-item:hover { background: var(--bg-elev); }
|
||||
.adm-side-item.on { background: var(--accent); color: var(--accent-fg); font-weight: 600; }
|
||||
.adm-pane { flex: 1 1 auto; min-width: 0; padding: 14px 16px; }
|
||||
.adm-table-wrap { overflow-x: auto; }
|
||||
.adm-face {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-sunken);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.adm-face img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.adm-filter { display: flex; align-items: center; gap: 8px; }
|
||||
.adm-form { display: flex; flex-direction: column; gap: 8px; max-width: 360px; }
|
||||
.field-label { font-size: 11.5px; color: var(--text-dim); }
|
||||
|
||||
/* --- profile picture ---------------------------------------------------- */
|
||||
/* One face for every control that names the signed-in user: the workspace
|
||||
TopBar, the landing chip and the profile form. No picture = initial. */
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-sunken);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.avatar-sm { width: 20px; height: 20px; font-size: 10px; }
|
||||
.avatar-row { display: flex; align-items: center; gap: 10px; }
|
||||
.avatar-row .avatar { width: 46px; height: 46px; font-size: 17px; }
|
||||
.account-btn { display: inline-flex; align-items: center; gap: 7px; }
|
||||
|
||||
/* --- responsive --------------------------------------------------------- */
|
||||
@media (max-width: 860px) {
|
||||
.workspace { flex-direction: column; }
|
||||
/* The frame's left column becomes a wrapping row above the pane. */
|
||||
.adm-frame { flex-direction: column; }
|
||||
.adm-side { flex: 0 0 auto; flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
/* The header wraps on narrow screens, so a menu-wrap can land at the start of
|
||||
a row — the popover is right-anchored to its button and would hang off the
|
||||
left of the screen (measured: x=-280 of 390px). Anchor it to the full-width
|
||||
|
||||
@@ -142,10 +142,26 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
|
||||
.lp-theme-pop .chip { background: transparent; border-color: var(--lp-line); color: var(--lp-muted); }
|
||||
.lp-theme-pop .chip:hover { color: var(--lp-text); }
|
||||
.lp-theme-pop .chip.on { color: var(--lp-ink-amber); border-color: var(--lp-ink-amber); background: rgba(251, 191, 36, 0.12); }
|
||||
/* The account chip: a face + a name, opening the two things that account can
|
||||
do — its page (Admin / Profile) and log out. */
|
||||
.lp-account { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.lp-account .avatar { border-color: var(--lp-line); background: rgba(255, 255, 255, 0.06); color: var(--lp-muted); }
|
||||
.lp-acc-pop .hint { color: var(--lp-muted); }
|
||||
.lp-acc-pop .btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border-color: var(--lp-line);
|
||||
color: var(--lp-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.lp-acc-pop .btn:hover { color: var(--lp-text); border-color: rgba(251, 191, 36, 0.5); }
|
||||
.lp-burger { display: none; }
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.lp-nav-links, .lp-nav .lp-wrap > .lp-btn { display: none; }
|
||||
.lp-nav-links, .lp-nav .lp-wrap > .lp-btn, .lp-nav .lp-account { display: none; }
|
||||
.lp-burger {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { User } from '../api';
|
||||
|
||||
// The signed-in user's picture, wherever their name is shown: the workspace
|
||||
// TopBar, the landing chip, the profile form. Falls back to the email's first
|
||||
// letter, so an account without a picture still has a face.
|
||||
export function Avatar({ user, small }: { user: User | null; small?: boolean }) {
|
||||
const url = user?.avatar ?? null;
|
||||
return (
|
||||
<span className={`avatar${small ? ' avatar-sm' : ''}`} data-key="avatar">
|
||||
{url ? <img src={url} alt="" /> : (user?.email[0] ?? '?').toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useI18n, LANGS } from '../i18n/I18nProvider';
|
||||
import { ACCENTS, FONT_SETS, useTheme } from '../theme/ThemeProvider';
|
||||
import { Avatar } from './Avatar';
|
||||
import type { User } from '../api';
|
||||
|
||||
// Header: the app's TopBar minus everything camera-only, plus the two controls
|
||||
@@ -123,18 +124,35 @@ export function TopBar({
|
||||
</div>
|
||||
|
||||
<div className="menu-wrap">
|
||||
<button type="button" className="btn ghost" onClick={() => setMenu(menu === 'account' ? null : 'account')}>
|
||||
{user ? user.email.split('@')[0] : t('nav.guest')}
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost account-btn"
|
||||
data-key="nav-account"
|
||||
aria-expanded={menu === 'account'}
|
||||
onClick={() => setMenu(menu === 'account' ? null : 'account')}
|
||||
>
|
||||
{user ? (
|
||||
<>
|
||||
<Avatar user={user} small />
|
||||
{user.email.split('@')[0]}
|
||||
</>
|
||||
) : (
|
||||
t('nav.guest')
|
||||
)}
|
||||
</button>
|
||||
{menu === 'account' ? (
|
||||
<div className="popover">
|
||||
{user ? (
|
||||
<>
|
||||
<p className="hint">{t('auth.loggedInAs', { email: user.email })}</p>
|
||||
{/* The account control opens the one page that account can act
|
||||
on: moderation for an admin, its own profile for a member. */}
|
||||
{user.admin ? (
|
||||
<a className="btn" data-key="nav-admin" href="/admin">{t('nav.admin')}</a>
|
||||
) : null}
|
||||
<button type="button" className="btn" onClick={onLogout}>{t('nav.logout')}</button>
|
||||
) : (
|
||||
<a className="btn" data-key="nav-profile" href="/profile">{t('nav.profile')}</a>
|
||||
)}
|
||||
<button type="button" className="btn" data-key="nav-logout" onClick={onLogout}>{t('nav.logout')}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user