web: bulk-select accounts in the admin table and link the studio from the top bar

The users table grows a checkbox column with a select-all box in its head,
and three controls above it: SELECT ALL, SELECT NONE and DELETE SELECTED
naming the count. An admin account is the API's own privilege source, so it
gets no box and select-all skips it; the picks clear once the deletes land.

The landing top bar gains a RecipesCam web studio button between the account
slot and Download RecipesCam. It hides with the rest of the wide row under
1160px, where the burger sheet already offers the studio.
This commit is contained in:
2026-09-18 11:11:43 +07:00
parent b4d5d2926b
commit 86a93f46be
5 changed files with 93 additions and 2 deletions
+74 -2
View File
@@ -42,6 +42,7 @@ export function Admin() {
const [owner, setOwner] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<string | null>(null);
const [picked, setPicked] = useState<number[]>([]);
const filePick = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
@@ -132,6 +133,24 @@ export function Admin() {
});
};
// 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();
});
};
const shown = owner ? photos.filter((p) => p.email === owner) : photos;
return (
@@ -266,10 +285,50 @@ export function Admin() {
{tab === 'users' ? (
users.length === 0 ? <p className="hint adm-note">{t('adm.noUsers')}</p> : (
<div className="adm-table-wrap">
<>
<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>
@@ -282,6 +341,18 @@ export function Admin() {
<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. */}
@@ -352,7 +423,8 @@ export function Admin() {
))}
</tbody>
</table>
</div>
</div>
</>
)
) : null}