refactor(catalog): unify commands and embed guarded catalog workspace

This commit is contained in:
Simo committed 2026-09-06 19:40:52 +02:00
1 parent 16f35568f5
commit 193b6686a9
57 files changed
+3268 -728

No files matched your search

@@ -0,0 +1,128 @@
"use client";
import { Loader2, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import Link from "@/components/link";
import { Input } from "@/components/ui/input";
import { useLatestRequest } from "../client/use-latest-request";
import {
type CatalogSearchResult,
type SearchCatalogKind,
validSearchQuery,
} from "../domain/search";
export function CatalogSearch({
catalogType = "normal",
}: {
catalogType?: SearchCatalogKind;
}) {
const t = useTranslations("pages.admin.catalog.workspace");
const [query, setQuery] = useState("");
const [results, setResults] = useState<CatalogSearchResult[]>([]);
const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">(
"idle",
);
const requests = useLatestRequest();
useEffect(() => {
const request = requests.start();
setResults([]);
if (!validSearchQuery(query)) {
setStatus("idle");
return () => requests.cancel();
}
setStatus("loading");
const timer = setTimeout(async () => {
try {
const params = new URLSearchParams({
q: query.trim(),
catalog: catalogType,
});
const response = await fetch(`/api/admin/catalog/search?${params}`, {
signal: request.signal,
});
if (!response.ok) throw Error("Search failed");
const data = await response.json();
if (!request.isCurrent()) return;
setResults(data.results);
setStatus("ready");
} catch {
if (request.isCurrent()) setStatus("error");
}
}, 250);
return () => {
clearTimeout(timer);
requests.cancel();
};
}, [query, catalogType, requests]);
return (
<section
aria-label={t("globalSearch")}
className="min-w-0 space-y-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 text-[var(--admin-text)]"
>
<div className="flex min-w-0 items-center gap-2">
<Search
aria-hidden
className="h-4 w-4 shrink-0 text-muted-foreground"
/>
<Input
aria-label={t("globalSearch")}
placeholder={t("searchPlaceholder")}
value={query}
onChange={(event) => setQuery(event.target.value)}
maxLength={100}
className="min-w-0"
/>
</div>
<div aria-live="polite">
{status === "loading" && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
{t("searching")}
</p>
)}
{status === "error" && (
<p role="alert" className="text-sm text-[var(--admin-error)]">
{t("searchFailed")}
</p>
)}
{status === "ready" && results.length === 0 && (
<p className="text-sm text-muted-foreground">
{t("searchNoResults")}
</p>
)}
</div>
{results.length > 0 && (
<ul className="max-h-80 overflow-y-auto divide-y divide-[var(--admin-border)]">
{results.map((result) => (
<li key={result.kind + result.id}>
<Link
href={result.href}
className="block min-w-0 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2"
>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-xs text-muted-foreground">
{t(result.kind)}
</span>
<span className="font-medium break-words">
{result.caption}
</span>
<span className="text-xs text-muted-foreground">
#{result.id}
</span>
</div>
<p className="break-words text-xs text-muted-foreground">
{result.path.join(" / ")}
</p>
{result.furniture && (
<p className="break-words text-xs text-muted-foreground">
{result.furniture.classname} · #{result.furniture.id} ·
sprite {result.furniture.spriteId}
</p>
)}
</Link>
</li>
))}
</ul>
)}
</section>
);
}
@@ -0,0 +1,90 @@
"use client";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
import { useVisiblePolling } from "@/hooks/use-visible-polling";
import { adminFetch } from "@/lib/admin-fetch";
interface Status {
hotel: { sent: boolean; checkedAt: string; reference?: string } | null;
git: {
enabled: boolean;
pending: number;
active: number;
error?: string;
commit?: string;
};
}
export function CatalogSyncStatus({ canEdit }: { canEdit: boolean }) {
const t = useTranslations("pages.admin.catalog.workspace");
const [status, setStatus] = useState<Status | null>(null);
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
try {
const res = await adminFetch("/api/admin/catalog/status", {
cache: "no-store",
});
if (!res.ok) throw Error();
setStatus(await res.json());
setError(false);
} catch {
setError(true);
}
}, []);
useVisiblePolling(refresh, 15000);
async function retry() {
setBusy(true);
try {
const res = await adminFetch("/api/admin/catalog/status", {
method: "POST",
});
if (!res.ok) throw Error();
await refresh();
} catch {
setError(true);
} finally {
setBusy(false);
}
}
return (
<div
className="flex flex-wrap items-center gap-3 rounded-lg border border-[var(--admin-border)] p-3 text-xs"
aria-live="polite"
>
<span>
{t("hotel")}:{" "}
{status?.hotel
? t(status.hotel.sent ? "sent" : "sendFailed")
: t("unknown")}
</span>
<span>
Git:{" "}
{status
? t(
!status.git.enabled
? "disabled"
: status.git.error
? "exportFailed"
: status.git.pending || status.git.active
? "queued"
: status.git.commit
? "exported"
: "unknown",
)
: t("unknown")}
</span>
{status?.hotel?.reference && <span>{status.hotel.reference}</span>}
{canEdit && status?.hotel?.sent === false && (
<Button size="sm" variant="outline" disabled={busy} onClick={retry}>
{t("retryHotel")}
</Button>
)}
{error && (
<button type="button" onClick={() => void refresh()}>
{t("retryStatus")}
</button>
)}
</div>
);
}