284 lines
8.5 KiB
TypeScript
284 lines
8.5 KiB
TypeScript
"use client";
|
|
import { Loader2, Search } from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { getBulkOfferDestinations } from "@/actions/catalog-bulk";
|
|
import Link from "@/components/link";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Input } from "@/components/ui/input";
|
|
import { requestCatalogNavigation } from "../client/use-catalog-navigation";
|
|
import { useLatestRequest } from "../client/use-latest-request";
|
|
import {
|
|
type CatalogSearchResult,
|
|
type SearchCatalogKind,
|
|
validSearchQuery,
|
|
} from "../domain/search";
|
|
import { BulkOfferEditor } from "./bulk-offer-editor";
|
|
export function CatalogSearch({
|
|
catalogType = "normal",
|
|
canEdit = false,
|
|
onNavigate,
|
|
autoFocus = false,
|
|
}: {
|
|
catalogType?: SearchCatalogKind;
|
|
canEdit?: boolean;
|
|
onNavigate?: (result: CatalogSearchResult) => void | Promise<void>;
|
|
autoFocus?: boolean;
|
|
}) {
|
|
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();
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
useEffect(() => {
|
|
if (autoFocus) inputRef.current?.focus();
|
|
}, [autoFocus]);
|
|
const bulk = useTranslations("pages.admin.catalog.bulk");
|
|
const router = useRouter();
|
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
|
const [pages, setPages] = useState<{ id: number; caption: string }[] | null>(
|
|
null,
|
|
);
|
|
const [destinationsLoading, setDestinationsLoading] = useState(false);
|
|
const [selectionError, setSelectionError] = useState<string | null>(null);
|
|
const destinationRequest = useRef(0);
|
|
const destinationBusy = useRef(false);
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Catalog switches invalidate the selection and destination requests.
|
|
useEffect(() => {
|
|
destinationRequest.current += 1;
|
|
destinationBusy.current = false;
|
|
setSelected(new Set());
|
|
setPages(null);
|
|
setDestinationsLoading(false);
|
|
setSelectionError(null);
|
|
return () => {
|
|
destinationRequest.current += 1;
|
|
};
|
|
}, [catalogType]);
|
|
async function loadDestinations() {
|
|
if (destinationBusy.current) return;
|
|
destinationBusy.current = true;
|
|
const request = ++destinationRequest.current;
|
|
setDestinationsLoading(true);
|
|
setSelectionError(null);
|
|
try {
|
|
const result = await getBulkOfferDestinations();
|
|
if (request !== destinationRequest.current) return;
|
|
if (!result.ok) {
|
|
setSelectionError(result.error);
|
|
return;
|
|
}
|
|
setPages(result.data.pages);
|
|
} catch {
|
|
if (request === destinationRequest.current)
|
|
setSelectionError(bulk("requestFailed"));
|
|
} finally {
|
|
if (request === destinationRequest.current) {
|
|
destinationBusy.current = false;
|
|
setDestinationsLoading(false);
|
|
}
|
|
}
|
|
}
|
|
function mayEdit() {
|
|
if (requestCatalogNavigation()) return true;
|
|
setSelectionError(bulk("disabledReason"));
|
|
return false;
|
|
}
|
|
function toggleOffer(id: number) {
|
|
if (!mayEdit()) return;
|
|
setSelected((previous) => {
|
|
const next = new Set(previous);
|
|
if (next.has(id)) next.delete(id);
|
|
else if (next.size < 500) next.add(id);
|
|
return next;
|
|
});
|
|
if (!pages) void loadDestinations();
|
|
}
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Successful bulk edits must refresh unchanged search queries.
|
|
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, refreshKey]);
|
|
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
|
|
ref={inputRef}
|
|
aria-label={t("globalSearch")}
|
|
placeholder={t("searchPlaceholder")}
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
maxLength={100}
|
|
className="min-w-0"
|
|
/>
|
|
</div>
|
|
{canEdit && catalogType === "normal" && selected.size > 0 && (
|
|
<div className="flex flex-wrap items-center gap-2 rounded-md border p-2">
|
|
<span className="text-sm">
|
|
{bulk("selectedCount", { count: selected.size })}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSelected(new Set())}
|
|
>
|
|
{bulk("clearSelection")}
|
|
</Button>
|
|
{pages ? (
|
|
<BulkOfferEditor
|
|
ids={[...selected]}
|
|
allPages={pages}
|
|
beforeEdit={mayEdit}
|
|
onApplied={() => {
|
|
setSelected(new Set());
|
|
setRefreshKey((value) => value + 1);
|
|
router.refresh();
|
|
}}
|
|
/>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={destinationsLoading}
|
|
onClick={() => void loadDestinations()}
|
|
>
|
|
{destinationsLoading && (
|
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
)}
|
|
{bulk(
|
|
destinationsLoading
|
|
? "loadingDestinations"
|
|
: "retryDestinations",
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{selectionError && (
|
|
<p role="alert" className="text-sm text-[var(--admin-error)]">
|
|
{selectionError}
|
|
</p>
|
|
)}
|
|
<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}
|
|
className="flex items-center gap-2"
|
|
>
|
|
{canEdit &&
|
|
catalogType === "normal" &&
|
|
result.kind === "offer" && (
|
|
<Checkbox
|
|
aria-label={bulk("selectOffer", {
|
|
name: result.caption,
|
|
id: result.id,
|
|
})}
|
|
checked={selected.has(result.id)}
|
|
disabled={selected.size >= 500 && !selected.has(result.id)}
|
|
onCheckedChange={() => toggleOffer(result.id)}
|
|
/>
|
|
)}
|
|
<Link
|
|
href={result.href}
|
|
onClick={(event) => {
|
|
if (
|
|
onNavigate &&
|
|
!event.ctrlKey &&
|
|
!event.metaKey &&
|
|
!event.shiftKey &&
|
|
!event.altKey
|
|
) {
|
|
event.preventDefault();
|
|
void onNavigate(result);
|
|
}
|
|
}}
|
|
className="block min-w-0 flex-1 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 text-[var(--admin-text)]">
|
|
{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>
|
|
);
|
|
}
|