feat(catalog): improve visual manager search and offer discovery
CI / check (push) Successful in 1m14s
CI / deploy (push) Successful in 1m29s

This commit is contained in:
Simo committed 2026-09-06 20:58:53 +02:00
1 parent 08321df2aa
commit 93a7e9a7c2
9 files changed
+505 -72

No files matched your search

@@ -1,8 +1,8 @@
"use client";
import { LayoutGrid, X } from "lucide-react";
import { LayoutGrid, Search, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog";
@@ -12,6 +12,8 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url";
import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
import type { CatalogSearchResult } from "@/features/catalog/domain/search";
import type { RootTab } from "@/types/catalog";
import { BreadcrumbBar } from "./breadcrumb-bar";
import { InlineEditor, type PageUpdateData } from "./inline-editor";
@@ -161,12 +163,20 @@ function CatalogManagerInner({
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const rootRequests = useLatestRequest();
const searchNavigation = useLatestRequest();
const [searchOpen, setSearchOpen] = useState(false);
const [searchTarget, setSearchTarget] = useState<{
offerId: number | null;
key: number;
}>({ offerId: null, key: 0 });
const tActions = useTranslations("pages.admin.actions");
const t = useTranslations("pages.admin.catalog.workspace");
const mayLeave = useCallback(
() => !state.editorDirty || window.confirm(tActions("leaveUnsaved")),
[state.editorDirty, tActions],
);
const mayLeaveRef = useRef(mayLeave);
mayLeaveRef.current = mayLeave;
const restoreSelection = useCallback(
(selection: CatalogSelection) => {
dispatch({ type: "RESTORE_SELECTION", ...selection });
@@ -196,6 +206,11 @@ function CatalogManagerInner({
if (target.closest?.("[role=listbox], [role=menu], [role=combobox]"))
return;
event.preventDefault();
if (searchOpen) {
searchNavigation.cancel();
setSearchOpen(false);
return;
}
close();
};
const onBeforeUnload = (event: BeforeUnloadEvent) => {
@@ -210,7 +225,7 @@ function CatalogManagerInner({
window.removeEventListener("keydown", onKey);
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, [close, state.editorDirty, embedded]);
}, [close, state.editorDirty, embedded, searchOpen, searchNavigation]);
const loadRootPages = useCallback(async () => {
const request = rootRequests.start();
@@ -253,10 +268,12 @@ function CatalogManagerInner({
}, [open, dispatch]);
const handleTabClick = (id: number) => {
searchNavigation.cancel();
if (id === state.activeTabId) return;
if (!mayLeave()) {
return;
}
setSearchTarget((previous) => ({ ...previous, offerId: null }));
saveSelectionUrl({ rootId: id, pageId: null });
dispatch({ type: "SET_ACTIVE_TAB", id });
};
@@ -285,17 +302,21 @@ function CatalogManagerInner({
};
const handleSelectPage = (id: number) => {
searchNavigation.cancel();
if (id === state.selectedPageId) return;
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
if (!mayLeave()) {
return;
}
setSearchTarget((previous) => ({ ...previous, offerId: null }));
saveSelectionUrl({ rootId: state.activeTabId, pageId: id > 0 ? id : null });
dispatch({ type: "SELECT_PAGE", id: id > 0 ? id : null });
};
const handleEditTab = (id: number) => {
searchNavigation.cancel();
if (id === state.selectedPageId && id === state.activeTabId) return;
if (!mayLeave()) return;
setSearchTarget((previous) => ({ ...previous, offerId: null }));
saveSelectionUrl({ rootId: id, pageId: id });
if (state.activeTabId !== id) dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "SELECT_PAGE", id });
@@ -316,6 +337,49 @@ function CatalogManagerInner({
}
};
async function openSearchResult(result: CatalogSearchResult) {
const request = searchNavigation.start();
try {
const response = await fetch(
`/api/admin/catalog/tree?ancestors=${result.pageId}${catQs}`,
{ signal: request.signal },
);
if (!response.ok) throw Error("Navigation failed");
const data = await response.json();
if (!request.isCurrent() || !mayLeaveRef.current()) return;
const rootId = Number(data.ancestors?.[0]?.id ?? result.pageId);
dispatch({ type: "RESTORE_SELECTION", rootId, pageId: result.pageId });
saveSelectionUrl({ rootId, pageId: result.pageId });
setSearchTarget((previous) => ({
offerId: result.kind === "offer" ? result.id : null,
key: previous.key + 1,
}));
setSearchOpen(false);
} catch {
if (request.isCurrent()) toast.error(t("searchFailed"));
}
}
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (
!(event.ctrlKey || event.metaKey) ||
!event.shiftKey ||
event.key.toLowerCase() !== "f"
)
return;
const dialog = (event.target as HTMLElement).closest?.('[role="dialog"]');
if (
dialog &&
dialog.getAttribute("aria-labelledby") !==
"visual-catalog-manager-title"
)
return;
event.preventDefault();
setSearchOpen(true);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
const heading =
catalogType === "bc"
? "BC Visual Catalog Manager"
@@ -334,19 +398,44 @@ function CatalogManagerInner({
<LayoutGrid className="h-4 w-4" />
{heading}
</h2>
{!embedded && (
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={close}
aria-label="Close"
variant="outline"
size="sm"
aria-label={t("globalSearch")}
aria-expanded={searchOpen}
onClick={() => {
searchNavigation.cancel();
setSearchOpen((value) => !value);
}}
>
<X className="h-4 w-4" />
<Search className="size-4" />
<span className="hidden sm:inline">{t("globalSearch")}</span>
<kbd className="hidden lg:inline text-xs">Ctrl Shift F</kbd>
</Button>
)}
{!embedded && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={close}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
{searchOpen && (
<div className="shrink-0 max-h-[45vh] overflow-y-auto border-b p-2">
<CatalogSearch
catalogType={catalogType}
onNavigate={openSearchResult}
autoFocus
/>
</div>
)}
<BreadcrumbBar onSelectPage={handleSelectPage} />
@@ -387,6 +476,8 @@ function CatalogManagerInner({
{t("backToCategories")}
</Button>
<InlineEditor
key={`${state.selectedPageId}:${searchTarget.key}`}
initialOfferId={searchTarget.offerId}
pageId={state.selectedPageId}
onSaved={handleEditorSaved}
/>