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
@@ -4,7 +4,11 @@ import { ChevronRight, Home } from "lucide-react";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { useCatalogManager } from "./use-catalog-store";
|
||||
|
||||
export function BreadcrumbBar() {
|
||||
export function BreadcrumbBar({
|
||||
onSelectPage,
|
||||
}: {
|
||||
onSelectPage: (id: number) => void;
|
||||
}) {
|
||||
const { state, dispatch, catalogType } = useCatalogManager();
|
||||
const { selectedPageId, ancestors } = state;
|
||||
const catQs = catalogType === "bc" ? "&catalog=bc" : "";
|
||||
@@ -40,7 +44,7 @@ export function BreadcrumbBar() {
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "SELECT_PAGE", id: a.id })}
|
||||
onClick={() => onSelectPage(a.id)}
|
||||
className="shrink-0 hover:text-foreground transition-colors truncate max-w-40"
|
||||
>
|
||||
{a.caption}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -22,12 +22,12 @@ import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteTreePage,
|
||||
reorderTreePage,
|
||||
reorderCatalogPages,
|
||||
toggleCatalogPage,
|
||||
} from "@/actions/catalog";
|
||||
import {
|
||||
deleteBcTreePage,
|
||||
reorderBcTreePage,
|
||||
reorderBcCatalogPages,
|
||||
toggleBcPage,
|
||||
} from "@/actions/catalog-bc";
|
||||
import { CatalogIcon } from "@/components/admin/catalog-tree";
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCatalogManager } from "./use-catalog-store";
|
||||
|
||||
@@ -54,6 +55,7 @@ interface RootTabsProps {
|
||||
onEditTab: (id: number) => void | Promise<void>;
|
||||
onCreateRoot: () => void;
|
||||
loading?: boolean;
|
||||
onMutation?: () => void;
|
||||
}
|
||||
|
||||
export function RootTabs({
|
||||
@@ -63,6 +65,7 @@ export function RootTabs({
|
||||
onEditTab,
|
||||
onCreateRoot,
|
||||
loading,
|
||||
onMutation,
|
||||
}: RootTabsProps) {
|
||||
const { catalogType, canEdit } = useCatalogManager();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
@@ -85,26 +88,26 @@ export function RootTabs({
|
||||
const newIndex = tabs.findIndex((t) => t.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
if (!canEdit) return;
|
||||
if (!canEdit || !requestCatalogNavigation()) return;
|
||||
const newOrder = arrayMove(tabs, oldIndex, newIndex);
|
||||
|
||||
// Batch reorder
|
||||
try {
|
||||
const reorder =
|
||||
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
|
||||
const results = await Promise.allSettled(
|
||||
newOrder.map((tab, idx) =>
|
||||
reorder({ pageId: tab.id, newOrderNum: idx + 1 }),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
if (failed) toast.error(`${failed} reorder(s) failed`);
|
||||
else toast.success("Tabs reordered");
|
||||
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
|
||||
const result = await reorder({
|
||||
parentId: -1,
|
||||
ids: newOrder.map((tab) => tab.id),
|
||||
expectedIds: tabs.map((tab) => tab.id),
|
||||
});
|
||||
if (!result.ok) throw Error("Failed to reorder tabs");
|
||||
toast.success("Tabs reordered");
|
||||
onMutation?.();
|
||||
} catch {
|
||||
toast.error("Failed to reorder tabs");
|
||||
}
|
||||
},
|
||||
[tabs, canEdit, catalogType],
|
||||
[tabs, canEdit, catalogType, onMutation],
|
||||
);
|
||||
|
||||
const handleToggle = async (tab: RootTab) => {
|
||||
@@ -118,6 +121,7 @@ export function RootTabs({
|
||||
toast.success(
|
||||
`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`,
|
||||
);
|
||||
onMutation?.();
|
||||
} catch {
|
||||
toast.error("Failed to toggle");
|
||||
}
|
||||
@@ -138,6 +142,7 @@ export function RootTabs({
|
||||
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
|
||||
}
|
||||
toast.success(`"${tab.caption}" deleted`);
|
||||
onMutation?.();
|
||||
} catch {
|
||||
toast.error("Failed to delete");
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { toast } from "sonner";
|
||||
import {
|
||||
createCatalogPage,
|
||||
deleteTreePage,
|
||||
reorderCatalogPages,
|
||||
reorderTreePage,
|
||||
toggleCatalogPage,
|
||||
updateCatalogPage,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
import {
|
||||
createBcPage,
|
||||
deleteBcTreePage,
|
||||
reorderBcCatalogPages,
|
||||
reorderBcTreePage,
|
||||
toggleBcPage,
|
||||
updateBcPage,
|
||||
@@ -113,7 +115,6 @@ export function SortableTree({
|
||||
const [subpageName, setSubpageName] = useState("");
|
||||
const [creatingSubpage, setCreatingSubpage] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [activeId, setActiveId] = useState<number | null>(null);
|
||||
const [treeLoading, setTreeLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<FilterMode>("all");
|
||||
@@ -189,29 +190,32 @@ export function SortableTree({
|
||||
void loadTabTree();
|
||||
}, [activeTabId, loadTabTree, dispatch]);
|
||||
|
||||
// Debounced search
|
||||
// Ignore stale search completions when the query or catalog changes.
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||
searchTimeout.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
setSearchResults(null);
|
||||
setSearching(false);
|
||||
if (!searchQuery.trim()) return;
|
||||
const timeout = setTimeout(async () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catQs}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSearchResults(data.pages || []);
|
||||
}
|
||||
if (!res.ok) throw new Error("Search failed");
|
||||
const data = await res.json();
|
||||
if (!controller.signal.aborted) setSearchResults(data.pages || []);
|
||||
} catch {
|
||||
if (!controller.signal.aborted)
|
||||
toast.error("Failed to search catalog pages");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
if (!controller.signal.aborted) setSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => {
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||
controller.abort();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [searchQuery, catQs]);
|
||||
|
||||
@@ -423,7 +427,8 @@ export function SortableTree({
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
if (!over || active.id === over.id || activeTabId === null) return;
|
||||
if (!canEdit || !over || active.id === over.id || activeTabId === null)
|
||||
return;
|
||||
|
||||
const activeNode = nodes.get(active.id as number);
|
||||
const overNode = nodes.get(over.id as number);
|
||||
@@ -446,16 +451,15 @@ export function SortableTree({
|
||||
|
||||
if (!canEdit) return;
|
||||
const reorder =
|
||||
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
|
||||
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
newOrder.map((id, idx) =>
|
||||
reorder({ pageId: id, newOrderNum: idx + 1 }),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
if (failed > 0) toast.error(`${failed} reorder(s) failed`);
|
||||
else toast.success("Reordered");
|
||||
const result = await reorder({
|
||||
parentId,
|
||||
ids: newOrder,
|
||||
expectedIds: siblings,
|
||||
});
|
||||
if (!result.ok) throw Error("Failed to reorder");
|
||||
toast.success("Reordered");
|
||||
onTreeMutation();
|
||||
} catch {
|
||||
toast.error("Failed to reorder");
|
||||
|
||||
@@ -317,6 +317,7 @@ export const TreeItemContent = React.memo(
|
||||
},
|
||||
(prev, next) => {
|
||||
return (
|
||||
prev.onSelect === next.onSelect &&
|
||||
prev.node.id === next.node.id &&
|
||||
prev.node.caption === next.node.caption &&
|
||||
prev.node.enabled === next.node.enabled &&
|
||||
|
||||
@@ -59,6 +59,7 @@ const initialState: CatalogManagerState = {
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
|
||||
export type CatalogAction =
|
||||
| { type: "RESTORE_SELECTION"; rootId: number | null; pageId: number | null }
|
||||
| { type: "SET_ROOT_PAGES"; pages: RootTab[] }
|
||||
| { type: "SET_LOADING_ROOT"; loading: boolean }
|
||||
| { type: "SET_ACTIVE_TAB"; id: number | null }
|
||||
@@ -93,14 +94,33 @@ function reducer(
|
||||
action: CatalogAction,
|
||||
): CatalogManagerState {
|
||||
switch (action.type) {
|
||||
case "RESTORE_SELECTION":
|
||||
return {
|
||||
...state,
|
||||
activeTabId:
|
||||
action.rootId ?? state.rootPages[0]?.id ?? state.activeTabId,
|
||||
selectedPageId: action.pageId,
|
||||
focusedId: action.pageId,
|
||||
editorDirty: false,
|
||||
multiSelectedIds: new Set(),
|
||||
ancestors: [],
|
||||
};
|
||||
case "SET_ROOT_PAGES":
|
||||
return { ...state, rootPages: action.pages, loadingRoot: false };
|
||||
return {
|
||||
...state,
|
||||
rootPages: action.pages,
|
||||
loadingRoot: false,
|
||||
activeTabId: action.pages.some((page) => page.id === state.activeTabId)
|
||||
? state.activeTabId
|
||||
: (action.pages[0]?.id ?? null),
|
||||
};
|
||||
case "SET_LOADING_ROOT":
|
||||
return { ...state, loadingRoot: action.loading };
|
||||
case "SET_ACTIVE_TAB":
|
||||
return {
|
||||
...state,
|
||||
activeTabId: action.id,
|
||||
editorDirty: false,
|
||||
selectedPageId: null,
|
||||
multiSelectedIds: new Set(),
|
||||
focusedId: null,
|
||||
@@ -110,6 +130,7 @@ function reducer(
|
||||
return {
|
||||
...state,
|
||||
selectedPageId: action.id,
|
||||
editorDirty: false,
|
||||
multiSelectedIds: new Set(),
|
||||
focusedId: action.id,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user