764 lines
32 KiB
TypeScript
764 lines
32 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
import { api, type AdminPhoto, type AdminUser, type PhotoSlot, type Rating } from './api';
|
|
import { useI18n } from './i18n/I18nProvider';
|
|
import { Profile } from './ui/Profile';
|
|
import { QrCode } from './ui/QrCode';
|
|
import { Stats } from './ui/Stats';
|
|
import type { MsgKey } from './i18n/vi';
|
|
|
|
// 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 — two columns of albums: the uploads on the left, one album
|
|
// per account; the landing on the right, one album per
|
|
// section. Every frame carries its own labels, section boxes,
|
|
// QR code and delete button under its preview
|
|
// Stats — the visitor counter: views, clicks and their breakdowns
|
|
// Backup — the data dir out as one .tar.gz, and the route that puts a
|
|
// .tar.gz back (the API replaces its data and restarts)
|
|
// 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' | 'stats' | 'backup';
|
|
|
|
// Where a photo can be put. The boxes are independent — a photo may sit in all
|
|
// three sections at once, and each section draws one random photo per visit out
|
|
// of its own set. Unticking every box takes the photo off the landing without
|
|
// deleting the uploader's row, which is the same switch the old picker carried.
|
|
const DESTINATIONS: { id: PhotoSlot; key: MsgKey }[] = [
|
|
{ id: 'strip', key: 'adm.pickStrip' },
|
|
{ id: 'tester', key: 'adm.pickTester' },
|
|
{ id: 'creator', key: 'adm.pickCreator' },
|
|
{ id: 'qr', key: 'adm.pickQr' },
|
|
];
|
|
|
|
// Column 2 browses these as albums. QR is not one of them: its code belongs to
|
|
// the frame, so it is drawn under every preview instead of in a shelf of its own.
|
|
const SECTIONS = DESTINATIONS.filter((d) => d.id !== 'qr');
|
|
|
|
// 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' },
|
|
{ id: 'stats', key: 'adm.tabStats' },
|
|
{ id: 'backup', key: 'adm.tabBackup' },
|
|
];
|
|
|
|
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');
|
|
// The two album browsers: which account's uploads are open on the left, which
|
|
// landing section is open on the right, and the filters both columns obey.
|
|
const [album, setAlbum] = useState<number | 'all'>('all');
|
|
const [section, setSection] = useState<PhotoSlot>('strip');
|
|
// Each column previews its own frame; both start on the newest of their album.
|
|
const [selUploads, setSelUploads] = useState<number | null>(null);
|
|
const [selSections, setSelSections] = useState<number | null>(null);
|
|
const [nameQuery, setNameQuery] = useState('');
|
|
const [sort, setSort] = useState<'newest' | 'recipe' | 'rating'>('newest');
|
|
const [minStars, setMinStars] = useState(0);
|
|
const [ratings, setRatings] = useState<Record<string, Rating>>({});
|
|
const [busy, setBusy] = useState(false);
|
|
const [note, setNote] = useState<string | null>(null);
|
|
const [picked, setPicked] = useState<number[]>([]);
|
|
const filePick = useRef<HTMLInputElement>(null);
|
|
const restorePick = useRef<HTMLInputElement>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
const me = await api.me().catch(() => null);
|
|
if (!me?.user) {
|
|
setState('guest');
|
|
return;
|
|
}
|
|
try {
|
|
const [p, u, r] = await Promise.all([api.adminListPhotos(), api.adminListUsers(), api.ratings()]);
|
|
setPhotos(p.photos);
|
|
setUsers(u.users);
|
|
setRatings(r.ratings);
|
|
setState('ready');
|
|
} catch {
|
|
setState('forbidden');
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
// Every action reports its own failure into `note` and never throws: one bad
|
|
// upload must not take the whole screen down.
|
|
const run = async (fn: () => Promise<void>) => {
|
|
setBusy(true);
|
|
setNote(null);
|
|
try {
|
|
await fn();
|
|
} catch (err) {
|
|
setNote(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const refreshPhotos = async () => {
|
|
const [p, u, r] = await Promise.all([api.adminListPhotos(), api.adminListUsers(), api.ratings()]);
|
|
setPhotos(p.photos);
|
|
setUsers(u.users);
|
|
setRatings(r.ratings);
|
|
};
|
|
|
|
const upload = (file: File) =>
|
|
run(async () => {
|
|
await api.uploadPhoto(file);
|
|
await refreshPhotos();
|
|
});
|
|
|
|
const remove = (id: number) =>
|
|
run(async () => {
|
|
await api.adminDeletePhoto(id);
|
|
await refreshPhotos();
|
|
});
|
|
|
|
// One checkbox tick: the whole new set goes to the API in one call. The box
|
|
// flips under the curator's finger first — waiting for the round trip would
|
|
// snap it back for a frame and read as a lost click — and a failed write puts
|
|
// the old set back.
|
|
const setSlots = (id: number, next: PhotoSlot[]) =>
|
|
run(async () => {
|
|
const before = photos.find((p) => p.id === id)?.slots ?? [];
|
|
const show = (slots: PhotoSlot[]) => setPhotos((prev) => prev.map((p) => (p.id === id ? { ...p, slots } : p)));
|
|
show(next);
|
|
try {
|
|
await api.adminSetPhotoSlots(id, next);
|
|
} catch (err) {
|
|
show(before);
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
const clear = () => {
|
|
if (!window.confirm(t('adm.clearConfirm', { n: photos.length }))) return;
|
|
return run(async () => {
|
|
const r = await api.adminClearPhotos();
|
|
await refreshPhotos();
|
|
setNote(t('adm.removed', { n: r.removed }));
|
|
});
|
|
};
|
|
|
|
// The one action here that replaces everything. The API swaps its data on
|
|
// disk and exits; the container's restart policy brings it back. So the page
|
|
// waits for the API to answer again instead of showing a listing that is no
|
|
// longer what the server holds — and the wait is bounded, because a restore
|
|
// that never comes back is a thing the operator needs told.
|
|
const restore = (file: File) => {
|
|
if (!window.confirm(t('adm.restoreConfirm', { file: file.name }))) return;
|
|
return run(async () => {
|
|
await api.adminRestore(file);
|
|
setNote(t('adm.restoreRunning'));
|
|
for (let i = 0; i < 90; i += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
try {
|
|
await api.me();
|
|
await refreshPhotos();
|
|
setNote(t('adm.restoreDone'));
|
|
return;
|
|
} catch {
|
|
// Still down for its restart; keep waiting.
|
|
}
|
|
}
|
|
setNote(t('adm.restoreSlow'));
|
|
});
|
|
};
|
|
|
|
// From the users table: open that account's album in the left column.
|
|
const showOwner = (userId: number) => {
|
|
setTab('pictures');
|
|
setAlbum(userId);
|
|
};
|
|
|
|
// Block/unblock, remove/restore and the PRO grant share one route; the list
|
|
// is reloaded because any of the flags also changes what the built-in viewer
|
|
// would show.
|
|
const setUserState = (id: number, patch: { blocked?: boolean; removed?: boolean; pro?: boolean }) =>
|
|
run(async () => {
|
|
await api.adminSetUser(id, patch);
|
|
await refreshPhotos();
|
|
});
|
|
|
|
const deleteUser = (u: AdminUser) => {
|
|
if (!window.confirm(t('adm.userDeleteConfirm', { email: u.email }))) return;
|
|
return run(async () => {
|
|
await api.adminDeleteUser(u.id);
|
|
await refreshPhotos();
|
|
});
|
|
};
|
|
|
|
// The users table's bulk actions. An admin account is the API's own privilege
|
|
// source, so it is never a moderation target: select-all skips it and a stray
|
|
// id is filtered out before the deletes run.
|
|
const selectable = users.filter((u) => !u.admin).map((u) => u.id);
|
|
const allPicked = selectable.length > 0 && selectable.every((id) => picked.includes(id));
|
|
const togglePicked = (id: number) =>
|
|
setPicked((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
|
const deleteSelected = () => {
|
|
const ids = picked.filter((id) => selectable.includes(id));
|
|
if (ids.length === 0) return;
|
|
if (!window.confirm(t('adm.userDeleteSelectedConfirm', { n: ids.length }))) return;
|
|
return run(async () => {
|
|
for (const id of ids) await api.adminDeleteUser(id);
|
|
setPicked([]);
|
|
await refreshPhotos();
|
|
});
|
|
};
|
|
|
|
// A frame's score, from the same public tally the landing draws. Whitespace
|
|
// in the name box is not a filter, so `query` is trimmed once for both uses.
|
|
const score = (p: AdminPhoto) => ratings[`photo:${p.id}`]?.avg ?? 0;
|
|
const query = nameQuery.trim().toLowerCase();
|
|
|
|
// Albums: one per account that owns a photo, alphabetical so the column reads
|
|
// as a list of names. The name box narrows the column, not the strip.
|
|
const albums = useMemo(() => {
|
|
const map = new Map<number, { userId: number; email: string; n: number }>();
|
|
for (const p of photos) {
|
|
const a = map.get(p.userId) ?? { userId: p.userId, email: p.email, n: 0 };
|
|
a.n += 1;
|
|
map.set(p.userId, a);
|
|
}
|
|
return [...map.values()].sort((a, b) => a.email.localeCompare(b.email));
|
|
}, [photos]);
|
|
const shownAlbums = query ? albums.filter((a) => a.email.toLowerCase().includes(query)) : albums;
|
|
|
|
// What an album draws: the frames the name box kept, minus anything under the
|
|
// rating bar, in the chosen order. Both columns run it, so one frame sits
|
|
// under the same rules on either side.
|
|
const arrange = useCallback(
|
|
(base: AdminPhoto[]) => {
|
|
const named = base.filter((p) => !query || p.email.toLowerCase().includes(query));
|
|
const sorted = [...(minStars > 0 ? named.filter((p) => score(p) >= minStars) : named)];
|
|
if (sort === 'recipe') sorted.sort((a, b) => (a.recipeName ?? '').localeCompare(b.recipeName ?? ''));
|
|
else if (sort === 'rating') sorted.sort((a, b) => score(b) - score(a));
|
|
else sorted.sort((a, b) => b.id - a.id);
|
|
return sorted;
|
|
// `score` reads `ratings`, which is in the deps.
|
|
},
|
|
[query, minStars, sort, ratings],
|
|
);
|
|
|
|
const albumPhotos = useMemo(
|
|
() => arrange(album === 'all' ? photos : photos.filter((p) => p.userId === album)),
|
|
[arrange, album, photos],
|
|
);
|
|
const sectionPhotos = useMemo(
|
|
() => arrange(photos.filter((p) => p.slots.includes(section))),
|
|
[arrange, photos, section],
|
|
);
|
|
|
|
// A column: the album shelf down the left, the frame that is up in the middle
|
|
// — its picture, then everything a curator can do to it (labels, the four
|
|
// section boxes, the look's own QR code, the delete button) under the picture
|
|
// — and the open album's thumbnail strip across the bottom.
|
|
const column = (
|
|
title: string,
|
|
key: string,
|
|
list: AdminPhoto[],
|
|
sel: number | null,
|
|
setSel: (id: number) => void,
|
|
shelf: ReactNode,
|
|
) => {
|
|
const up = list.find((p) => p.id === sel) ?? list[0] ?? null;
|
|
return (
|
|
<div className="adm-col" data-key={`adm-col-${key}`}>
|
|
<h3 className="adm-row-title">
|
|
{title}
|
|
<span className="adm-row-count">{list.length}</span>
|
|
</h3>
|
|
<div className="adm-split">
|
|
<div className="adm-album-list" data-key={`adm-albums-${key}`}>
|
|
{shelf}
|
|
</div>
|
|
<section className="adm-stage" data-key={`adm-stage-${key}`}>
|
|
{up ? (
|
|
<>
|
|
<img className="adm-stage-img" data-key={`adm-stage-img-${key}`} src={api.photoUrl(up.id)} alt="" />
|
|
<div className="adm-meta">
|
|
<b>#{up.id}</b>
|
|
{up.tag ? <span>{up.tag}</span> : null}
|
|
{up.title ? <span>{up.title}</span> : null}
|
|
{up.meta ? <span>{up.meta}</span> : null}
|
|
{up.recipeName ? (
|
|
<span>
|
|
{t('adm.recipe')}: {up.recipeName}
|
|
</span>
|
|
) : null}
|
|
<span>
|
|
{t('adm.rating')}: {score(up).toFixed(1)} ({ratings[`photo:${up.id}`]?.n ?? 0})
|
|
</span>
|
|
<span>
|
|
{t('adm.owner')}: {up.email}
|
|
</span>
|
|
<span>
|
|
{t('adm.uploaded')}: {new Date(up.createdAt).toLocaleString()}
|
|
</span>
|
|
<span>
|
|
{t('adm.size')}: {Math.round(up.bytes / 1024)} KB · {up.mime}
|
|
</span>
|
|
</div>
|
|
<div className="adm-choices">
|
|
{DESTINATIONS.map((d) => (
|
|
<label className="adm-choice" key={d.id}>
|
|
<input
|
|
type="checkbox"
|
|
data-key={`adm-pick-${d.id}-${up.id}`}
|
|
checked={up.slots.includes(d.id)}
|
|
// A photo with no stored look has no file to hand out, so
|
|
// the QR box would only ever draw an empty card.
|
|
disabled={busy || (d.id === 'qr' && !up.hasPreset)}
|
|
title={d.id === 'qr' && !up.hasPreset ? t('adm.noPreset') : undefined}
|
|
onChange={() =>
|
|
void setSlots(
|
|
up.id,
|
|
up.slots.includes(d.id) ? up.slots.filter((x) => x !== d.id) : [...up.slots, d.id],
|
|
)
|
|
}
|
|
/>
|
|
{t(d.key)}
|
|
</label>
|
|
))}
|
|
</div>
|
|
{/* The QR section's real artefact is the code, not the photo,
|
|
so it is drawn here under the frame — on white, because a
|
|
scanner needs the quiet zone and the dark theme has none. */}
|
|
<div className="adm-qr" data-key={`adm-qr-${up.id}`}>
|
|
{up.hasPreset ? (
|
|
<QrCode url={new URL(api.presetUrl(up.id), window.location.origin).href} size={104} />
|
|
) : (
|
|
<span>{t('adm.noPreset')}</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn adm-danger"
|
|
data-key={`adm-del-${up.id}`}
|
|
disabled={busy}
|
|
onClick={() => void remove(up.id)}
|
|
>
|
|
{t('adm.delete')}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<p className="hint adm-row-empty">{t('adm.bigEmpty')}</p>
|
|
)}
|
|
</section>
|
|
</div>
|
|
{/* The strip: one thumbnail per frame the album holds, in the order the
|
|
filters and the sort chose. */}
|
|
<div className="adm-filmstrip" data-key={`adm-strip-${key}`}>
|
|
{list.map((p) => (
|
|
<button
|
|
key={p.id}
|
|
type="button"
|
|
className={`adm-thumb${up?.id === p.id ? ' on' : ''}`}
|
|
data-key={`adm-thumb-${key}-${p.id}`}
|
|
onClick={() => setSel(p.id)}
|
|
>
|
|
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
|
<span className="adm-thumb-cap">#{p.id}</span>
|
|
</button>
|
|
))}
|
|
{list.length === 0 ? <p className="hint adm-row-empty">{t('adm.rowEmpty')}</p> : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="adm">
|
|
<header className="adm-bar">
|
|
<a className="adm-brand" data-key="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('adm.title')}</b>
|
|
<span className="adm-spacer" />
|
|
<span className="hint">{t('adm.total', { n: photos.length })}</span>
|
|
{state === 'ready' ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
data-key="adm-upload"
|
|
disabled={busy}
|
|
onClick={() => filePick.current?.click()}
|
|
>
|
|
{busy ? t('auth.busy') : t('adm.upload')}
|
|
</button>
|
|
<input
|
|
ref={filePick}
|
|
type="file"
|
|
hidden
|
|
accept="image/jpeg,image/png,image/webp"
|
|
data-key="adm-upload-input"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = '';
|
|
if (file) void upload(file);
|
|
}}
|
|
/>
|
|
{photos.length > 0 ? (
|
|
<button type="button" className="btn adm-danger" data-key="adm-clear" disabled={busy} onClick={() => void clear()}>
|
|
{t('adm.clearAll')}
|
|
</button>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</header>
|
|
|
|
{note ? <p className="hint adm-note">{note}</p> : null}
|
|
|
|
{state === 'loading' ? <p className="hint adm-note">{t('auth.busy')}</p> : null}
|
|
|
|
{state === 'guest' ? (
|
|
<p className="adm-note">
|
|
<a className="btn primary" href="/?auth=login">{t('adm.signIn')}</a>
|
|
</p>
|
|
) : null}
|
|
|
|
{state === 'forbidden' ? <p className="err adm-note">{t('adm.notAdmin')}</p> : null}
|
|
|
|
{state === 'ready' ? (
|
|
<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={`adm-side-item${tab === entry.id ? ' on' : ''}`}
|
|
data-key={`adm-nav-${entry.id}`}
|
|
aria-current={tab === entry.id}
|
|
onClick={() => setTab(entry.id)}
|
|
>
|
|
{t(entry.key)}
|
|
</button>
|
|
))}
|
|
<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>
|
|
{photos.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
|
|
<div className="adm-cols">
|
|
{/* Two columns of the same shape: the uploads on the left, one
|
|
album per account; the landing on the right, one album per
|
|
section. Each column is an album shelf, the frame that is
|
|
up with its own controls, and a thumbnail strip. */}
|
|
{column(
|
|
t('adm.uploads'),
|
|
'uploads',
|
|
albumPhotos,
|
|
selUploads,
|
|
setSelUploads,
|
|
<>
|
|
<input
|
|
type="search"
|
|
className="adm-input"
|
|
data-key="adm-filter-name"
|
|
placeholder={t('adm.filterName')}
|
|
value={nameQuery}
|
|
onChange={(e) => setNameQuery(e.target.value)}
|
|
/>
|
|
<div className="adm-filters">
|
|
<select
|
|
className="adm-input"
|
|
data-key="adm-sort"
|
|
aria-label={t('adm.sort')}
|
|
value={sort}
|
|
onChange={(e) => setSort(e.target.value as 'newest' | 'recipe' | 'rating')}
|
|
>
|
|
<option value="newest">{t('adm.sortNewest')}</option>
|
|
<option value="recipe">{t('adm.sortRecipe')}</option>
|
|
<option value="rating">{t('adm.sortRating')}</option>
|
|
</select>
|
|
<select
|
|
className="adm-input"
|
|
data-key="adm-filter-rating"
|
|
aria-label={t('adm.rating')}
|
|
value={String(minStars)}
|
|
onChange={(e) => setMinStars(Number(e.target.value))}
|
|
>
|
|
<option value="0">{t('adm.ratingAll')}</option>
|
|
{[5, 4, 3, 2, 1].map((n) => (
|
|
<option key={n} value={String(n)}>
|
|
{t('adm.ratingAtLeast', { n })}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={`adm-album${album === 'all' ? ' on' : ''}`}
|
|
data-key="adm-album-all"
|
|
onClick={() => setAlbum('all')}
|
|
>
|
|
<span className="adm-album-name">{t('adm.albumAll')}</span>
|
|
<span className="adm-album-count">{photos.length}</span>
|
|
</button>
|
|
{shownAlbums.map((a) => (
|
|
<button
|
|
key={a.userId}
|
|
type="button"
|
|
className={`adm-album${album === a.userId ? ' on' : ''}`}
|
|
data-key={`adm-album-${a.userId}`}
|
|
onClick={() => setAlbum(a.userId)}
|
|
>
|
|
<span className="adm-album-name">{a.email}</span>
|
|
<span className="adm-album-count">{a.n}</span>
|
|
</button>
|
|
))}
|
|
</>,
|
|
)}
|
|
{column(
|
|
t('adm.sections'),
|
|
'sections',
|
|
sectionPhotos,
|
|
selSections,
|
|
setSelSections,
|
|
<>
|
|
{SECTIONS.map((d) => (
|
|
<button
|
|
key={d.id}
|
|
type="button"
|
|
className={`adm-album${section === d.id ? ' on' : ''}`}
|
|
data-key={`adm-section-${d.id}`}
|
|
onClick={() => setSection(d.id)}
|
|
>
|
|
<span className="adm-album-name">{t(d.key)}</span>
|
|
<span className="adm-album-count">
|
|
{photos.filter((p) => p.slots.includes(d.id)).length}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</>,
|
|
)}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
{tab === 'users' ? (
|
|
users.length === 0 ? <p className="hint adm-note">{t('adm.noUsers')}</p> : (
|
|
<>
|
|
<div className="adm-bulk">
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
data-key="adm-users-all"
|
|
disabled={busy || selectable.length === 0}
|
|
onClick={() => setPicked(selectable)}
|
|
>
|
|
{t('adm.selectAll')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
data-key="adm-users-none"
|
|
disabled={busy || picked.length === 0}
|
|
onClick={() => setPicked([])}
|
|
>
|
|
{t('adm.selectNone')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn adm-danger"
|
|
data-key="adm-users-delete"
|
|
disabled={busy || picked.length === 0}
|
|
onClick={() => void deleteSelected()}
|
|
>
|
|
{t('adm.deleteSelected', { n: picked.length })}
|
|
</button>
|
|
</div>
|
|
<div className="adm-table-wrap">
|
|
<table className="adm-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="adm-check">
|
|
<input
|
|
type="checkbox"
|
|
data-key="adm-user-select-all"
|
|
aria-label={t('adm.selectAll')}
|
|
checked={allPicked}
|
|
disabled={busy || selectable.length === 0}
|
|
onChange={() => setPicked(allPicked ? [] : selectable)}
|
|
/>
|
|
</th>
|
|
<th>{t('adm.userPicture')}</th>
|
|
<th>{t('adm.userName')}</th>
|
|
<th>{t('adm.userEmail')}</th>
|
|
<th>{t('adm.userRole')}</th>
|
|
<th className="adm-pro">{t('adm.userPro')}</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 className="adm-check">
|
|
{u.admin ? null : (
|
|
<input
|
|
type="checkbox"
|
|
data-key={`adm-user-check-${u.id}`}
|
|
aria-label={u.email}
|
|
checked={picked.includes(u.id)}
|
|
disabled={busy}
|
|
onChange={() => togglePicked(u.id)}
|
|
/>
|
|
)}
|
|
</td>
|
|
<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]}
|
|
{u.blocked ? <span className="adm-badge adm-badge-warn">{t('adm.userBlocked')}</span> : null}
|
|
{u.removed ? <span className="adm-badge adm-badge-warn">{t('adm.userRemoved')}</span> : null}
|
|
</td>
|
|
<td>{u.email}</td>
|
|
<td>
|
|
<span className={`adm-badge${u.admin ? ' on' : ''}`}>
|
|
{u.admin ? t('adm.roleAdmin') : t('adm.roleMember')}
|
|
</span>
|
|
</td>
|
|
<td className="adm-pro">
|
|
{/* The PRO grant. Ticking it hands this account the
|
|
studio's PRO tier on its next request; the API
|
|
reads the row, not the session, so nothing has
|
|
to be signed in again. An admin account is PRO
|
|
by allowlist, so its box only reports that. */}
|
|
<input
|
|
type="checkbox"
|
|
data-key={`adm-user-pro-${u.id}`}
|
|
aria-label={t('adm.userPro')}
|
|
checked={u.pro}
|
|
disabled={busy || u.admin}
|
|
onChange={() => void setUserState(u.id, { pro: !u.pro })}
|
|
/>
|
|
</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.id)}
|
|
>
|
|
{t('adm.viewPhotos')}
|
|
</button>
|
|
{/* An admin account is the API's own privilege
|
|
source, so it is never a moderation target and
|
|
offers no buttons here. */}
|
|
{u.admin ? null : (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
data-key={`adm-user-block-${u.id}`}
|
|
disabled={busy}
|
|
onClick={() => void setUserState(u.id, { blocked: !u.blocked })}
|
|
>
|
|
{u.blocked ? t('adm.userUnblock') : t('adm.userBlock')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
data-key={`adm-user-remove-${u.id}`}
|
|
disabled={busy}
|
|
onClick={() => void setUserState(u.id, { removed: !u.removed })}
|
|
>
|
|
{u.removed ? t('adm.userRestore') : t('adm.userRemove')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn adm-danger"
|
|
data-key={`adm-user-delete-${u.id}`}
|
|
disabled={busy}
|
|
onClick={() => void deleteUser(u)}
|
|
>
|
|
{t('adm.userDelete')}
|
|
</button>
|
|
</>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
)
|
|
) : null}
|
|
|
|
{tab === 'profile' ? <Profile onSaved={(msg) => setNote(msg)} /> : null}
|
|
|
|
{tab === 'stats' ? <Stats /> : null}
|
|
|
|
{tab === 'backup' ? (
|
|
<>
|
|
<p className="hint adm-sub">{t('adm.backupHint')}</p>
|
|
<div className="adm-bulk">
|
|
{/* A plain link, so the browser does the download and the
|
|
session cookie authorises it — nothing here buffers a
|
|
multi-hundred-megabyte archive in memory. */}
|
|
<a className="btn primary" data-key="adm-backup-save" href={api.adminBackupUrl()}>
|
|
{t('adm.backupSave')}
|
|
</a>
|
|
<button
|
|
type="button"
|
|
className="btn adm-danger"
|
|
data-key="adm-restore-pick"
|
|
disabled={busy}
|
|
onClick={() => restorePick.current?.click()}
|
|
>
|
|
{busy ? t('auth.busy') : t('adm.restorePick')}
|
|
</button>
|
|
<input
|
|
ref={restorePick}
|
|
type="file"
|
|
hidden
|
|
accept=".gz,application/gzip"
|
|
data-key="adm-restore-input"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = '';
|
|
if (file) void restore(file);
|
|
}}
|
|
/>
|
|
</div>
|
|
<p className="hint adm-sub">{t('adm.restoreHint')}</p>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|