web: moderate accounts, accept 12MB uploads, put SAVE under CREATE

- /admin User account rows gain BLOCK/UNBLOCK, REMOVE/RESTORE and DELETE.
  Blocked = cannot sign in (sessions swept), removed = hidden from the strip
  and cannot sign in, both reversible; DELETE drops the account with its
  photos and recipes and unlinks the files. An allowlisted account is never
  a target, so an admin cannot moderate or delete itself.
- Photo uploads move from a 3MB API cap / 4m nginx cap to 12MB / 16m, and
  the browser shrinks an oversized still before sending it (2048px JPEG,
  avatars 512px) so the declared type still matches the sniffed bytes.
- The studio SAVE leaves the top bar and sits under the CREATE RECIPES tab,
  labelled SAVE RECIPES.
This commit is contained in:
2026-09-18 10:33:35 +07:00
parent 8e6c1493e8
commit 43d86b4b6f
12 changed files with 386 additions and 45 deletions
+55 -1
View File
@@ -116,6 +116,22 @@ export function Admin() {
setTab('pictures');
};
// 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();
});
};
const shown = owner ? photos.filter((p) => p.email === owner) : photos;
return (
@@ -270,7 +286,11 @@ export function Admin() {
<span className="adm-face adm-face-empty">{(u.email[0] ?? '?').toUpperCase()}</span>
)}
</td>
<td>{u.email.split('@')[0]}</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' : ''}`}>
@@ -288,6 +308,40 @@ export function Admin() {
>
{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>
))}