refactor(catalog): unify commands and embed guarded catalog workspace
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user