d2115941c7
A new BACKUP tab downloads the deployment's whole state — the SQLite file and both media folders, photos included — as one .tar.gz, and takes the same file back. That one artefact therefore does both jobs: the operator's backup and the data package that moves an install onto another box. The database is snapshotted through SQLite's own backup rather than copied, because the file is written to while the archive streams; the media folders are tarred straight off the volume, so no second copy of them is made. A restore replaces the data on disk and then exits — the container's restart policy brings the API back on the restored files, which is the only moment the open handle can be dropped. The state being replaced is tarred aside first, and the archive is checked for `..` entries before anything is unpacked. The API authenticates that route before it reads a byte, and nginx lets that one path past the body cap which holds everywhere else.
747 lines
31 KiB
TypeScript
747 lines
31 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 and remove/restore share one route; the list is reloaded
|
|
// because either flag also changes what the built-in viewer would show.
|
|
const setUserState = (id: number, patch: { blocked?: boolean; removed?: 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>{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>{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>
|
|
);
|
|
}
|