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:
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user