feat(catalog): improve visual manager search and offer discovery
This commit is contained in:
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}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user