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}
+5
View File
@@ -348,6 +348,11 @@ export function Landing() {
{t('nav.login')}
</button>
)}
{/* Between the account slot and the store CTA: the browser studio is
live today, so it gets its own way in from the top bar. */}
<a className="lp-btn lp-sm" data-key="nav-studio" href="/app">
{c({ en: 'RecipesCam web studio', vi: 'Studio web RecipesCam' })}
</a>
<button type="button" className="lp-btn lp-sm lp-primary" onClick={() => showToast(toastDownload)}>
{c({ en: 'Download RecipesCam', vi: 'Tải RecipesCam' })}
</button>
+4
View File
@@ -186,6 +186,10 @@ export const en: Dict = {
'adm.userBlocked': 'Blocked',
'adm.userRemoved': 'Removed',
'adm.userDeleteConfirm': 'Permanently delete the account {email} with all of its photos and recipes? This cannot be undone.',
'adm.selectAll': 'SELECT ALL',
'adm.selectNone': 'SELECT NONE',
'adm.deleteSelected': 'DELETE SELECTED ({n})',
'adm.userDeleteSelectedConfirm': 'Permanently delete the {n} selected accounts with all of their photos and recipes? This cannot be undone.',
'profile.title': 'My profile',
+4
View File
@@ -189,6 +189,10 @@ export const vi = {
'adm.userBlocked': 'Đã khoá',
'adm.userRemoved': 'Đã gỡ',
'adm.userDeleteConfirm': 'Xoá vĩnh viễn tài khoản {email} cùng toàn bộ ảnh và công thức của nó? Không hoàn tác được.',
'adm.selectAll': 'CHỌN TẤT CẢ',
'adm.selectNone': 'BỎ CHỌN',
'adm.deleteSelected': 'XOÁ ĐÃ CHỌN ({n})',
'adm.userDeleteSelectedConfirm': 'Xoá vĩnh viễn {n} tài khoản đã chọn cùng toàn bộ ảnh và công thức của chúng? Không hoàn tác được.',
'profile.title': 'Hồ sơ của tôi',
+6
View File
@@ -384,6 +384,12 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.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; }
/* Bulk selection: the three controls above the users table, and the checkbox
column at its head (narrow so the picture column keeps the width). */
.adm-bulk { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; }
.adm-bulk .btn { margin: 0; }
.adm-check { width: 1%; white-space: nowrap; }
.adm-check input { display: block; cursor: pointer; }
.adm-face {
display: inline-flex;
align-items: center;