@hectasquare/medusa-plugin-category-tree 1.0.7 → 1.0.9

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.
@@ -5,11 +5,11 @@ 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
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"
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("LR") : onToggleDirection()), className: `px-2 py-0.5 text-xs font-medium rounded-md transition-all ${direction === "LR"
9
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"
10
+ : "text-ui-fg-muted hover:text-ui-fg-base"}`, title: "Vertical Tree Layout", children: "\u2B07 Vertical" }), (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"
11
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
12
+ : "text-ui-fg-muted hover:text-ui-fg-base"}`, title: "Mind Map Layout", 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
13
13
  ? "bg-blue-600 text-white shadow-xs"
14
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" })] })] }))] }));
15
15
  };
@@ -27,7 +27,7 @@ const CanvasInner = () => {
27
27
  const [salesChannels, setSalesChannels] = (0, react_1.useState)([]);
28
28
  const [isLoading, setIsLoading] = (0, react_1.useState)(true);
29
29
  const [isSaving, setIsSaving] = (0, react_1.useState)(false);
30
- const [direction, setDirection] = (0, react_1.useState)("TB");
30
+ const [direction, setDirection] = (0, react_1.useState)("LR");
31
31
  const [selectedStorefront, setSelectedStorefront] = (0, react_1.useState)("all");
32
32
  const [searchQuery, setSearchQuery] = (0, react_1.useState)("");
33
33
  const [collapsedIds, setCollapsedIds] = (0, react_1.useState)(new Set());
@@ -270,7 +270,48 @@ const CanvasInner = () => {
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-[#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) => {
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" }), (0, jsx_runtime_1.jsx)("style", { children: `
274
+ .react-flow__controls {
275
+ background: #ffffff !important;
276
+ border: 1px solid #d1d5db !important;
277
+ border-radius: 9999px !important;
278
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2) !important;
279
+ padding: 3px !important;
280
+ display: flex !important;
281
+ flex-direction: column !important;
282
+ gap: 2px !important;
283
+ overflow: hidden !important;
284
+ }
285
+ .react-flow__controls-button {
286
+ background: #ffffff !important;
287
+ border: none !important;
288
+ border-bottom: 1px solid #f1f5f9 !important;
289
+ border-radius: 9999px !important;
290
+ width: 28px !important;
291
+ height: 28px !important;
292
+ display: flex !important;
293
+ align-items: center !important;
294
+ justify-content: center !important;
295
+ cursor: pointer !important;
296
+ transition: background 0.15s ease !important;
297
+ }
298
+ .react-flow__controls-button:last-child {
299
+ border-bottom: none !important;
300
+ }
301
+ .react-flow__controls-button:hover {
302
+ background: #f3f4f6 !important;
303
+ }
304
+ .react-flow__controls-button svg {
305
+ fill: #000000 !important;
306
+ stroke: #000000 !important;
307
+ color: #000000 !important;
308
+ }
309
+ .react-flow__controls-button svg path {
310
+ fill: #000000 !important;
311
+ stroke: #000000 !important;
312
+ color: #000000 !important;
313
+ }
314
+ ` }), (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
315
  setCategories((prev) => [...prev, newCat]);
275
316
  setOriginalCategories((prev) => [...prev, newCat]);
276
317
  } }), (0, jsx_runtime_1.jsx)(edit_category_drawer_1.EditCategoryDrawer, { open: editDrawerOpen, onOpenChange: setEditDrawerOpen, category: editingCategory, salesChannels: salesChannels, onSuccess: (updatedCat) => {
@@ -42,6 +42,10 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
42
42
  const [isFullScreen, setIsFullScreen] = (0, react_1.useState)(false);
43
43
  const [isSubmitting, setIsSubmitting] = (0, react_1.useState)(false);
44
44
  const [isDeleting, setIsDeleting] = (0, react_1.useState)(false);
45
+ // Media upload & gallery state
46
+ const fileInputRef = (0, react_1.useRef)(null);
47
+ const [isUploading, setIsUploading] = (0, react_1.useState)(false);
48
+ const [categoryImages, setCategoryImages] = (0, react_1.useState)([]);
45
49
  (0, react_1.useEffect)(() => {
46
50
  if (category) {
47
51
  setName(category.name || "");
@@ -64,8 +68,136 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
64
68
  }
65
69
  setYoutubeUrls(urls);
66
70
  setActiveTab("general");
71
+ // Load category media from backend
72
+ loadCategoryImages(category.id, category.image_url || meta?.thumbnail || meta?.image);
67
73
  }
68
74
  }, [category, salesChannels]);
75
+ // Load existing category media gallery
76
+ const loadCategoryImages = (0, react_1.useCallback)(async (catId, currentImageUrl) => {
77
+ try {
78
+ const res = await sdk_1.sdk.client.fetch(`/admin/categories/${catId}/images`);
79
+ if (res?.category_images && Array.isArray(res.category_images) && res.category_images.length > 0) {
80
+ setCategoryImages(res.category_images);
81
+ const thumb = res.category_images.find((img) => img.type === "thumbnail");
82
+ if (thumb?.url) {
83
+ setImageUrl(thumb.url);
84
+ }
85
+ return;
86
+ }
87
+ }
88
+ catch {
89
+ // Endpoint fallback
90
+ }
91
+ if (currentImageUrl) {
92
+ setCategoryImages([{ url: currentImageUrl, type: "thumbnail" }]);
93
+ }
94
+ else {
95
+ setCategoryImages([]);
96
+ }
97
+ }, []);
98
+ // Universal S3/Storage Media Upload Handler (works with AWS S3, MinIO, Cloudflare R2, Local)
99
+ const handleFileUpload = async (files) => {
100
+ if (!files || files.length === 0)
101
+ return;
102
+ setIsUploading(true);
103
+ try {
104
+ const fileList = Array.from(files);
105
+ // Calls standard Medusa File Module API (delegates to configured S3 provider in medusa-config.js)
106
+ const res = await sdk_1.sdk.admin.upload.create({ files: fileList });
107
+ const uploadedFiles = res.files || [];
108
+ if (uploadedFiles.length === 0) {
109
+ throw new Error("No files returned from upload service");
110
+ }
111
+ const firstUrl = uploadedFiles[0].url;
112
+ if (!imageUrl) {
113
+ setImageUrl(firstUrl);
114
+ }
115
+ // If backend has custom category images endpoint, save them there too
116
+ try {
117
+ await sdk_1.sdk.client.fetch(`/admin/categories/${category.id}/images`, {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: {
121
+ images: uploadedFiles.map((f, i) => ({
122
+ url: f.url,
123
+ type: i === 0 && !imageUrl ? "thumbnail" : "image",
124
+ })),
125
+ },
126
+ });
127
+ await loadCategoryImages(category.id, firstUrl);
128
+ }
129
+ catch {
130
+ // Fallback for standard stores: update local gallery state
131
+ setCategoryImages((prev) => [
132
+ ...prev,
133
+ ...uploadedFiles.map((f, i) => ({
134
+ url: f.url,
135
+ type: i === 0 && !imageUrl ? "thumbnail" : "image",
136
+ })),
137
+ ]);
138
+ }
139
+ ui_1.toast.success(`${uploadedFiles.length} image(s) uploaded successfully`);
140
+ }
141
+ catch (err) {
142
+ ui_1.toast.error(err?.message || "Failed to upload image");
143
+ }
144
+ finally {
145
+ setIsUploading(false);
146
+ if (fileInputRef.current)
147
+ fileInputRef.current.value = "";
148
+ }
149
+ };
150
+ // Set selected image as Category Thumbnail
151
+ const handleSetThumbnail = async (img) => {
152
+ setImageUrl(img.url);
153
+ if (img.id) {
154
+ try {
155
+ await sdk_1.sdk.client.fetch(`/admin/categories/${category.id}/images/batch`, {
156
+ method: "POST",
157
+ headers: { "Content-Type": "application/json" },
158
+ body: {
159
+ updates: [{ id: img.id, type: "thumbnail" }],
160
+ },
161
+ });
162
+ await loadCategoryImages(category.id, img.url);
163
+ }
164
+ catch {
165
+ setCategoryImages((prev) => prev.map((item) => ({
166
+ ...item,
167
+ type: item.url === img.url ? "thumbnail" : "image",
168
+ })));
169
+ }
170
+ }
171
+ else {
172
+ setCategoryImages((prev) => prev.map((item) => ({
173
+ ...item,
174
+ type: item.url === img.url ? "thumbnail" : "image",
175
+ })));
176
+ }
177
+ ui_1.toast.success("Designated as category thumbnail");
178
+ };
179
+ // Delete category image
180
+ const handleDeleteImage = async (img) => {
181
+ if (img.id) {
182
+ try {
183
+ await sdk_1.sdk.client.fetch(`/admin/categories/${category.id}/images/batch`, {
184
+ method: "DELETE",
185
+ headers: { "Content-Type": "application/json" },
186
+ body: {
187
+ ids: [img.id],
188
+ },
189
+ });
190
+ }
191
+ catch {
192
+ // Fallback
193
+ }
194
+ }
195
+ setCategoryImages((prev) => prev.filter((i) => i.url !== img.url));
196
+ if (imageUrl === img.url) {
197
+ setImageUrl("");
198
+ }
199
+ ui_1.toast.info("Image removed");
200
+ };
69
201
  if (!category)
70
202
  return null;
71
203
  const toggleChannel = (channelId) => {
@@ -176,10 +308,28 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
176
308
  } }) })), activeTab === "channels" && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2", children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { className: "text-xs font-semibold", children: "Storefront Visibility" }), (0, jsx_runtime_1.jsx)(ui_1.Text, { size: "xsmall", className: "text-ui-fg-muted", children: "Designate which storefront sales channels display this category." }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-col gap-2 mt-1", children: salesChannels.map((channel) => {
177
309
  const isChecked = selectedChannels.includes(channel.id);
178
310
  return ((0, jsx_runtime_1.jsxs)("label", { className: "flex items-center gap-2.5 text-xs text-ui-fg-base cursor-pointer select-none p-2 rounded-lg border border-ui-border-base bg-ui-bg-subtle/40 hover:bg-ui-bg-subtle", children: [(0, jsx_runtime_1.jsx)("input", { type: "checkbox", checked: isChecked, onChange: () => toggleChannel(channel.id), className: "rounded border-ui-border-base text-blue-600 focus:ring-blue-500 cursor-pointer" }), (0, jsx_runtime_1.jsx)("span", { className: "font-medium", children: channel.name })] }, channel.id));
179
- }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5 pt-3 border-t border-ui-border-base", children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { htmlFor: "edit-image", className: "text-xs font-semibold", children: "Category Thumbnail / Image URL" }), (0, jsx_runtime_1.jsx)(ui_1.Input, { id: "edit-image", size: "small", value: imageUrl, onChange: (e) => setImageUrl(e.target.value), placeholder: "https://example.com/category-image.jpg" }), imageUrl && ((0, jsx_runtime_1.jsx)("div", { className: "mt-2 w-24 h-24 rounded-xl overflow-hidden border border-ui-border-base bg-ui-bg-subtle shadow-xs", children: (0, jsx_runtime_1.jsx)("img", { src: imageUrl, alt: "Category Preview", className: "w-full h-full object-cover", onError: (e) => {
180
- ;
181
- e.target.style.display = "none";
182
- } }) }))] })] })), activeTab === "youtube" && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h4", { className: "text-xs font-semibold text-ui-fg-base", children: "Category Demonstration Videos (YouTube)" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-ui-fg-muted mt-0.5", children: "Attach YouTube video demonstration links to show interactive video carousels on the storefront category page." })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(ui_1.Input, { size: "small", placeholder: "Paste YouTube watch URL, shorts, or video ID\u2026", value: newYoutubeInput, onChange: (e) => setNewYoutubeInput(e.target.value), className: "flex-1 text-xs", onKeyDown: (e) => {
311
+ }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-3 pt-3 border-t border-ui-border-base", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { className: "text-xs font-semibold", children: "Category Media" }), (0, jsx_runtime_1.jsx)(ui_1.Text, { size: "xsmall", className: "text-ui-fg-muted", children: "Upload category thumbnails and banners. Automatically handled by your configured S3 or storage provider." })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => fileInputRef.current?.click(), disabled: isUploading, 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", children: [(0, jsx_runtime_1.jsx)(icons_1.ArrowDownTray, { className: "w-3.5 h-3.5" }), (0, jsx_runtime_1.jsx)("span", { children: isUploading ? "Uploading…" : "Upload Media" })] })] }), (0, jsx_runtime_1.jsx)("input", { ref: fileInputRef, type: "file", multiple: true, accept: "image/jpeg,image/png,image/gif,image/webp,image/svg+xml", onChange: (e) => handleFileUpload(e.target.files), className: "hidden" }), (0, jsx_runtime_1.jsxs)("div", { onDragOver: (e) => {
312
+ e.preventDefault();
313
+ e.stopPropagation();
314
+ }, onDrop: (e) => {
315
+ e.preventDefault();
316
+ e.stopPropagation();
317
+ if (!isUploading)
318
+ handleFileUpload(e.dataTransfer.files);
319
+ }, onClick: () => fileInputRef.current?.click(), 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", children: [(0, jsx_runtime_1.jsx)("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", children: (0, jsx_runtime_1.jsx)(icons_1.ArrowDownTray, { className: "w-5 h-5" }) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-0.5", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-xs font-semibold text-ui-fg-base", children: isUploading ? "Uploading to storage…" : "Click to upload or drag & drop images" }), (0, jsx_runtime_1.jsx)("span", { className: "text-[11px] text-ui-fg-muted", children: "PNG, JPG, WEBP, GIF, SVG up to 10MB" })] })] }), categoryImages.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2 mt-2", children: [(0, jsx_runtime_1.jsxs)(ui_1.Label, { className: "text-xs font-medium text-ui-fg-muted", children: ["Uploaded Images (", categoryImages.length, ")"] }), (0, jsx_runtime_1.jsx)("div", { className: "grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-3", children: categoryImages.map((img, idx) => {
320
+ const isThumbnail = img.type === "thumbnail" || img.url === imageUrl;
321
+ return ((0, jsx_runtime_1.jsxs)("div", { className: `group relative aspect-square rounded-xl overflow-hidden border bg-ui-bg-base transition-all shadow-xs ${isThumbnail
322
+ ? "ring-2 ring-blue-500 border-transparent"
323
+ : "border-ui-border-base hover:border-ui-border-strong"}`, children: [(0, jsx_runtime_1.jsx)("img", { src: img.url, alt: "Category Media", className: "w-full h-full object-cover" }), isThumbnail && ((0, jsx_runtime_1.jsx)("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", children: "\u2605 Thumbnail" })), (0, jsx_runtime_1.jsxs)("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", children: [!isThumbnail && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: (e) => {
324
+ e.preventDefault();
325
+ e.stopPropagation();
326
+ handleSetThumbnail(img);
327
+ }, className: "px-2 py-0.5 bg-white text-neutral-900 rounded text-[10px] font-semibold hover:bg-neutral-100 transition-colors shadow-xs", children: "Set Thumbnail" })), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: (e) => {
328
+ e.preventDefault();
329
+ e.stopPropagation();
330
+ handleDeleteImage(img);
331
+ }, className: "p-1 bg-rose-600 text-white rounded hover:bg-rose-700 transition-colors", title: "Remove image", children: (0, jsx_runtime_1.jsx)(icons_1.Trash, { className: "w-3.5 h-3.5" }) })] })] }, img.id || idx));
332
+ }) })] })), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1 mt-1 pt-2 border-t border-ui-border-base/50", children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { htmlFor: "edit-image", className: "text-[11px] text-ui-fg-muted", children: "Or paste an external image URL" }), (0, jsx_runtime_1.jsx)(ui_1.Input, { id: "edit-image", size: "small", value: imageUrl, onChange: (e) => setImageUrl(e.target.value), placeholder: "https://example.com/category-image.jpg" })] })] })] })), activeTab === "youtube" && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h4", { className: "text-xs font-semibold text-ui-fg-base", children: "Category Demonstration Videos (YouTube)" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-ui-fg-muted mt-0.5", children: "Attach YouTube video demonstration links to show interactive video carousels on the storefront category page." })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(ui_1.Input, { size: "small", placeholder: "Paste YouTube watch URL, shorts, or video ID\u2026", value: newYoutubeInput, onChange: (e) => setNewYoutubeInput(e.target.value), className: "flex-1 text-xs", onKeyDown: (e) => {
183
333
  if (e.key === "Enter") {
184
334
  e.preventDefault();
185
335
  handleAddYoutube();
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
- - Category thumbnail and image URL editor with live preview.
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.7",
3
+ "version": "1.0.9",
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",
@@ -93,25 +93,25 @@ export const CanvasDock: React.FC<CanvasDockProps> = ({
93
93
  <div className="flex items-center p-0.5 bg-ui-bg-subtle border border-ui-border-base rounded-lg gap-0.5">
94
94
  <button
95
95
  type="button"
96
- onClick={() => (onSelectDirection ? onSelectDirection("TB") : onToggleDirection())}
96
+ onClick={() => (onSelectDirection ? onSelectDirection("LR") : onToggleDirection())}
97
97
  className={`px-2 py-0.5 text-xs font-medium rounded-md transition-all ${
98
- direction === "TB"
98
+ direction === "LR"
99
99
  ? "bg-ui-bg-base text-ui-fg-base shadow-xs font-semibold"
100
100
  : "text-ui-fg-muted hover:text-ui-fg-base"
101
101
  }`}
102
- title="Vertical Tree Layout (Default)"
102
+ title="Vertical Tree Layout"
103
103
  >
104
104
  ⬇ Vertical
105
105
  </button>
106
106
  <button
107
107
  type="button"
108
- onClick={() => (onSelectDirection ? onSelectDirection("LR") : onToggleDirection())}
108
+ onClick={() => (onSelectDirection ? onSelectDirection("TB") : onToggleDirection())}
109
109
  className={`px-2 py-0.5 text-xs font-medium rounded-md transition-all ${
110
- direction === "LR"
110
+ direction === "TB"
111
111
  ? "bg-ui-bg-base text-ui-fg-base shadow-xs font-semibold"
112
112
  : "text-ui-fg-muted hover:text-ui-fg-base"
113
113
  }`}
114
- title="Mind Map Layout (Horizontal)"
114
+ title="Mind Map Layout"
115
115
  >
116
116
  ➡ Mind Map
117
117
  </button>
@@ -51,7 +51,7 @@ const CanvasInner: React.FC = () => {
51
51
  const [isLoading, setIsLoading] = useState(true)
52
52
  const [isSaving, setIsSaving] = useState(false)
53
53
 
54
- const [direction, setDirection] = useState<"TB" | "LR">("TB")
54
+ const [direction, setDirection] = useState<"TB" | "LR">("LR")
55
55
  const [selectedStorefront, setSelectedStorefront] = useState<string>("all")
56
56
  const [searchQuery, setSearchQuery] = useState("")
57
57
  const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set())
@@ -408,8 +408,50 @@ const CanvasInner: React.FC = () => {
408
408
  <Background color="#cbd5e1" gap={20} size={1.25} />
409
409
  <Controls
410
410
  showInteractive={false}
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
+ className="!bottom-4 !left-4"
412
412
  />
413
+ <style>{`
414
+ .react-flow__controls {
415
+ background: #ffffff !important;
416
+ border: 1px solid #d1d5db !important;
417
+ border-radius: 9999px !important;
418
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2) !important;
419
+ padding: 3px !important;
420
+ display: flex !important;
421
+ flex-direction: column !important;
422
+ gap: 2px !important;
423
+ overflow: hidden !important;
424
+ }
425
+ .react-flow__controls-button {
426
+ background: #ffffff !important;
427
+ border: none !important;
428
+ border-bottom: 1px solid #f1f5f9 !important;
429
+ border-radius: 9999px !important;
430
+ width: 28px !important;
431
+ height: 28px !important;
432
+ display: flex !important;
433
+ align-items: center !important;
434
+ justify-content: center !important;
435
+ cursor: pointer !important;
436
+ transition: background 0.15s ease !important;
437
+ }
438
+ .react-flow__controls-button:last-child {
439
+ border-bottom: none !important;
440
+ }
441
+ .react-flow__controls-button:hover {
442
+ background: #f3f4f6 !important;
443
+ }
444
+ .react-flow__controls-button svg {
445
+ fill: #000000 !important;
446
+ stroke: #000000 !important;
447
+ color: #000000 !important;
448
+ }
449
+ .react-flow__controls-button svg path {
450
+ fill: #000000 !important;
451
+ stroke: #000000 !important;
452
+ color: #000000 !important;
453
+ }
454
+ `}</style>
413
455
  <MiniMap
414
456
  nodeColor={() => "var(--bg-interactive, #3b82f6)"}
415
457
  maskColor="rgba(0, 0, 0, 0.25)"
@@ -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
- <div className="flex flex-col gap-1.5 pt-3 border-t border-ui-border-base">
464
- <Label htmlFor="edit-image" className="text-xs font-semibold">
465
- Category Thumbnail / Image URL
466
- </Label>
467
- <Input
468
- id="edit-image"
469
- size="small"
470
- value={imageUrl}
471
- onChange={(e) => setImageUrl(e.target.value)}
472
- placeholder="https://example.com/category-image.jpg"
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
- {imageUrl && (
475
- <div className="mt-2 w-24 h-24 rounded-xl overflow-hidden border border-ui-border-base bg-ui-bg-subtle shadow-xs">
476
- <img
477
- src={imageUrl}
478
- alt="Category Preview"
479
- className="w-full h-full object-cover"
480
- onError={(e) => {
481
- ;(e.target as any).style.display = "none"
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
  )}