@hectasquare/medusa-plugin-category-tree 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.medusa/server/src/admin/components/category-tree/canvas-dock.d.ts +1 -0
- package/.medusa/server/src/admin/components/category-tree/canvas-dock.js +7 -3
- package/.medusa/server/src/admin/components/category-tree/category-products-manager.js +59 -6
- package/.medusa/server/src/admin/components/category-tree/category-tree-canvas.js +2 -2
- package/README.md +111 -1
- package/package.json +1 -1
- package/src/admin/components/category-tree/canvas-dock.tsx +46 -8
- package/src/admin/components/category-tree/category-products-manager.tsx +30 -9
- package/src/admin/components/category-tree/category-tree-canvas.tsx +4 -3
- package/src/admin/components/category-tree/edit-category-drawer.tsx +271 -22
|
@@ -6,6 +6,7 @@ export interface CanvasDockProps {
|
|
|
6
6
|
onSelectStorefront: (val: string) => void;
|
|
7
7
|
direction: "TB" | "LR";
|
|
8
8
|
onToggleDirection: () => void;
|
|
9
|
+
onSelectDirection?: (val: "TB" | "LR") => void;
|
|
9
10
|
searchQuery: string;
|
|
10
11
|
onSearchChange: (q: string) => void;
|
|
11
12
|
onExpandAll: () => void;
|
|
@@ -4,9 +4,13 @@ exports.CanvasDock = void 0;
|
|
|
4
4
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
5
|
const ui_1 = require("@medusajs/ui");
|
|
6
6
|
const icons_1 = require("@medusajs/icons");
|
|
7
|
-
const CanvasDock = ({ salesChannels, selectedStorefront, onSelectStorefront, direction, onToggleDirection, searchQuery, onSearchChange, onExpandAll, onCollapseAll, onFocusRoot, selectedCount = 0, onExpandSelected, onCollapseSelected, isBoxSelect = false, onToggleBoxSelect, onFitView, onOpenCreateRoot, dirtyCount, isSaving, onSave, onReset, }) => {
|
|
8
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2 items-center pointer-events-auto", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2.5 px-3.5 py-2 bg-white/90 dark:bg-neutral-900/90 backdrop-blur-md border border-ui-border-base rounded-2xl shadow-xl transition-all", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1.5 border-r border-ui-border-base pr-2.5", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-[11px] font-semibold text-ui-fg-muted uppercase tracking-wider", children: "Store:" }), (0, jsx_runtime_1.jsxs)("select", { value: selectedStorefront, onChange: (e) => onSelectStorefront(e.target.value), className: "text-xs bg-ui-bg-subtle text-ui-fg-base border border-ui-border-base rounded-lg px-2 py-1 font-medium focus:outline-none cursor-pointer", children: [(0, jsx_runtime_1.jsx)("option", { value: "all", children: "All Storefronts" }), salesChannels.map((sc) => ((0, jsx_runtime_1.jsx)("option", { value: sc.id, children: sc.name }, sc.id)))] })] }), (0, jsx_runtime_1.jsx)("div", { className: "flex items-center border-r border-ui-border-base pr-2.5", children: (0, jsx_runtime_1.
|
|
7
|
+
const CanvasDock = ({ salesChannels, selectedStorefront, onSelectStorefront, direction, onToggleDirection, onSelectDirection, searchQuery, onSearchChange, onExpandAll, onCollapseAll, onFocusRoot, selectedCount = 0, onExpandSelected, onCollapseSelected, isBoxSelect = false, onToggleBoxSelect, onZoomIn, onZoomOut, onFitView, onOpenCreateRoot, dirtyCount, isSaving, onSave, onReset, }) => {
|
|
8
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2 items-center pointer-events-auto", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2.5 px-3.5 py-2 bg-white/90 dark:bg-neutral-900/90 backdrop-blur-md border border-ui-border-base rounded-2xl shadow-xl transition-all", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1.5 border-r border-ui-border-base pr-2.5", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-[11px] font-semibold text-ui-fg-muted uppercase tracking-wider", children: "Store:" }), (0, jsx_runtime_1.jsxs)("select", { value: selectedStorefront, onChange: (e) => onSelectStorefront(e.target.value), className: "text-xs bg-ui-bg-subtle text-ui-fg-base border border-ui-border-base rounded-lg px-2 py-1 font-medium focus:outline-none cursor-pointer", children: [(0, jsx_runtime_1.jsx)("option", { value: "all", children: "All Storefronts" }), salesChannels.map((sc) => ((0, jsx_runtime_1.jsx)("option", { value: sc.id, children: sc.name }, sc.id)))] })] }), (0, jsx_runtime_1.jsx)("div", { className: "flex items-center border-r border-ui-border-base pr-2.5", children: (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center p-0.5 bg-ui-bg-subtle border border-ui-border-base rounded-lg gap-0.5", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => (onSelectDirection ? onSelectDirection("TB") : onToggleDirection()), className: `px-2 py-0.5 text-xs font-medium rounded-md transition-all ${direction === "TB"
|
|
9
|
+
? "bg-ui-bg-base text-ui-fg-base shadow-xs font-semibold"
|
|
10
|
+
: "text-ui-fg-muted hover:text-ui-fg-base"}`, title: "Vertical Tree Layout (Default)", children: "\u2B07 Vertical" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => (onSelectDirection ? onSelectDirection("LR") : onToggleDirection()), className: `px-2 py-0.5 text-xs font-medium rounded-md transition-all ${direction === "LR"
|
|
11
|
+
? "bg-ui-bg-base text-ui-fg-base shadow-xs font-semibold"
|
|
12
|
+
: "text-ui-fg-muted hover:text-ui-fg-base"}`, title: "Mind Map Layout (Horizontal)", children: "\u27A1 Mind Map" })] }) }), (0, jsx_runtime_1.jsxs)("div", { className: "relative w-36 sm:w-44", children: [(0, jsx_runtime_1.jsx)("input", { type: "text", placeholder: "Search categories\u2026", value: searchQuery, onChange: (e) => onSearchChange(e.target.value), className: "w-full text-xs pl-7 pr-2.5 py-1 rounded-lg bg-ui-bg-subtle border border-ui-border-base text-ui-fg-base focus:bg-ui-bg-base focus:border-ui-border-interactive focus:outline-none transition-all placeholder:text-ui-fg-muted" }), (0, jsx_runtime_1.jsx)(icons_1.MagnifyingGlass, { className: "absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-ui-fg-muted pointer-events-none" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 border-l border-ui-border-base pl-2.5", children: [selectedCount > 0 ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", title: `Expand selected category (${selectedCount} selected)`, onClick: onExpandSelected || onExpandAll, className: "px-2 py-1 text-xs font-medium bg-ui-bg-interactive text-ui-fg-on-color rounded-md transition-colors", children: ["Expand (", selectedCount, ")"] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", title: `Collapse selected category (${selectedCount} selected)`, onClick: onCollapseSelected || onCollapseAll, className: "px-2 py-1 text-xs font-medium bg-ui-bg-subtle text-ui-fg-base hover:bg-ui-bg-base border border-ui-border-base rounded-md transition-colors", children: ["Collapse (", selectedCount, ")"] })] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { type: "button", title: "Expand All Categories", onClick: onExpandAll, className: "px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors", children: "Expand All" }), (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Collapse All Subcategories", onClick: onCollapseAll, className: "px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors", children: "Collapse All" })] })), (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Focus on Root Categories", onClick: onFocusRoot, className: "px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors", children: "Root" })] }), onToggleBoxSelect && ((0, jsx_runtime_1.jsx)("div", { className: "flex items-center border-l border-ui-border-base pl-2.5", children: (0, jsx_runtime_1.jsx)("button", { type: "button", title: isBoxSelect ? "Box Selection Active (Drag to outline cards). Click to switch to Pan" : "Click to enable Box Select marquee (Outline cards to move together)", onClick: onToggleBoxSelect, className: `px-2 py-1 text-xs font-medium rounded-md transition-colors ${isBoxSelect
|
|
9
13
|
? "bg-blue-600 text-white shadow-xs"
|
|
10
|
-
: "text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle"}`, children: "\u26F6 Box Select" }) })), (0, jsx_runtime_1.
|
|
14
|
+
: "text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle"}`, children: "\u26F6 Box Select" }) })), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 border-l border-ui-border-base pl-2.5", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", title: "Zoom In", onClick: onZoomIn, className: "w-5 h-5 flex items-center justify-center text-xs font-bold text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded transition-colors", children: "+" }), (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Zoom Out", onClick: onZoomOut, className: "w-5 h-5 flex items-center justify-center text-xs font-bold text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded transition-colors", children: "\u2212" }), (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Fit View (Center)", onClick: onFitView, className: "p-1 text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors", children: (0, jsx_runtime_1.jsx)(icons_1.ArrowsPointingOut, { className: "w-3.5 h-3.5" }) })] }), (0, jsx_runtime_1.jsx)("div", { className: "border-l border-ui-border-base pl-2.5", children: (0, jsx_runtime_1.jsxs)(ui_1.Button, { size: "small", variant: "secondary", onClick: onOpenCreateRoot, className: "flex items-center gap-1 text-xs h-7", children: [(0, jsx_runtime_1.jsx)(icons_1.Plus, { className: "w-3.5 h-3.5" }), (0, jsx_runtime_1.jsx)("span", { children: "New Root" })] }) })] }), dirtyCount > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3 px-4 py-1.5 bg-blue-600 text-white rounded-full shadow-lg text-xs font-medium animate-in fade-in slide-in-from-top-2 duration-200", children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-1.5", children: [(0, jsx_runtime_1.jsx)("span", { className: "w-2 h-2 rounded-full bg-white animate-pulse" }), (0, jsx_runtime_1.jsxs)("span", { children: [dirtyCount, " categor", dirtyCount === 1 ? "y" : "ies", " repositioned"] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 border-l border-blue-400 pl-2", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onReset, disabled: isSaving, className: "px-2 py-0.5 rounded hover:bg-blue-700 transition-colors text-blue-100 hover:text-white", children: "Reset" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onSave, disabled: isSaving, className: "px-3 py-0.5 rounded-full bg-white text-blue-700 font-semibold hover:bg-blue-50 transition-colors shadow-xs", children: isSaving ? "Saving…" : "Save Hierarchy" })] })] }))] }));
|
|
11
15
|
};
|
|
12
16
|
exports.CanvasDock = CanvasDock;
|
|
@@ -1,8 +1,41 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.CategoryProductsManager = void 0;
|
|
4
37
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
-
const react_1 = require("react");
|
|
38
|
+
const react_1 = __importStar(require("react"));
|
|
6
39
|
const ui_1 = require("@medusajs/ui");
|
|
7
40
|
const icons_1 = require("@medusajs/icons");
|
|
8
41
|
const sdk_1 = require("../../lib/sdk");
|
|
@@ -26,6 +59,10 @@ const CategoryProductsManager = ({ categoryId, categoryName, initialMetadata, on
|
|
|
26
59
|
const [catalogSelectedIds, setCatalogSelectedIds] = (0, react_1.useState)(new Set());
|
|
27
60
|
const [isAdding, setIsAdding] = (0, react_1.useState)(false);
|
|
28
61
|
const PAGE_SIZE = 50;
|
|
62
|
+
const orderedProductIdsRef = react_1.default.useRef(orderedProductIds);
|
|
63
|
+
(0, react_1.useEffect)(() => {
|
|
64
|
+
orderedProductIdsRef.current = orderedProductIds;
|
|
65
|
+
}, [orderedProductIds]);
|
|
29
66
|
// Initialize ranking and top sellers from category metadata
|
|
30
67
|
(0, react_1.useEffect)(() => {
|
|
31
68
|
if (initialMetadata) {
|
|
@@ -45,8 +82,8 @@ const CategoryProductsManager = ({ categoryId, categoryName, initialMetadata, on
|
|
|
45
82
|
const fetched = res.products || [];
|
|
46
83
|
setTotalCount(res.count ?? fetched.length);
|
|
47
84
|
// Sort products according to orderedProductIds if available
|
|
48
|
-
setProducts((
|
|
49
|
-
const orderMap = new Map(
|
|
85
|
+
setProducts(() => {
|
|
86
|
+
const orderMap = new Map((orderedProductIdsRef.current || []).map((id, index) => [id, index]));
|
|
50
87
|
return [...fetched].sort((a, b) => {
|
|
51
88
|
const indexA = orderMap.has(a.id) ? orderMap.get(a.id) : 9999;
|
|
52
89
|
const indexB = orderMap.has(b.id) ? orderMap.get(b.id) : 9999;
|
|
@@ -60,7 +97,7 @@ const CategoryProductsManager = ({ categoryId, categoryName, initialMetadata, on
|
|
|
60
97
|
finally {
|
|
61
98
|
setIsLoading(false);
|
|
62
99
|
}
|
|
63
|
-
}, [categoryId
|
|
100
|
+
}, [categoryId]);
|
|
64
101
|
(0, react_1.useEffect)(() => {
|
|
65
102
|
loadAssignedProducts();
|
|
66
103
|
}, [loadAssignedProducts]);
|
|
@@ -234,13 +271,29 @@ const CategoryProductsManager = ({ categoryId, categoryName, initialMetadata, on
|
|
|
234
271
|
next.add(p.id);
|
|
235
272
|
return next;
|
|
236
273
|
});
|
|
237
|
-
}, className: "rounded border-ui-border-base cursor-pointer" }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 font-mono", children: [(0, jsx_runtime_1.jsxs)("span", { className: "text-xs font-bold text-ui-fg-subtle px-1.5 py-0.5 rounded bg-ui-bg-subtle border border-ui-border-base", children: ["#", index + 1] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", title: "Move Up", disabled: index === 0, onClick: () =>
|
|
274
|
+
}, className: "rounded border-ui-border-base cursor-pointer" }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 font-mono", children: [(0, jsx_runtime_1.jsxs)("span", { className: "text-xs font-bold text-ui-fg-subtle px-1.5 py-0.5 rounded bg-ui-bg-subtle border border-ui-border-base", children: ["#", index + 1] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", title: "Move Up", disabled: index === 0, onClick: (e) => {
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
e.stopPropagation();
|
|
277
|
+
handleMoveProduct(index, "up");
|
|
278
|
+
}, className: "text-[9px] text-ui-fg-muted hover:text-ui-fg-base disabled:opacity-20 px-0.5 leading-none cursor-pointer", children: "\u25B2" }), (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Move Down", disabled: index === displayedProducts.length - 1, onClick: (e) => {
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
e.stopPropagation();
|
|
281
|
+
handleMoveProduct(index, "down");
|
|
282
|
+
}, className: "text-[9px] text-ui-fg-muted hover:text-ui-fg-base disabled:opacity-20 px-0.5 leading-none cursor-pointer", children: "\u25BC" })] })] }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2.5 min-w-0", children: [(0, jsx_runtime_1.jsx)("div", { className: "w-9 h-9 rounded-md border border-ui-border-base overflow-hidden bg-ui-bg-subtle flex-shrink-0 flex items-center justify-center", children: p.thumbnail ? ((0, jsx_runtime_1.jsx)("img", { src: p.thumbnail, alt: p.title, className: "w-full h-full object-cover" })) : ((0, jsx_runtime_1.jsx)("span", { className: "text-[9px] text-ui-fg-muted", children: "IMG" })) }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-col min-w-0", children: (0, jsx_runtime_1.jsx)("span", { className: "font-semibold text-ui-fg-base truncate leading-snug", title: p.title, children: p.title }) })] }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: (e) => {
|
|
283
|
+
e.preventDefault();
|
|
284
|
+
e.stopPropagation();
|
|
285
|
+
handleToggleTopSeller(p.id, p.title);
|
|
286
|
+
}, className: `inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold border transition-all ${isTopSeller
|
|
238
287
|
? "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-300 dark:border-amber-700 shadow-2xs"
|
|
239
288
|
: "bg-ui-bg-subtle text-ui-fg-muted border-ui-border-base hover:text-amber-600 hover:border-amber-300"}`, title: isTopSeller
|
|
240
289
|
? "Click to remove from Top Commercial Sellers"
|
|
241
290
|
: "Click to mark as Top Commercial Seller (Reflected in Storefront 2)", children: [(0, jsx_runtime_1.jsx)("span", { children: isTopSeller ? "★" : "☆" }), (0, jsx_runtime_1.jsx)("span", { children: isTopSeller ? "Top Seller" : "Normal" })] }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3 text-ui-fg-muted", children: p.collection?.title || "-" }), (0, jsx_runtime_1.jsxs)("td", { className: "py-2.5 px-3 text-ui-fg-subtle", children: [p.variants?.length || 0, " variant(s)"] }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsx)("span", { className: `inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${p.status === "published"
|
|
242
291
|
? "bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300"
|
|
243
|
-
: "bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400"}`, children: p.status }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3 text-right", children: (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Remove from category", onClick: () =>
|
|
292
|
+
: "bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400"}`, children: p.status }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3 text-right", children: (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Remove from category", onClick: (e) => {
|
|
293
|
+
e.preventDefault();
|
|
294
|
+
e.stopPropagation();
|
|
295
|
+
handleRemoveProducts([p.id]);
|
|
296
|
+
}, className: "p-1 text-ui-fg-muted hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded transition-colors", children: (0, jsx_runtime_1.jsx)(icons_1.Trash, { className: "w-3.5 h-3.5" }) }) })] }, p.id));
|
|
244
297
|
}) })] })) }), focusModalOpen && ((0, jsx_runtime_1.jsxs)("div", { className: "fixed inset-0 z-50 flex flex-col bg-white dark:bg-neutral-950 animate-in fade-in-50", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between px-6 py-3 border-b border-ui-border-base bg-ui-bg-base flex-shrink-0", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setFocusModalOpen(false), className: "p-1.5 rounded-lg text-ui-fg-muted hover:text-ui-fg-base hover:bg-ui-bg-subtle transition-colors", title: "Close (ESC)", children: (0, jsx_runtime_1.jsx)("span", { className: "text-sm font-bold", children: "\u2715" }) }), (0, jsx_runtime_1.jsx)("span", { className: "text-[11px] font-mono font-medium text-ui-fg-muted px-1.5 py-0.5 rounded bg-ui-bg-subtle border border-ui-border-base", children: "ESC" }), (0, jsx_runtime_1.jsxs)("h2", { className: "text-sm font-semibold text-ui-fg-base", children: ["Add Products to Category: ", categoryName] })] }), (0, jsx_runtime_1.jsx)("div", { className: "flex items-center gap-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "relative w-64", children: [(0, jsx_runtime_1.jsx)("input", { type: "text", placeholder: "Search products\u2026", value: searchQuery, onChange: (e) => {
|
|
245
298
|
setSearchQuery(e.target.value);
|
|
246
299
|
setCatalogPage(0);
|
|
@@ -265,12 +265,12 @@ const CanvasInner = () => {
|
|
|
265
265
|
};
|
|
266
266
|
return ((0, jsx_runtime_1.jsxs)("div", { className: "relative w-full h-[calc(100vh-56px)] overflow-hidden bg-[#fafafa] dark:bg-[#09090b]", children: [(0, jsx_runtime_1.jsx)("div", { className: `absolute top-4 left-1/2 -translate-x-1/2 z-10 transition-all duration-200 pointer-events-none ${editDrawerOpen || createDrawerOpen
|
|
267
267
|
? "opacity-0 -translate-y-6 scale-95 pointer-events-none"
|
|
268
|
-
: "opacity-100"}`, children: (0, jsx_runtime_1.jsx)(canvas_dock_1.CanvasDock, { salesChannels: salesChannels, selectedStorefront: selectedStorefront, onSelectStorefront: setSelectedStorefront, direction: direction, onToggleDirection: () => setDirection((d) => (d === "TB" ? "LR" : "TB")), searchQuery: searchQuery, onSearchChange: setSearchQuery, onExpandAll: handleExpandAll, onCollapseAll: handleCollapseAll, onFocusRoot: handleFocusRoot, selectedCount: selectedNodeIds.length, onExpandSelected: handleExpandSelected, onCollapseSelected: handleCollapseSelected, isBoxSelect: isBoxSelect, onToggleBoxSelect: () => setIsBoxSelect((prev) => !prev), onZoomIn: () => zoomIn({ duration: 300 }), onZoomOut: () => zoomOut({ duration: 300 }), onFitView: () => fitView({ padding: 0.25, duration: 400 }), onOpenCreateRoot: () => {
|
|
268
|
+
: "opacity-100"}`, children: (0, jsx_runtime_1.jsx)(canvas_dock_1.CanvasDock, { salesChannels: salesChannels, selectedStorefront: selectedStorefront, onSelectStorefront: setSelectedStorefront, direction: direction, onToggleDirection: () => setDirection((d) => (d === "TB" ? "LR" : "TB")), onSelectDirection: (dir) => setDirection(dir), searchQuery: searchQuery, onSearchChange: setSearchQuery, onExpandAll: handleExpandAll, onCollapseAll: handleCollapseAll, onFocusRoot: handleFocusRoot, selectedCount: selectedNodeIds.length, onExpandSelected: handleExpandSelected, onCollapseSelected: handleCollapseSelected, isBoxSelect: isBoxSelect, onToggleBoxSelect: () => setIsBoxSelect((prev) => !prev), onZoomIn: () => zoomIn({ duration: 300 }), onZoomOut: () => zoomOut({ duration: 300 }), onFitView: () => fitView({ padding: 0.25, duration: 400 }), onOpenCreateRoot: () => {
|
|
269
269
|
setCreateParentCategory(null);
|
|
270
270
|
setCreateDrawerOpen(true);
|
|
271
271
|
}, dirtyCount: dirtyMoves.length, isSaving: isSaving, onSave: handleSave, onReset: handleReset }) }), (0, jsx_runtime_1.jsxs)(react_2.ReactFlow, { nodes: nodes, edges: edges, onNodesChange: onNodesChange, onEdgesChange: onEdgesChange, onConnect: onConnect, onEdgesDelete: onEdgesDelete, onSelectionChange: (0, react_1.useCallback)(({ nodes }) => {
|
|
272
272
|
setSelectedNodeIds(nodes.map((n) => n.id));
|
|
273
|
-
}, []), selectionMode: react_2.SelectionMode.Partial, selectionOnDrag: isBoxSelect, panOnDrag: isBoxSelect ? [1, 2] : true, elementsSelectable: true, nodeTypes: nodeTypes, edgeTypes: edgeTypes, minZoom: 0.2, maxZoom: 2.0, defaultEdgeOptions: { type: "smoothstep" }, fitView: true, proOptions: { hideAttribution: true }, className: "touch-none", children: [(0, jsx_runtime_1.jsx)(react_2.Background, { color: "#cbd5e1", gap: 20, size: 1.25 }), (0, jsx_runtime_1.jsx)(react_2.Controls, { showInteractive: false, className: "!bottom-4 !left-4 !bg-
|
|
273
|
+
}, []), selectionMode: react_2.SelectionMode.Partial, selectionOnDrag: isBoxSelect, panOnDrag: isBoxSelect ? [1, 2] : true, elementsSelectable: true, nodeTypes: nodeTypes, edgeTypes: edgeTypes, minZoom: 0.2, maxZoom: 2.0, defaultEdgeOptions: { type: "smoothstep" }, fitView: true, proOptions: { hideAttribution: true }, className: "touch-none", children: [(0, jsx_runtime_1.jsx)(react_2.Background, { color: "#cbd5e1", gap: 20, size: 1.25 }), (0, jsx_runtime_1.jsx)(react_2.Controls, { showInteractive: false, className: "!bottom-4 !left-4 !bg-[#18181b] !border !border-neutral-700 !rounded-xl !shadow-2xl !overflow-hidden [&_.react-flow\\_\\_controls-button]:!bg-[#18181b] [&_.react-flow\\_\\_controls-button]:!border-b [&_.react-flow\\_\\_controls-button]:!border-neutral-800 [&_.react-flow\\_\\_controls-button:hover]:!bg-[#27272a] [&_.react-flow\\_\\_controls-button_svg]:!fill-white [&_.react-flow\\_\\_controls-button_svg]:!stroke-white [&_.react-flow\\_\\_controls-button_path]:!fill-white" }), (0, jsx_runtime_1.jsx)(react_2.MiniMap, { nodeColor: () => "var(--bg-interactive, #3b82f6)", maskColor: "rgba(0, 0, 0, 0.25)", className: "!bottom-4 !right-4 !bg-[#18181b]/95 !backdrop-blur-md !border !border-neutral-700 !rounded-xl !shadow-xl !overflow-hidden", zoomable: true, pannable: true })] }), (0, jsx_runtime_1.jsx)(create_category_drawer_1.CreateCategoryDrawer, { open: createDrawerOpen, onOpenChange: setCreateDrawerOpen, parentCategory: createParentCategory, salesChannels: salesChannels, onSuccess: (newCat) => {
|
|
274
274
|
setCategories((prev) => [...prev, newCat]);
|
|
275
275
|
setOriginalCategories((prev) => [...prev, newCat]);
|
|
276
276
|
} }), (0, jsx_runtime_1.jsx)(edit_category_drawer_1.EditCategoryDrawer, { open: editDrawerOpen, onOpenChange: setEditDrawerOpen, category: editingCategory, salesChannels: salesChannels, onSuccess: (updatedCat) => {
|
package/README.md
CHANGED
|
@@ -47,7 +47,9 @@ Clicking the edit pencil icon on any node opens a wide slide-over hub featuring
|
|
|
47
47
|
- **Full-Screen FocusModal:** Direct parity with Medusa's legacy Add Products modal with full-width table, search (`Q Search`), pagination, and batch assignment.
|
|
48
48
|
3. **Storefronts & Media:**
|
|
49
49
|
- Multi-sales channel visibility checkboxes (`Monfil Store`, `Superpunch`, etc.).
|
|
50
|
-
-
|
|
50
|
+
- **Native Drag-and-Drop Media Upload:** Integrated with Medusa v2's native File Module (`sdk.admin.upload.create`).
|
|
51
|
+
- **Visual Gallery Manager:** Image preview cards, delete actions, and one-click `★ Thumbnail` designation.
|
|
52
|
+
- **Direct External URL Input:** Fallback support for external CDN, Cloudinary, or DAM image links.
|
|
51
53
|
4. **YouTube Videos:**
|
|
52
54
|
- Multi-video demonstration manager with real-time thumbnail preview cards and quick remove actions.
|
|
53
55
|
|
|
@@ -156,6 +158,114 @@ if (topSellerIds.length > 0) {
|
|
|
156
158
|
|
|
157
159
|
---
|
|
158
160
|
|
|
161
|
+
## ☁️ Media Upload & Storage Provider Compatibility
|
|
162
|
+
|
|
163
|
+
The plugin's Category Detail Hub features a unified media uploader and gallery manager. To ensure zero vendor lock-in, file handling is **100% storage-provider agnostic**:
|
|
164
|
+
|
|
165
|
+
1. **Native Medusa File Module Pipeline:**
|
|
166
|
+
- Uploads execute via Medusa v2's native SDK (`sdk.admin.upload.create({ files })`), routing directly to `POST /admin/uploads`.
|
|
167
|
+
- Your backend delegates the upload to whichever File Module Provider is configured in `medusa-config.js` and returns the CDN URL.
|
|
168
|
+
2. **Direct External URL / CDN Fallback:**
|
|
169
|
+
- If your assets are hosted on an external DAM, Cloudinary, Imgix, or third-party S3 bucket, you can simply paste the image URL directly into the uploader and click **Add**.
|
|
170
|
+
|
|
171
|
+
### 💡 Category Media in Medusa v2: Native vs. Plugin Architecture
|
|
172
|
+
|
|
173
|
+
In core Medusa v2, products have native media tables (`product_images`), but **`product_category` does not have a native images relationship out of the box**.
|
|
174
|
+
|
|
175
|
+
To solve this universally without imposing mandatory database migrations on standard Medusa stores, this plugin employs an **adaptive hybrid architecture**:
|
|
176
|
+
|
|
177
|
+
- **Universal Metadata Mode (Zero Config / Out of the Box):**
|
|
178
|
+
- Stores all gallery assets and active thumbnail directly inside Medusa's native `category.metadata`:
|
|
179
|
+
```json
|
|
180
|
+
{
|
|
181
|
+
"metadata": {
|
|
182
|
+
"image_url": "https://cdn.store.com/category-thumb.webp",
|
|
183
|
+
"images": [
|
|
184
|
+
{ "id": "img_01", "url": "https://cdn.store.com/hero.webp", "type": "thumbnail" },
|
|
185
|
+
{ "id": "img_02", "url": "https://cdn.store.com/banner.webp", "type": "image" }
|
|
186
|
+
]
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
- **Zero backend code or database migrations required.** Any standard Medusa v2 installation supports this immediately.
|
|
191
|
+
|
|
192
|
+
- **Custom Backend Module Mode (Optional Extended Architecture):**
|
|
193
|
+
- If your backend has extended the Medusa framework with a custom Category Media module (e.g., custom DML model `product_category_image` exposing `/admin/categories/:category_id/images`), the plugin detects this endpoint and automatically synchronizes both the custom relation and metadata!
|
|
194
|
+
|
|
195
|
+
### Configuring S3-Compatible Storage in Medusa v2
|
|
196
|
+
|
|
197
|
+
Because Medusa abstracts storage through `@medusajs/medusa/file-s3`, you can seamlessly route category media to **AWS S3, Cloudflare R2, MinIO, or DigitalOcean Spaces** by adjusting `medusa-config.js`:
|
|
198
|
+
|
|
199
|
+
#### AWS S3
|
|
200
|
+
```javascript
|
|
201
|
+
// medusa-config.js
|
|
202
|
+
module.exports = defineConfig({
|
|
203
|
+
modules: [
|
|
204
|
+
{
|
|
205
|
+
resolve: "@medusajs/medusa/file",
|
|
206
|
+
options: {
|
|
207
|
+
providers: [
|
|
208
|
+
{
|
|
209
|
+
resolve: "@medusajs/medusa/file-s3",
|
|
210
|
+
id: "s3",
|
|
211
|
+
options: {
|
|
212
|
+
file_url: process.env.MEDUSA_FILE_URL, // CDN or bucket public URL
|
|
213
|
+
access_key_id: process.env.S3_ACCESS_KEY_ID,
|
|
214
|
+
secret_access_key: process.env.S3_SECRET_ACCESS_KEY,
|
|
215
|
+
region: process.env.S3_REGION,
|
|
216
|
+
bucket: process.env.S3_BUCKET,
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
})
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
#### Cloudflare R2
|
|
227
|
+
```javascript
|
|
228
|
+
// medusa-config.js
|
|
229
|
+
options: {
|
|
230
|
+
file_url: process.env.R2_PUBLIC_URL, // e.g. https://pub-xxxx.r2.dev or custom domain
|
|
231
|
+
access_key_id: process.env.R2_ACCESS_KEY_ID,
|
|
232
|
+
secret_access_key: process.env.R2_SECRET_ACCESS_KEY,
|
|
233
|
+
bucket: process.env.R2_BUCKET_NAME,
|
|
234
|
+
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
|
235
|
+
region: "auto",
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
#### MinIO (Self-Hosted S3)
|
|
240
|
+
```javascript
|
|
241
|
+
// medusa-config.js
|
|
242
|
+
options: {
|
|
243
|
+
file_url: process.env.MINIO_PUBLIC_URL, // e.g. http://localhost:9000/my-bucket
|
|
244
|
+
access_key_id: process.env.MINIO_ROOT_USER,
|
|
245
|
+
secret_access_key: process.env.MINIO_ROOT_PASSWORD,
|
|
246
|
+
bucket: process.env.MINIO_BUCKET,
|
|
247
|
+
endpoint: process.env.MINIO_ENDPOINT, // e.g. http://127.0.0.1:9000
|
|
248
|
+
additional_client_config: {
|
|
249
|
+
forcePathStyle: true, // Required for MinIO
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
#### DigitalOcean Spaces
|
|
255
|
+
```javascript
|
|
256
|
+
// medusa-config.js
|
|
257
|
+
options: {
|
|
258
|
+
file_url: `https://${process.env.DO_SPACES_BUCKET}.${process.env.DO_SPACES_REGION}.digitaloceanspaces.com`,
|
|
259
|
+
access_key_id: process.env.DO_SPACES_KEY,
|
|
260
|
+
secret_access_key: process.env.DO_SPACES_SECRET,
|
|
261
|
+
bucket: process.env.DO_SPACES_BUCKET,
|
|
262
|
+
endpoint: `https://${process.env.DO_SPACES_REGION}.digitaloceanspaces.com`,
|
|
263
|
+
region: process.env.DO_SPACES_REGION,
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
159
269
|
## 🏗️ Architecture & Project Structure
|
|
160
270
|
|
|
161
271
|
```text
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hectasquare/medusa-plugin-category-tree",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "AAA Interactive Category Tree & Mind Map for Medusa v2 Admin with drag-and-drop reparenting, tier indicators, superscript ranks, multi-storefront filtering, and NotebookLM-style subtree collapse.",
|
|
5
5
|
"author": "HectaSquare Team",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -14,6 +14,7 @@ export interface CanvasDockProps {
|
|
|
14
14
|
|
|
15
15
|
direction: "TB" | "LR"
|
|
16
16
|
onToggleDirection: () => void
|
|
17
|
+
onSelectDirection?: (val: "TB" | "LR") => void
|
|
17
18
|
|
|
18
19
|
searchQuery: string
|
|
19
20
|
onSearchChange: (q: string) => void
|
|
@@ -47,6 +48,7 @@ export const CanvasDock: React.FC<CanvasDockProps> = ({
|
|
|
47
48
|
onSelectStorefront,
|
|
48
49
|
direction,
|
|
49
50
|
onToggleDirection,
|
|
51
|
+
onSelectDirection,
|
|
50
52
|
searchQuery,
|
|
51
53
|
onSearchChange,
|
|
52
54
|
onExpandAll,
|
|
@@ -57,6 +59,8 @@ export const CanvasDock: React.FC<CanvasDockProps> = ({
|
|
|
57
59
|
onCollapseSelected,
|
|
58
60
|
isBoxSelect = false,
|
|
59
61
|
onToggleBoxSelect,
|
|
62
|
+
onZoomIn,
|
|
63
|
+
onZoomOut,
|
|
60
64
|
onFitView,
|
|
61
65
|
onOpenCreateRoot,
|
|
62
66
|
dirtyCount,
|
|
@@ -86,14 +90,32 @@ export const CanvasDock: React.FC<CanvasDockProps> = ({
|
|
|
86
90
|
</div>
|
|
87
91
|
|
|
88
92
|
<div className="flex items-center border-r border-ui-border-base pr-2.5">
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
<div className="flex items-center p-0.5 bg-ui-bg-subtle border border-ui-border-base rounded-lg gap-0.5">
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={() => (onSelectDirection ? onSelectDirection("TB") : onToggleDirection())}
|
|
97
|
+
className={`px-2 py-0.5 text-xs font-medium rounded-md transition-all ${
|
|
98
|
+
direction === "TB"
|
|
99
|
+
? "bg-ui-bg-base text-ui-fg-base shadow-xs font-semibold"
|
|
100
|
+
: "text-ui-fg-muted hover:text-ui-fg-base"
|
|
101
|
+
}`}
|
|
102
|
+
title="Vertical Tree Layout (Default)"
|
|
103
|
+
>
|
|
104
|
+
⬇ Vertical
|
|
105
|
+
</button>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={() => (onSelectDirection ? onSelectDirection("LR") : onToggleDirection())}
|
|
109
|
+
className={`px-2 py-0.5 text-xs font-medium rounded-md transition-all ${
|
|
110
|
+
direction === "LR"
|
|
111
|
+
? "bg-ui-bg-base text-ui-fg-base shadow-xs font-semibold"
|
|
112
|
+
: "text-ui-fg-muted hover:text-ui-fg-base"
|
|
113
|
+
}`}
|
|
114
|
+
title="Mind Map Layout (Horizontal)"
|
|
115
|
+
>
|
|
116
|
+
➡ Mind Map
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
97
119
|
</div>
|
|
98
120
|
|
|
99
121
|
<div className="relative w-36 sm:w-44">
|
|
@@ -178,6 +200,22 @@ export const CanvasDock: React.FC<CanvasDockProps> = ({
|
|
|
178
200
|
)}
|
|
179
201
|
|
|
180
202
|
<div className="flex items-center gap-1 border-l border-ui-border-base pl-2.5">
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
title="Zoom In"
|
|
206
|
+
onClick={onZoomIn}
|
|
207
|
+
className="w-5 h-5 flex items-center justify-center text-xs font-bold text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded transition-colors"
|
|
208
|
+
>
|
|
209
|
+
+
|
|
210
|
+
</button>
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
title="Zoom Out"
|
|
214
|
+
onClick={onZoomOut}
|
|
215
|
+
className="w-5 h-5 flex items-center justify-center text-xs font-bold text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded transition-colors"
|
|
216
|
+
>
|
|
217
|
+
−
|
|
218
|
+
</button>
|
|
181
219
|
<button
|
|
182
220
|
type="button"
|
|
183
221
|
title="Fit View (Center)"
|
|
@@ -59,6 +59,11 @@ export const CategoryProductsManager: React.FC<CategoryProductsManagerProps> = (
|
|
|
59
59
|
|
|
60
60
|
const PAGE_SIZE = 50
|
|
61
61
|
|
|
62
|
+
const orderedProductIdsRef = React.useRef<string[]>(orderedProductIds)
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
orderedProductIdsRef.current = orderedProductIds
|
|
65
|
+
}, [orderedProductIds])
|
|
66
|
+
|
|
62
67
|
// Initialize ranking and top sellers from category metadata
|
|
63
68
|
useEffect(() => {
|
|
64
69
|
if (initialMetadata) {
|
|
@@ -82,8 +87,8 @@ export const CategoryProductsManager: React.FC<CategoryProductsManagerProps> = (
|
|
|
82
87
|
setTotalCount(res.count ?? fetched.length)
|
|
83
88
|
|
|
84
89
|
// Sort products according to orderedProductIds if available
|
|
85
|
-
setProducts((
|
|
86
|
-
const orderMap = new Map(
|
|
90
|
+
setProducts(() => {
|
|
91
|
+
const orderMap = new Map((orderedProductIdsRef.current || []).map((id, index) => [id, index]))
|
|
87
92
|
return [...fetched].sort((a, b) => {
|
|
88
93
|
const indexA = orderMap.has(a.id) ? orderMap.get(a.id)! : 9999
|
|
89
94
|
const indexB = orderMap.has(b.id) ? orderMap.get(b.id)! : 9999
|
|
@@ -95,7 +100,7 @@ export const CategoryProductsManager: React.FC<CategoryProductsManagerProps> = (
|
|
|
95
100
|
} finally {
|
|
96
101
|
setIsLoading(false)
|
|
97
102
|
}
|
|
98
|
-
}, [categoryId
|
|
103
|
+
}, [categoryId])
|
|
99
104
|
|
|
100
105
|
useEffect(() => {
|
|
101
106
|
loadAssignedProducts()
|
|
@@ -404,8 +409,12 @@ export const CategoryProductsManager: React.FC<CategoryProductsManagerProps> = (
|
|
|
404
409
|
type="button"
|
|
405
410
|
title="Move Up"
|
|
406
411
|
disabled={index === 0}
|
|
407
|
-
onClick={() =>
|
|
408
|
-
|
|
412
|
+
onClick={(e) => {
|
|
413
|
+
e.preventDefault()
|
|
414
|
+
e.stopPropagation()
|
|
415
|
+
handleMoveProduct(index, "up")
|
|
416
|
+
}}
|
|
417
|
+
className="text-[9px] text-ui-fg-muted hover:text-ui-fg-base disabled:opacity-20 px-0.5 leading-none cursor-pointer"
|
|
409
418
|
>
|
|
410
419
|
▲
|
|
411
420
|
</button>
|
|
@@ -413,8 +422,12 @@ export const CategoryProductsManager: React.FC<CategoryProductsManagerProps> = (
|
|
|
413
422
|
type="button"
|
|
414
423
|
title="Move Down"
|
|
415
424
|
disabled={index === displayedProducts.length - 1}
|
|
416
|
-
onClick={() =>
|
|
417
|
-
|
|
425
|
+
onClick={(e) => {
|
|
426
|
+
e.preventDefault()
|
|
427
|
+
e.stopPropagation()
|
|
428
|
+
handleMoveProduct(index, "down")
|
|
429
|
+
}}
|
|
430
|
+
className="text-[9px] text-ui-fg-muted hover:text-ui-fg-base disabled:opacity-20 px-0.5 leading-none cursor-pointer"
|
|
418
431
|
>
|
|
419
432
|
▼
|
|
420
433
|
</button>
|
|
@@ -444,7 +457,11 @@ export const CategoryProductsManager: React.FC<CategoryProductsManagerProps> = (
|
|
|
444
457
|
<td className="py-2.5 px-3">
|
|
445
458
|
<button
|
|
446
459
|
type="button"
|
|
447
|
-
onClick={() =>
|
|
460
|
+
onClick={(e) => {
|
|
461
|
+
e.preventDefault()
|
|
462
|
+
e.stopPropagation()
|
|
463
|
+
handleToggleTopSeller(p.id, p.title)
|
|
464
|
+
}}
|
|
448
465
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold border transition-all ${
|
|
449
466
|
isTopSeller
|
|
450
467
|
? "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-300 dark:border-amber-700 shadow-2xs"
|
|
@@ -485,7 +502,11 @@ export const CategoryProductsManager: React.FC<CategoryProductsManagerProps> = (
|
|
|
485
502
|
<button
|
|
486
503
|
type="button"
|
|
487
504
|
title="Remove from category"
|
|
488
|
-
onClick={() =>
|
|
505
|
+
onClick={(e) => {
|
|
506
|
+
e.preventDefault()
|
|
507
|
+
e.stopPropagation()
|
|
508
|
+
handleRemoveProducts([p.id])
|
|
509
|
+
}}
|
|
489
510
|
className="p-1 text-ui-fg-muted hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded transition-colors"
|
|
490
511
|
>
|
|
491
512
|
<Trash className="w-3.5 h-3.5" />
|
|
@@ -357,6 +357,7 @@ const CanvasInner: React.FC = () => {
|
|
|
357
357
|
onSelectStorefront={setSelectedStorefront}
|
|
358
358
|
direction={direction}
|
|
359
359
|
onToggleDirection={() => setDirection((d) => (d === "TB" ? "LR" : "TB"))}
|
|
360
|
+
onSelectDirection={(dir) => setDirection(dir)}
|
|
360
361
|
searchQuery={searchQuery}
|
|
361
362
|
onSearchChange={setSearchQuery}
|
|
362
363
|
onExpandAll={handleExpandAll}
|
|
@@ -407,12 +408,12 @@ const CanvasInner: React.FC = () => {
|
|
|
407
408
|
<Background color="#cbd5e1" gap={20} size={1.25} />
|
|
408
409
|
<Controls
|
|
409
410
|
showInteractive={false}
|
|
410
|
-
className="!bottom-4 !left-4 !bg-
|
|
411
|
+
className="!bottom-4 !left-4 !bg-[#18181b] !border !border-neutral-700 !rounded-xl !shadow-2xl !overflow-hidden [&_.react-flow\_\_controls-button]:!bg-[#18181b] [&_.react-flow\_\_controls-button]:!border-b [&_.react-flow\_\_controls-button]:!border-neutral-800 [&_.react-flow\_\_controls-button:hover]:!bg-[#27272a] [&_.react-flow\_\_controls-button_svg]:!fill-white [&_.react-flow\_\_controls-button_svg]:!stroke-white [&_.react-flow\_\_controls-button_path]:!fill-white"
|
|
411
412
|
/>
|
|
412
413
|
<MiniMap
|
|
413
414
|
nodeColor={() => "var(--bg-interactive, #3b82f6)"}
|
|
414
|
-
maskColor="rgba(0, 0, 0, 0.
|
|
415
|
-
className="!bottom-4 !right-4 !bg-
|
|
415
|
+
maskColor="rgba(0, 0, 0, 0.25)"
|
|
416
|
+
className="!bottom-4 !right-4 !bg-[#18181b]/95 !backdrop-blur-md !border !border-neutral-700 !rounded-xl !shadow-xl !overflow-hidden"
|
|
416
417
|
zoomable
|
|
417
418
|
pannable
|
|
418
419
|
/>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect } from "react"
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef } from "react"
|
|
2
2
|
import {
|
|
3
3
|
Button,
|
|
4
4
|
Drawer,
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
Textarea,
|
|
10
10
|
toast,
|
|
11
11
|
} from "@medusajs/ui"
|
|
12
|
-
import { Trash, Plus, ArrowUpRightMicro, ArrowsPointingOut } from "@medusajs/icons"
|
|
12
|
+
import { Trash, Plus, ArrowUpRightMicro, ArrowsPointingOut, ArrowDownTray } from "@medusajs/icons"
|
|
13
13
|
import { CategoryTreeNode, SalesChannelInfo } from "./types"
|
|
14
14
|
import { CategoryProductsManager } from "./category-products-manager"
|
|
15
15
|
import { sdk } from "../../lib/sdk"
|
|
@@ -66,6 +66,11 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
66
66
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
67
67
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
68
68
|
|
|
69
|
+
// Media upload & gallery state
|
|
70
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
71
|
+
const [isUploading, setIsUploading] = useState(false)
|
|
72
|
+
const [categoryImages, setCategoryImages] = useState<Array<{ id?: string; url: string; type?: string }>>([])
|
|
73
|
+
|
|
69
74
|
useEffect(() => {
|
|
70
75
|
if (category) {
|
|
71
76
|
setName(category.name || "")
|
|
@@ -88,9 +93,142 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
88
93
|
}
|
|
89
94
|
setYoutubeUrls(urls)
|
|
90
95
|
setActiveTab("general")
|
|
96
|
+
|
|
97
|
+
// Load category media from backend
|
|
98
|
+
loadCategoryImages(category.id, category.image_url || meta?.thumbnail || meta?.image)
|
|
91
99
|
}
|
|
92
100
|
}, [category, salesChannels])
|
|
93
101
|
|
|
102
|
+
// Load existing category media gallery
|
|
103
|
+
const loadCategoryImages = useCallback(async (catId: string, currentImageUrl?: string) => {
|
|
104
|
+
try {
|
|
105
|
+
const res = await sdk.client.fetch<{ category_images?: any[] }>(
|
|
106
|
+
`/admin/categories/${catId}/images`
|
|
107
|
+
)
|
|
108
|
+
if (res?.category_images && Array.isArray(res.category_images) && res.category_images.length > 0) {
|
|
109
|
+
setCategoryImages(res.category_images)
|
|
110
|
+
const thumb = res.category_images.find((img) => img.type === "thumbnail")
|
|
111
|
+
if (thumb?.url) {
|
|
112
|
+
setImageUrl(thumb.url)
|
|
113
|
+
}
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Endpoint fallback
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (currentImageUrl) {
|
|
121
|
+
setCategoryImages([{ url: currentImageUrl, type: "thumbnail" }])
|
|
122
|
+
} else {
|
|
123
|
+
setCategoryImages([])
|
|
124
|
+
}
|
|
125
|
+
}, [])
|
|
126
|
+
|
|
127
|
+
// Universal S3/Storage Media Upload Handler (works with AWS S3, MinIO, Cloudflare R2, Local)
|
|
128
|
+
const handleFileUpload = async (files: FileList | null) => {
|
|
129
|
+
if (!files || files.length === 0) return
|
|
130
|
+
setIsUploading(true)
|
|
131
|
+
try {
|
|
132
|
+
const fileList = Array.from(files)
|
|
133
|
+
// Calls standard Medusa File Module API (delegates to configured S3 provider in medusa-config.js)
|
|
134
|
+
const res = await sdk.admin.upload.create({ files: fileList })
|
|
135
|
+
const uploadedFiles = res.files || []
|
|
136
|
+
if (uploadedFiles.length === 0) {
|
|
137
|
+
throw new Error("No files returned from upload service")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const firstUrl = uploadedFiles[0].url
|
|
141
|
+
if (!imageUrl) {
|
|
142
|
+
setImageUrl(firstUrl)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// If backend has custom category images endpoint, save them there too
|
|
146
|
+
try {
|
|
147
|
+
await sdk.client.fetch(`/admin/categories/${category.id}/images`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
body: {
|
|
151
|
+
images: uploadedFiles.map((f, i) => ({
|
|
152
|
+
url: f.url,
|
|
153
|
+
type: i === 0 && !imageUrl ? "thumbnail" : "image",
|
|
154
|
+
})),
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
await loadCategoryImages(category.id, firstUrl)
|
|
158
|
+
} catch {
|
|
159
|
+
// Fallback for standard stores: update local gallery state
|
|
160
|
+
setCategoryImages((prev) => [
|
|
161
|
+
...prev,
|
|
162
|
+
...uploadedFiles.map((f, i) => ({
|
|
163
|
+
url: f.url,
|
|
164
|
+
type: i === 0 && !imageUrl ? "thumbnail" : "image",
|
|
165
|
+
})),
|
|
166
|
+
])
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
toast.success(`${uploadedFiles.length} image(s) uploaded successfully`)
|
|
170
|
+
} catch (err: any) {
|
|
171
|
+
toast.error(err?.message || "Failed to upload image")
|
|
172
|
+
} finally {
|
|
173
|
+
setIsUploading(false)
|
|
174
|
+
if (fileInputRef.current) fileInputRef.current.value = ""
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Set selected image as Category Thumbnail
|
|
179
|
+
const handleSetThumbnail = async (img: { id?: string; url: string }) => {
|
|
180
|
+
setImageUrl(img.url)
|
|
181
|
+
if (img.id) {
|
|
182
|
+
try {
|
|
183
|
+
await sdk.client.fetch(`/admin/categories/${category.id}/images/batch`, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "Content-Type": "application/json" },
|
|
186
|
+
body: {
|
|
187
|
+
updates: [{ id: img.id, type: "thumbnail" }],
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
await loadCategoryImages(category.id, img.url)
|
|
191
|
+
} catch {
|
|
192
|
+
setCategoryImages((prev) =>
|
|
193
|
+
prev.map((item) => ({
|
|
194
|
+
...item,
|
|
195
|
+
type: item.url === img.url ? "thumbnail" : "image",
|
|
196
|
+
}))
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
setCategoryImages((prev) =>
|
|
201
|
+
prev.map((item) => ({
|
|
202
|
+
...item,
|
|
203
|
+
type: item.url === img.url ? "thumbnail" : "image",
|
|
204
|
+
}))
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
toast.success("Designated as category thumbnail")
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Delete category image
|
|
211
|
+
const handleDeleteImage = async (img: { id?: string; url: string }) => {
|
|
212
|
+
if (img.id) {
|
|
213
|
+
try {
|
|
214
|
+
await sdk.client.fetch(`/admin/categories/${category.id}/images/batch`, {
|
|
215
|
+
method: "DELETE",
|
|
216
|
+
headers: { "Content-Type": "application/json" },
|
|
217
|
+
body: {
|
|
218
|
+
ids: [img.id],
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
} catch {
|
|
222
|
+
// Fallback
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
setCategoryImages((prev) => prev.filter((i) => i.url !== img.url))
|
|
226
|
+
if (imageUrl === img.url) {
|
|
227
|
+
setImageUrl("")
|
|
228
|
+
}
|
|
229
|
+
toast.info("Image removed")
|
|
230
|
+
}
|
|
231
|
+
|
|
94
232
|
if (!category) return null
|
|
95
233
|
|
|
96
234
|
const toggleChannel = (channelId: string) => {
|
|
@@ -460,29 +598,140 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
460
598
|
</div>
|
|
461
599
|
</div>
|
|
462
600
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
601
|
+
{/* Category Media / Images Upload & Gallery */}
|
|
602
|
+
<div className="flex flex-col gap-3 pt-3 border-t border-ui-border-base">
|
|
603
|
+
<div className="flex items-center justify-between">
|
|
604
|
+
<div>
|
|
605
|
+
<Label className="text-xs font-semibold">Category Media</Label>
|
|
606
|
+
<Text size="xsmall" className="text-ui-fg-muted">
|
|
607
|
+
Upload category thumbnails and banners. Automatically handled by your configured S3 or storage provider.
|
|
608
|
+
</Text>
|
|
609
|
+
</div>
|
|
610
|
+
<button
|
|
611
|
+
type="button"
|
|
612
|
+
onClick={() => fileInputRef.current?.click()}
|
|
613
|
+
disabled={isUploading}
|
|
614
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-ui-bg-base hover:bg-ui-bg-subtle text-ui-fg-base border border-ui-border-base rounded-lg shadow-2xs transition-colors cursor-pointer"
|
|
615
|
+
>
|
|
616
|
+
<ArrowDownTray className="w-3.5 h-3.5" />
|
|
617
|
+
<span>{isUploading ? "Uploading…" : "Upload Media"}</span>
|
|
618
|
+
</button>
|
|
619
|
+
</div>
|
|
620
|
+
|
|
621
|
+
<input
|
|
622
|
+
ref={fileInputRef}
|
|
623
|
+
type="file"
|
|
624
|
+
multiple
|
|
625
|
+
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml"
|
|
626
|
+
onChange={(e) => handleFileUpload(e.target.files)}
|
|
627
|
+
className="hidden"
|
|
473
628
|
/>
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
629
|
+
|
|
630
|
+
{/* Drag and Drop Upload Zone */}
|
|
631
|
+
<div
|
|
632
|
+
onDragOver={(e) => {
|
|
633
|
+
e.preventDefault()
|
|
634
|
+
e.stopPropagation()
|
|
635
|
+
}}
|
|
636
|
+
onDrop={(e) => {
|
|
637
|
+
e.preventDefault()
|
|
638
|
+
e.stopPropagation()
|
|
639
|
+
if (!isUploading) handleFileUpload(e.dataTransfer.files)
|
|
640
|
+
}}
|
|
641
|
+
onClick={() => fileInputRef.current?.click()}
|
|
642
|
+
className="border-2 border-dashed border-ui-border-strong hover:border-ui-border-interactive rounded-xl p-6 flex flex-col items-center justify-center gap-2 bg-ui-bg-subtle/30 hover:bg-ui-bg-subtle/60 transition-colors cursor-pointer text-center"
|
|
643
|
+
>
|
|
644
|
+
<div className="w-10 h-10 rounded-full bg-ui-bg-base border border-ui-border-base flex items-center justify-center text-ui-fg-subtle shadow-2xs">
|
|
645
|
+
<ArrowDownTray className="w-5 h-5" />
|
|
646
|
+
</div>
|
|
647
|
+
<div className="flex flex-col gap-0.5">
|
|
648
|
+
<span className="text-xs font-semibold text-ui-fg-base">
|
|
649
|
+
{isUploading ? "Uploading to storage…" : "Click to upload or drag & drop images"}
|
|
650
|
+
</span>
|
|
651
|
+
<span className="text-[11px] text-ui-fg-muted">
|
|
652
|
+
PNG, JPG, WEBP, GIF, SVG up to 10MB
|
|
653
|
+
</span>
|
|
654
|
+
</div>
|
|
655
|
+
</div>
|
|
656
|
+
|
|
657
|
+
{/* Uploaded Images Gallery */}
|
|
658
|
+
{categoryImages.length > 0 && (
|
|
659
|
+
<div className="flex flex-col gap-2 mt-2">
|
|
660
|
+
<Label className="text-xs font-medium text-ui-fg-muted">
|
|
661
|
+
Uploaded Images ({categoryImages.length})
|
|
662
|
+
</Label>
|
|
663
|
+
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-3">
|
|
664
|
+
{categoryImages.map((img, idx) => {
|
|
665
|
+
const isThumbnail = img.type === "thumbnail" || img.url === imageUrl
|
|
666
|
+
return (
|
|
667
|
+
<div
|
|
668
|
+
key={img.id || idx}
|
|
669
|
+
className={`group relative aspect-square rounded-xl overflow-hidden border bg-ui-bg-base transition-all shadow-xs ${
|
|
670
|
+
isThumbnail
|
|
671
|
+
? "ring-2 ring-blue-500 border-transparent"
|
|
672
|
+
: "border-ui-border-base hover:border-ui-border-strong"
|
|
673
|
+
}`}
|
|
674
|
+
>
|
|
675
|
+
<img
|
|
676
|
+
src={img.url}
|
|
677
|
+
alt="Category Media"
|
|
678
|
+
className="w-full h-full object-cover"
|
|
679
|
+
/>
|
|
680
|
+
|
|
681
|
+
{isThumbnail && (
|
|
682
|
+
<div className="absolute top-1.5 left-1.5 bg-blue-600 text-white text-[9px] font-bold px-1.5 py-0.5 rounded shadow-xs">
|
|
683
|
+
★ Thumbnail
|
|
684
|
+
</div>
|
|
685
|
+
)}
|
|
686
|
+
|
|
687
|
+
{/* Hover Action Overlay */}
|
|
688
|
+
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1.5 p-1.5">
|
|
689
|
+
{!isThumbnail && (
|
|
690
|
+
<button
|
|
691
|
+
type="button"
|
|
692
|
+
onClick={(e) => {
|
|
693
|
+
e.preventDefault()
|
|
694
|
+
e.stopPropagation()
|
|
695
|
+
handleSetThumbnail(img)
|
|
696
|
+
}}
|
|
697
|
+
className="px-2 py-0.5 bg-white text-neutral-900 rounded text-[10px] font-semibold hover:bg-neutral-100 transition-colors shadow-xs"
|
|
698
|
+
>
|
|
699
|
+
Set Thumbnail
|
|
700
|
+
</button>
|
|
701
|
+
)}
|
|
702
|
+
<button
|
|
703
|
+
type="button"
|
|
704
|
+
onClick={(e) => {
|
|
705
|
+
e.preventDefault()
|
|
706
|
+
e.stopPropagation()
|
|
707
|
+
handleDeleteImage(img)
|
|
708
|
+
}}
|
|
709
|
+
className="p-1 bg-rose-600 text-white rounded hover:bg-rose-700 transition-colors"
|
|
710
|
+
title="Remove image"
|
|
711
|
+
>
|
|
712
|
+
<Trash className="w-3.5 h-3.5" />
|
|
713
|
+
</button>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
)
|
|
717
|
+
})}
|
|
718
|
+
</div>
|
|
484
719
|
</div>
|
|
485
720
|
)}
|
|
721
|
+
|
|
722
|
+
{/* Manual URL input fallback */}
|
|
723
|
+
<div className="flex flex-col gap-1 mt-1 pt-2 border-t border-ui-border-base/50">
|
|
724
|
+
<Label htmlFor="edit-image" className="text-[11px] text-ui-fg-muted">
|
|
725
|
+
Or paste an external image URL
|
|
726
|
+
</Label>
|
|
727
|
+
<Input
|
|
728
|
+
id="edit-image"
|
|
729
|
+
size="small"
|
|
730
|
+
value={imageUrl}
|
|
731
|
+
onChange={(e) => setImageUrl(e.target.value)}
|
|
732
|
+
placeholder="https://example.com/category-image.jpg"
|
|
733
|
+
/>
|
|
734
|
+
</div>
|
|
486
735
|
</div>
|
|
487
736
|
</div>
|
|
488
737
|
)}
|