This commit is contained in:
k
2026-09-21 21:10:09 +02:00
parent c0e7899ab3
commit 4cefd3e17f
18 changed files with 577 additions and 217 deletions
+49 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } from "lucide-react";
import { api, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
@@ -23,8 +23,11 @@ export function AccessRequestsPanel() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const { t } = useT();
const listBusy = useRef(false);
async function refresh(select?: string) {
if (listBusy.current) return;
listBusy.current = true;
try {
const res = await api.adminAccessList();
setRequests(res.requests);
@@ -32,12 +35,19 @@ export function AccessRequestsPanel() {
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
} finally {
listBusy.current = false;
setLoading(false);
}
}
useEffect(() => {
refresh();
// Live list: new requests / replies pop without manual refresh.
const id = setInterval(() => {
if (!document.hidden) refresh();
}, 15000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const visible = requests.filter((r) => filter === "all" || r.status === filter);
@@ -122,12 +132,47 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
const [error, setError] = useState("");
const { t } = useT();
const [freshKey, setFreshKey] = useState<string | null>(null);
// Latest callbacks / server state without restarting the poll loop.
const onChangedRef = useRef(onChanged);
onChangedRef.current = onChanged;
const known = useRef({ status: request.status, reason: request.reason });
useEffect(() => {
setReason(request.reason);
setFreshKey(null);
api.adminAccessGet(request.id).then((res) => setMessages(res.messages)).catch(() => {});
}, [request.id, request.reason]);
known.current = { status: request.status, reason: request.reason };
let stopped = false;
let busy = false;
// Live thread: requester replies appear without manual refresh, and a
// status change made elsewhere syncs the list (badges, filters).
async function fetchOnce() {
if (busy || document.hidden) return;
busy = true;
try {
const res = await api.adminAccessGet(request.id);
if (stopped) return;
setMessages(res.messages);
if (
res.request.status !== known.current.status ||
res.request.reason !== known.current.reason
) {
known.current = { status: res.request.status, reason: res.request.reason };
onChangedRef.current();
}
} catch {
// Transient failure: keep last state, retry next tick.
} finally {
busy = false;
}
}
fetchOnce();
const id = setInterval(fetchOnce, 5000);
return () => {
stopped = true;
clearInterval(id);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [request.id]);
const closed = request.status === "accepted" || request.status === "refused";
@@ -137,6 +182,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
try {
const res = await api.adminAccessPatch(request.id, { status, reason: reason.trim() });
if (res.key) setFreshKey(res.key);
known.current = { status: res.request.status, reason: res.request.reason };
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : t("access.updateFailed"));
+37 -1
View File
@@ -23,6 +23,7 @@ export function AdminCenter() {
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } | null>(null);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [openAccessCount, setOpenAccessCount] = useState(0);
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
@@ -45,6 +46,32 @@ export function AdminCenter() {
.catch(() => setAllowed(false));
}, []);
// Live badge on the Access tab: poll the open (pending + reviewing)
// request count so new demands pop without opening the tab.
useEffect(() => {
if (!allowed) return;
let stopped = false;
async function fetchCount() {
try {
const res = await api.adminAccessList();
if (stopped) return;
setOpenAccessCount(
res.requests.filter((r) => r.status === "pending" || r.status === "reviewing").length,
);
} catch {
// Transient failure: keep last count, retry next tick.
}
}
fetchCount();
const id = setInterval(() => {
if (!document.hidden) fetchCount();
}, 15000);
return () => {
stopped = true;
clearInterval(id);
};
}, [allowed]);
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) {
setSaving(true);
setError("");
@@ -95,7 +122,7 @@ export function AdminCenter() {
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
"flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
"relative flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
tab === t.id
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
: "opacity-60 hover:opacity-100",
@@ -103,6 +130,15 @@ export function AdminCenter() {
>
<t.icon size={15} />
{t.label}
{t.id === "access" && openAccessCount > 0 && (
<span
role="status"
aria-label={`${t.label}: ${openAccessCount}`}
className="absolute -right-0.5 -top-1.5 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold leading-5 text-white shadow"
>
{openAccessCount > 99 ? "99+" : openAccessCount}
</span>
)}
</button>
))}
</div>
+4 -3
View File
@@ -90,11 +90,12 @@ export function Chat({
const modelGroups: PickerGroup[] = (() => {
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
for (const m of models) {
const list = byDriver.get(m.driver) ?? [];
const group = m.group ?? m.driver;
const list = byDriver.get(group) ?? [];
list.push({ value: m.id, label: m.label, hint: m.id });
byDriver.set(m.driver, list);
byDriver.set(group, list);
}
return [...byDriver.entries()].map(([driver, options]) => ({ group: driver, options }));
return [...byDriver.entries()].map(([group, options]) => ({ group, options }));
})();
useEffect(() => {
+36 -3
View File
@@ -83,7 +83,31 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
}
useEffect(() => {
let stopped = false;
load();
// Live updates: short-poll the ticket so admin replies and the
// decision show up without a manual refresh. No WS infra on the
// backend — traffic here is tiny, polling is the clean fit.
let busy = false;
async function poll() {
if (stopped || busy || document.hidden) return;
busy = true;
try {
const res = await api.accessTicket(ticketId);
if (stopped) return;
setRequest(res.request);
setMessages(res.messages);
} catch {
// Transient failure: keep last known state, retry next tick.
} finally {
busy = false;
}
}
const id = setInterval(poll, 5000);
return () => {
stopped = true;
clearInterval(id);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ticketId]);
@@ -147,9 +171,18 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
</p>
)}
{request.status === "accepted" && (
<p className="rounded-2xl bg-accent-500/10 px-4 py-2.5 text-center text-sm">
{t("review.acceptedHint")}
</p>
<>
<p className="rounded-2xl bg-accent-500/10 px-4 py-2.5 text-center text-sm">
{t("review.acceptedHint")}
</p>
<Button
size="sm"
className="w-full"
onClick={() => (window.location.href = "/")}
>
{t("common.backToLogin")}
</Button>
</>
)}
<div className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">