diff --git a/docker/frontend/src/Admin.tsx b/docker/frontend/src/Admin.tsx index 027c3ca..9d40f78 100644 --- a/docker/frontend/src/Admin.tsx +++ b/docker/frontend/src/Admin.tsx @@ -42,6 +42,7 @@ export function Admin() { const [owner, setOwner] = useState(null); const [busy, setBusy] = useState(false); const [note, setNote] = useState(null); + const [picked, setPicked] = useState([]); const filePick = useRef(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 ?

{t('adm.noUsers')}

: ( -
+ <> +
+ + + +
+
+ @@ -282,6 +341,18 @@ export function Admin() { {users.map((u) => ( +
+ setPicked(allPicked ? [] : selectable)} + /> + {t('adm.userPicture')} {t('adm.userName')} {t('adm.userEmail')}
+ {u.admin ? null : ( + togglePicked(u.id)} + /> + )} + {/* The account's own picture, straight from the public avatar route — nothing to fetch. */} @@ -352,7 +423,8 @@ export function Admin() { ))}
-
+
+ ) ) : null} diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index 170759c..17ee4fb 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -348,6 +348,11 @@ export function Landing() { {t('nav.login')} )} + {/* 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. */} + + {c({ en: 'RecipesCam web studio', vi: 'Studio web RecipesCam' })} + diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 04ab6d2..ddc40a0 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -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', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index d8ee96f..021e3c1 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 660cdb9..c2b2d6e 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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;