feat(downloads): let admins set a quota for any user, not only downloaders

The admin System tab only listed users with an existing footprint, so a quota could be set only
after someone downloaded something. Add a 'Set a user's quota…' picker (over /api/admin/users,
excl. demo) beside the Per-user footprint heading that opens the quota editor for ANY user — the
quota GET/PUT endpoints already resolve defaults + create a row on demand, so no backend change.
Also show an empty-state line when no one has a footprint yet. i18n en/hu/de.
This commit is contained in:
npeter83 2026-07-04 05:07:23 +02:00
parent 1e557db12e
commit b7f365dd5a
4 changed files with 69 additions and 6 deletions

View file

@ -1,4 +1,4 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -275,6 +275,56 @@ function QuotaModal({ userId, email, onClose }: { userId: number; email: string;
); );
} }
// Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the
// footprint list below only shows users who already have files).
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
const { t } = useTranslation();
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const users = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
const list = (users.data ?? []).filter((u) => !u.is_demo);
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
const base = s
? list.filter((u) => u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s))
: list;
return base.slice(0, 8);
}, [q, list]);
return (
<div className="relative w-full sm:w-72">
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder={t("downloads.admin.quotaPickPlaceholder")}
className={inputCls}
/>
{open && filtered.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-xl overflow-hidden">
{filtered.map((u) => (
<button
key={u.id}
onClick={() => {
onPick({ id: u.id, email: u.email });
setQ("");
setOpen(false);
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
>
<span className="truncate">{u.display_name || u.email}</span>
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
</button>
))}
</div>
)}
</div>
);
}
function AdminSystem() { function AdminSystem() {
const { t } = useTranslation(); const { t } = useTranslation();
const storage = useLiveQuery(["admin-storage"], api.adminDownloadStorage, { intervalMs: 5000 }); const storage = useLiveQuery(["admin-storage"], api.adminDownloadStorage, { intervalMs: 5000 });
@ -298,9 +348,13 @@ function AdminSystem() {
</div> </div>
)} )}
{s && s.per_user.length > 0 && ( {s && (
<div className="mb-6"> <div className="mb-6">
<div className="text-sm font-medium mb-2">{t("downloads.admin.perUser")}</div> <div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
<div className="text-sm font-medium">{t("downloads.admin.perUser")}</div>
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
<QuotaUserPicker onPick={setQuotaFor} />
</div>
<div className="space-y-1"> <div className="space-y-1">
{s.per_user.map((u) => ( {s.per_user.map((u) => (
<div key={u.user_id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40"> <div key={u.user_id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40">
@ -315,6 +369,9 @@ function AdminSystem() {
</button> </button>
</div> </div>
))} ))}
{s.per_user.length === 0 && (
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</div>
)}
</div> </div>
</div> </div>
)} )}

View file

@ -157,7 +157,9 @@
"reset": "Auf Standard zurücksetzen", "reset": "Auf Standard zurücksetzen",
"save": "Speichern", "save": "Speichern",
"custom": "individuell", "custom": "individuell",
"default": "Standard" "default": "Standard",
"quotaPickPlaceholder": "Kontingent für einen Nutzer festlegen…",
"noFootprint": "Noch keine Downloads."
}, },
"cols": { "cols": {
"title": "Titel", "title": "Titel",

View file

@ -157,7 +157,9 @@
"reset": "Reset to default", "reset": "Reset to default",
"save": "Save", "save": "Save",
"custom": "custom", "custom": "custom",
"default": "default" "default": "default",
"quotaPickPlaceholder": "Set a user's quota…",
"noFootprint": "No downloads yet."
}, },
"cols": { "cols": {
"title": "Title", "title": "Title",

View file

@ -157,7 +157,9 @@
"reset": "Visszaállítás alapértékre", "reset": "Visszaállítás alapértékre",
"save": "Mentés", "save": "Mentés",
"custom": "egyedi", "custom": "egyedi",
"default": "alapértelmezett" "default": "alapértelmezett",
"quotaPickPlaceholder": "Kvóta beállítása egy felhasználónak…",
"noFootprint": "Még nincs letöltés."
}, },
"cols": { "cols": {
"title": "Cím", "title": "Cím",