@hectasquare/medusa-plugin-category-tree 1.0.2 → 1.0.5

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.
@@ -1,7 +1,17 @@
1
1
  import React, { useState, useEffect } from "react"
2
- import { Button, Drawer, Input, Label, Switch, Text, Textarea, toast } from "@medusajs/ui"
3
- import { Trash } from "@medusajs/icons"
2
+ import {
3
+ Button,
4
+ Drawer,
5
+ Input,
6
+ Label,
7
+ Switch,
8
+ Text,
9
+ Textarea,
10
+ toast,
11
+ } from "@medusajs/ui"
12
+ import { Trash, Plus, ArrowUpRightMicro } from "@medusajs/icons"
4
13
  import { CategoryTreeNode, SalesChannelInfo } from "./types"
14
+ import { CategoryProductsManager } from "./category-products-manager"
5
15
  import { sdk } from "../../lib/sdk"
6
16
 
7
17
  export interface EditCategoryDrawerProps {
@@ -13,6 +23,23 @@ export interface EditCategoryDrawerProps {
13
23
  onDeleteSuccess: (deletedId: string) => void
14
24
  }
15
25
 
26
+ function extractYouTubeVideoId(url: string): string | null {
27
+ if (!url || typeof url !== "string") return null
28
+ const trimmed = url.trim()
29
+ const patterns = [
30
+ /(?:youtube\.com\/shorts\/)([^?&\s]+)/i,
31
+ /(?:youtube\.com\/watch\?.*v=)([^?&\s]+)/i,
32
+ /(?:youtu\.be\/)([^?&\s]+)/i,
33
+ /(?:youtube\.com\/embed\/)([^?&\s]+)/i,
34
+ ]
35
+ for (const pattern of patterns) {
36
+ const match = trimmed.match(pattern)
37
+ if (match && match[1]) return match[1]
38
+ }
39
+ if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed
40
+ return null
41
+ }
42
+
16
43
  export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
17
44
  open,
18
45
  onOpenChange,
@@ -21,6 +48,8 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
21
48
  onSuccess,
22
49
  onDeleteSuccess,
23
50
  }) => {
51
+ const [activeTab, setActiveTab] = useState<"general" | "products" | "channels" | "youtube">("general")
52
+
24
53
  const [name, setName] = useState("")
25
54
  const [handle, setHandle] = useState("")
26
55
  const [description, setDescription] = useState("")
@@ -29,6 +58,10 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
29
58
  const [isActive, setIsActive] = useState(true)
30
59
  const [isInternal, setIsInternal] = useState(false)
31
60
  const [selectedChannels, setSelectedChannels] = useState<string[]>([])
61
+ const [youtubeUrls, setYoutubeUrls] = useState<string[]>([])
62
+ const [newYoutubeInput, setNewYoutubeInput] = useState("")
63
+
64
+ const [currentMetadata, setCurrentMetadata] = useState<Record<string, any>>({})
32
65
  const [isSubmitting, setIsSubmitting] = useState(false)
33
66
  const [isDeleting, setIsDeleting] = useState(false)
34
67
 
@@ -42,6 +75,18 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
42
75
  setIsActive(Boolean(category.is_active))
43
76
  setIsInternal(Boolean(category.is_internal))
44
77
  setSelectedChannels(category.sales_channels || salesChannels.map((s) => s.id))
78
+ setCurrentMetadata((category.metadata as Record<string, any>) || {})
79
+
80
+ // Parse YouTube URLs from metadata
81
+ const meta = category.metadata as Record<string, any> | null
82
+ const urls: string[] = []
83
+ if (meta?.youtube_urls && Array.isArray(meta.youtube_urls)) {
84
+ meta.youtube_urls.forEach((u) => typeof u === "string" && u.trim() && urls.push(u.trim()))
85
+ } else if (meta?.youtube_url && typeof meta.youtube_url === "string") {
86
+ urls.push(meta.youtube_url.trim())
87
+ }
88
+ setYoutubeUrls(urls)
89
+ setActiveTab("general")
45
90
  }
46
91
  }, [category, salesChannels])
47
92
 
@@ -55,6 +100,28 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
55
100
  )
56
101
  }
57
102
 
103
+ const handleAddYoutube = () => {
104
+ const raw = newYoutubeInput.trim()
105
+ if (!raw) return
106
+ const id = extractYouTubeVideoId(raw)
107
+ if (!id) {
108
+ toast.error("Please enter a valid YouTube URL or video ID")
109
+ return
110
+ }
111
+ const cleanUrl = `https://www.youtube.com/watch?v=${id}`
112
+ if (youtubeUrls.includes(cleanUrl)) {
113
+ toast.error("Video already added")
114
+ return
115
+ }
116
+ setYoutubeUrls((prev) => [...prev, cleanUrl])
117
+ setNewYoutubeInput("")
118
+ toast.success("YouTube video added")
119
+ }
120
+
121
+ const handleRemoveYoutube = (urlToRemove: string) => {
122
+ setYoutubeUrls((prev) => prev.filter((u) => u !== urlToRemove))
123
+ }
124
+
58
125
  const handleSubmit = async (e: React.FormEvent) => {
59
126
  e.preventDefault()
60
127
  if (!name.trim()) {
@@ -65,8 +132,9 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
65
132
  setIsSubmitting(true)
66
133
  try {
67
134
  const updatedMetadata: Record<string, any> = {
68
- ...(category.metadata || {}),
135
+ ...(currentMetadata || {}),
69
136
  sales_channels: selectedChannels,
137
+ youtube_urls: youtubeUrls,
70
138
  }
71
139
  if (imageUrl.trim()) {
72
140
  updatedMetadata.thumbnail = imageUrl.trim()
@@ -126,185 +194,358 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
126
194
 
127
195
  return (
128
196
  <Drawer open={open} onOpenChange={onOpenChange}>
129
- <Drawer.Content className="max-w-md">
197
+ <Drawer.Content className="max-w-3xl w-full">
130
198
  <form onSubmit={handleSubmit} className="flex flex-col h-full">
131
- <Drawer.Header>
132
- <Drawer.Title>Edit Category: {category.name}</Drawer.Title>
133
- <Drawer.Description>
134
- Tier {category.tier} • ID: {category.id}
135
- </Drawer.Description>
136
- </Drawer.Header>
199
+ <Drawer.Header className="border-b border-ui-border-base pb-3">
200
+ <div className="flex items-center justify-between w-full">
201
+ <div>
202
+ <Drawer.Title className="text-base font-semibold text-ui-fg-base">
203
+ {category.name}
204
+ </Drawer.Title>
205
+ <Drawer.Description className="text-xs text-ui-fg-muted mt-0.5">
206
+ Tier {category.tier} • ID: {category.id}
207
+ </Drawer.Description>
208
+ </div>
137
209
 
138
- <Drawer.Body className="flex flex-col gap-4 overflow-y-auto px-6 py-4 flex-1">
139
- <div className="flex flex-col gap-1.5">
140
- <Label htmlFor="edit-name" className="text-xs font-semibold">
141
- Category Name *
142
- </Label>
143
- <Input
144
- id="edit-name"
145
- size="small"
146
- value={name}
147
- onChange={(e) => setName(e.target.value)}
148
- />
210
+ {/* Direct Open Legacy Page Link */}
211
+ <button
212
+ type="button"
213
+ onClick={() => window.open(`/app/categories/${category.id}`, "_blank")}
214
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base rounded-lg border border-ui-border-base bg-ui-bg-subtle hover:bg-ui-bg-base shadow-2xs transition-colors"
215
+ title="Open full category details page in a new tab"
216
+ >
217
+ <span>Open Full Page</span>
218
+ <ArrowUpRightMicro className="w-3.5 h-3.5" />
219
+ </button>
149
220
  </div>
150
221
 
151
- <div className="flex flex-col gap-1.5">
152
- <Label htmlFor="edit-handle" className="text-xs font-semibold">
153
- Handle (URL slug)
154
- </Label>
155
- <Input
156
- id="edit-handle"
157
- size="small"
158
- value={handle}
159
- onChange={(e) => setHandle(e.target.value)}
160
- />
222
+ {/* Tab Navigation Bar */}
223
+ <div className="flex items-center gap-1 mt-3 border-b border-ui-border-base/50 -mb-3 pb-1">
224
+ <button
225
+ type="button"
226
+ onClick={() => setActiveTab("general")}
227
+ className={`px-3 py-1.5 text-xs font-medium rounded-t-lg transition-colors border-b-2 -mb-[5px] ${
228
+ activeTab === "general"
229
+ ? "border-ui-border-interactive text-ui-fg-base bg-ui-bg-subtle/50"
230
+ : "border-transparent text-ui-fg-muted hover:text-ui-fg-base"
231
+ }`}
232
+ >
233
+ General & Ranking
234
+ </button>
235
+
236
+ <button
237
+ type="button"
238
+ onClick={() => setActiveTab("products")}
239
+ className={`px-3 py-1.5 text-xs font-medium rounded-t-lg transition-colors border-b-2 -mb-[5px] ${
240
+ activeTab === "products"
241
+ ? "border-ui-border-interactive text-ui-fg-base bg-ui-bg-subtle/50"
242
+ : "border-transparent text-ui-fg-muted hover:text-ui-fg-base"
243
+ }`}
244
+ >
245
+ Products Management
246
+ </button>
247
+
248
+ <button
249
+ type="button"
250
+ onClick={() => setActiveTab("channels")}
251
+ className={`px-3 py-1.5 text-xs font-medium rounded-t-lg transition-colors border-b-2 -mb-[5px] ${
252
+ activeTab === "channels"
253
+ ? "border-ui-border-interactive text-ui-fg-base bg-ui-bg-subtle/50"
254
+ : "border-transparent text-ui-fg-muted hover:text-ui-fg-base"
255
+ }`}
256
+ >
257
+ Storefronts & Media
258
+ </button>
259
+
260
+ <button
261
+ type="button"
262
+ onClick={() => setActiveTab("youtube")}
263
+ className={`px-3 py-1.5 text-xs font-medium rounded-t-lg transition-colors border-b-2 -mb-[5px] ${
264
+ activeTab === "youtube"
265
+ ? "border-ui-border-interactive text-ui-fg-base bg-ui-bg-subtle/50"
266
+ : "border-transparent text-ui-fg-muted hover:text-ui-fg-base"
267
+ }`}
268
+ >
269
+ YouTube Videos ({youtubeUrls.length})
270
+ </button>
161
271
  </div>
272
+ </Drawer.Header>
162
273
 
163
- <div className="flex flex-col gap-1.5 p-3 rounded-lg bg-ui-bg-subtle border border-ui-border-base">
164
- <div className="flex items-center justify-between">
165
- <Label htmlFor="edit-rank" className="text-xs font-semibold">
166
- Storefront Ranking (Sort Order)
167
- </Label>
168
- <span className="text-[10px] font-mono text-ui-fg-muted">
169
- Controls Storefront 2 & Monfil
170
- </span>
171
- </div>
172
- <div className="flex items-center gap-2">
173
- <Input
174
- id="edit-rank"
175
- type="number"
176
- size="small"
177
- value={rank}
178
- onChange={(e) => setRank(Number(e.target.value))}
179
- className="w-24"
180
- />
181
- <div className="flex items-center gap-1">
182
- <button
183
- type="button"
184
- onClick={() => setRank((r) => Math.max(0, r - 1))}
185
- className="px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors"
186
- title="Move Earlier in Storefront"
187
- >
188
- ↑ Earlier
189
- </button>
190
- <button
191
- type="button"
192
- onClick={() => setRank((r) => r + 1)}
193
- className="px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors"
194
- title="Move Later in Storefront"
195
- >
196
- ↓ Later
197
- </button>
198
- <button
199
- type="button"
200
- onClick={() => setRank(0)}
201
- className="px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors"
202
- title="Set as First Category"
203
- >
204
- Top (#0)
205
- </button>
274
+ <Drawer.Body className="flex flex-col gap-4 overflow-y-auto px-6 py-4 flex-1">
275
+ {/* TAB 1: GENERAL & RANKING */}
276
+ {activeTab === "general" && (
277
+ <div className="flex flex-col gap-4">
278
+ <div className="flex flex-col gap-1.5">
279
+ <Label htmlFor="edit-name" className="text-xs font-semibold">
280
+ Category Name *
281
+ </Label>
282
+ <Input
283
+ id="edit-name"
284
+ size="small"
285
+ value={name}
286
+ onChange={(e) => setName(e.target.value)}
287
+ />
206
288
  </div>
207
- </div>
208
- <p className="text-[11px] text-ui-fg-muted leading-relaxed">
209
- Categories are sorted by rank ASC (0, 1, 2...). Lower numbers appear first in your storefront navigation.
210
- </p>
211
- </div>
212
289
 
213
- <div className="flex flex-col gap-1.5">
214
- <Label htmlFor="edit-image" className="text-xs font-semibold">
215
- Category Image URL
216
- </Label>
217
- <Input
218
- id="edit-image"
219
- size="small"
220
- value={imageUrl}
221
- onChange={(e) => setImageUrl(e.target.value)}
222
- />
223
- {imageUrl && (
224
- <div className="mt-1 w-16 h-16 rounded-lg overflow-hidden border border-ui-border-base bg-ui-bg-subtle">
225
- <img
226
- src={imageUrl}
227
- alt="Category"
228
- className="w-full h-full object-cover"
229
- onError={(e) => {
230
- ;(e.target as any).style.display = "none"
231
- }}
290
+ <div className="flex flex-col gap-1.5">
291
+ <Label htmlFor="edit-handle" className="text-xs font-semibold">
292
+ Handle (URL slug)
293
+ </Label>
294
+ <Input
295
+ id="edit-handle"
296
+ size="small"
297
+ value={handle}
298
+ onChange={(e) => setHandle(e.target.value)}
232
299
  />
233
300
  </div>
234
- )}
235
- </div>
236
301
 
237
- <div className="flex flex-col gap-1.5">
238
- <Label htmlFor="edit-desc" className="text-xs font-semibold">
239
- Description
240
- </Label>
241
- <Textarea
242
- id="edit-desc"
243
- value={description}
244
- onChange={(e) => setDescription(e.target.value)}
245
- rows={2}
246
- />
247
- </div>
302
+ <div className="flex flex-col gap-1.5 p-3 rounded-lg bg-ui-bg-subtle border border-ui-border-base">
303
+ <div className="flex items-center justify-between">
304
+ <Label htmlFor="edit-rank" className="text-xs font-semibold">
305
+ Storefront Ranking (Sort Order)
306
+ </Label>
307
+ <span className="text-[10px] font-mono text-ui-fg-muted">
308
+ Controls Storefront 2 & Monfil
309
+ </span>
310
+ </div>
311
+ <div className="flex items-center gap-2">
312
+ <Input
313
+ id="edit-rank"
314
+ type="number"
315
+ size="small"
316
+ value={rank}
317
+ onChange={(e) => setRank(Number(e.target.value))}
318
+ className="w-24"
319
+ />
320
+ <div className="flex items-center gap-1">
321
+ <button
322
+ type="button"
323
+ onClick={() => setRank((r) => Math.max(0, r - 1))}
324
+ className="px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors"
325
+ title="Move Earlier in Storefront"
326
+ >
327
+ ↑ Earlier
328
+ </button>
329
+ <button
330
+ type="button"
331
+ onClick={() => setRank((r) => r + 1)}
332
+ className="px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors"
333
+ title="Move Later in Storefront"
334
+ >
335
+ ↓ Later
336
+ </button>
337
+ <button
338
+ type="button"
339
+ onClick={() => setRank(0)}
340
+ className="px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors"
341
+ title="Set as First Category"
342
+ >
343
+ Top (#0)
344
+ </button>
345
+ </div>
346
+ </div>
347
+ <p className="text-[11px] text-ui-fg-muted leading-relaxed">
348
+ Categories are sorted by rank ASC (0, 1, 2...). Lower numbers appear first on your storefront navigation menu.
349
+ </p>
350
+ </div>
248
351
 
249
- <div className="flex flex-col gap-2 pt-2 border-t border-ui-border-base">
250
- <Label className="text-xs font-semibold">Storefront Visibility</Label>
251
- <Text size="xsmall" className="text-ui-fg-muted">
252
- Designate which storefronts can display this category.
253
- </Text>
254
- <div className="flex flex-col gap-2 mt-1">
255
- {salesChannels.map((channel) => {
256
- const isChecked = selectedChannels.includes(channel.id)
257
- return (
258
- <label
259
- key={channel.id}
260
- className="flex items-center gap-2.5 text-xs text-ui-fg-base cursor-pointer select-none"
261
- >
262
- <input
263
- type="checkbox"
264
- checked={isChecked}
265
- onChange={() => toggleChannel(channel.id)}
266
- className="rounded border-ui-border-base text-blue-600 focus:ring-blue-500 cursor-pointer"
267
- />
268
- <span className="font-medium">{channel.name}</span>
269
- </label>
270
- )
271
- })}
352
+ <div className="flex flex-col gap-1.5">
353
+ <Label htmlFor="edit-desc" className="text-xs font-semibold">
354
+ Description
355
+ </Label>
356
+ <Textarea
357
+ id="edit-desc"
358
+ value={description}
359
+ onChange={(e) => setDescription(e.target.value)}
360
+ rows={3}
361
+ />
362
+ </div>
363
+
364
+ <div className="flex items-center justify-between pt-2 border-t border-ui-border-base">
365
+ <div>
366
+ <Label className="text-xs font-semibold">Active Status</Label>
367
+ <Text size="xsmall" className="text-ui-fg-muted">
368
+ Visible to shoppers on enabled storefronts
369
+ </Text>
370
+ </div>
371
+ <Switch checked={isActive} onCheckedChange={setIsActive} />
372
+ </div>
373
+
374
+ <div className="flex items-center justify-between">
375
+ <div>
376
+ <Label className="text-xs font-semibold">Internal Only</Label>
377
+ <Text size="xsmall" className="text-ui-fg-muted">
378
+ Hidden from public storefront navigation
379
+ </Text>
380
+ </div>
381
+ <Switch checked={isInternal} onCheckedChange={setIsInternal} />
382
+ </div>
383
+
384
+ <div className="pt-3 border-t border-ui-border-base">
385
+ <Button
386
+ type="button"
387
+ variant="danger"
388
+ size="small"
389
+ onClick={handleDelete}
390
+ isLoading={isDeleting}
391
+ className="w-full flex items-center justify-center gap-1.5"
392
+ >
393
+ <Trash className="w-3.5 h-3.5" />
394
+ <span>Delete Category</span>
395
+ </Button>
396
+ </div>
272
397
  </div>
273
- </div>
398
+ )}
274
399
 
275
- <div className="flex items-center justify-between pt-2 border-t border-ui-border-base">
276
- <div>
277
- <Label className="text-xs font-semibold">Active Status</Label>
278
- <Text size="xsmall" className="text-ui-fg-muted">
279
- Visible to shoppers on enabled storefronts
280
- </Text>
400
+ {/* TAB 2: PRODUCTS MANAGEMENT */}
401
+ {activeTab === "products" && (
402
+ <div className="flex flex-col gap-3">
403
+ <CategoryProductsManager
404
+ categoryId={category.id}
405
+ categoryName={category.name}
406
+ initialMetadata={currentMetadata}
407
+ onMetadataUpdate={(meta) => {
408
+ setCurrentMetadata(meta)
409
+ }}
410
+ />
281
411
  </div>
282
- <Switch checked={isActive} onCheckedChange={setIsActive} />
283
- </div>
412
+ )}
284
413
 
285
- <div className="flex items-center justify-between">
286
- <div>
287
- <Label className="text-xs font-semibold">Internal Only</Label>
288
- <Text size="xsmall" className="text-ui-fg-muted">
289
- Hidden from public storefront navigation
290
- </Text>
414
+ {/* TAB 3: STOREFRONTS & MEDIA */}
415
+ {activeTab === "channels" && (
416
+ <div className="flex flex-col gap-4">
417
+ <div className="flex flex-col gap-2">
418
+ <Label className="text-xs font-semibold">Storefront Visibility</Label>
419
+ <Text size="xsmall" className="text-ui-fg-muted">
420
+ Designate which storefront sales channels display this category.
421
+ </Text>
422
+ <div className="flex flex-col gap-2 mt-1">
423
+ {salesChannels.map((channel) => {
424
+ const isChecked = selectedChannels.includes(channel.id)
425
+ return (
426
+ <label
427
+ key={channel.id}
428
+ 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"
429
+ >
430
+ <input
431
+ type="checkbox"
432
+ checked={isChecked}
433
+ onChange={() => toggleChannel(channel.id)}
434
+ className="rounded border-ui-border-base text-blue-600 focus:ring-blue-500 cursor-pointer"
435
+ />
436
+ <span className="font-medium">{channel.name}</span>
437
+ </label>
438
+ )
439
+ })}
440
+ </div>
441
+ </div>
442
+
443
+ <div className="flex flex-col gap-1.5 pt-3 border-t border-ui-border-base">
444
+ <Label htmlFor="edit-image" className="text-xs font-semibold">
445
+ Category Thumbnail / Image URL
446
+ </Label>
447
+ <Input
448
+ id="edit-image"
449
+ size="small"
450
+ value={imageUrl}
451
+ onChange={(e) => setImageUrl(e.target.value)}
452
+ placeholder="https://example.com/category-image.jpg"
453
+ />
454
+ {imageUrl && (
455
+ <div className="mt-2 w-24 h-24 rounded-xl overflow-hidden border border-ui-border-base bg-ui-bg-subtle shadow-xs">
456
+ <img
457
+ src={imageUrl}
458
+ alt="Category Preview"
459
+ className="w-full h-full object-cover"
460
+ onError={(e) => {
461
+ ;(e.target as any).style.display = "none"
462
+ }}
463
+ />
464
+ </div>
465
+ )}
466
+ </div>
291
467
  </div>
292
- <Switch checked={isInternal} onCheckedChange={setIsInternal} />
293
- </div>
468
+ )}
294
469
 
295
- <div className="pt-3 border-t border-ui-border-base">
296
- <Button
297
- type="button"
298
- variant="danger"
299
- size="small"
300
- onClick={handleDelete}
301
- isLoading={isDeleting}
302
- className="w-full flex items-center justify-center gap-1.5"
303
- >
304
- <Trash className="w-3.5 h-3.5" />
305
- <span>Delete Category</span>
306
- </Button>
307
- </div>
470
+ {/* TAB 4: YOUTUBE VIDEOS */}
471
+ {activeTab === "youtube" && (
472
+ <div className="flex flex-col gap-4">
473
+ <div>
474
+ <h4 className="text-xs font-semibold text-ui-fg-base">
475
+ Category Demonstration Videos (YouTube)
476
+ </h4>
477
+ <p className="text-xs text-ui-fg-muted mt-0.5">
478
+ Attach YouTube video demonstration links to show interactive video carousels on the storefront category page.
479
+ </p>
480
+ </div>
481
+
482
+ <div className="flex items-center gap-2">
483
+ <Input
484
+ size="small"
485
+ placeholder="Paste YouTube watch URL, shorts, or video ID…"
486
+ value={newYoutubeInput}
487
+ onChange={(e) => setNewYoutubeInput(e.target.value)}
488
+ className="flex-1 text-xs"
489
+ onKeyDown={(e) => {
490
+ if (e.key === "Enter") {
491
+ e.preventDefault()
492
+ handleAddYoutube()
493
+ }
494
+ }}
495
+ />
496
+ <Button
497
+ size="small"
498
+ type="button"
499
+ variant="secondary"
500
+ onClick={handleAddYoutube}
501
+ className="flex items-center gap-1 text-xs flex-shrink-0"
502
+ >
503
+ <Plus className="w-3.5 h-3.5" />
504
+ <span>Add Video</span>
505
+ </Button>
506
+ </div>
507
+
508
+ {youtubeUrls.length === 0 ? (
509
+ <div className="p-8 text-center text-xs text-ui-fg-muted border border-dashed border-ui-border-base rounded-xl">
510
+ No demonstration videos attached yet.
511
+ </div>
512
+ ) : (
513
+ <div className="grid grid-cols-2 gap-3">
514
+ {youtubeUrls.map((url) => {
515
+ const id = extractYouTubeVideoId(url)
516
+ const thumb = id ? `https://img.youtube.com/vi/${id}/hqdefault.jpg` : null
517
+ return (
518
+ <div
519
+ key={url}
520
+ className="flex flex-col border border-ui-border-base rounded-xl overflow-hidden bg-ui-bg-subtle/40 shadow-xs"
521
+ >
522
+ <div className="relative aspect-video bg-black/80 overflow-hidden flex items-center justify-center">
523
+ {thumb ? (
524
+ <img src={thumb} alt="YouTube Preview" className="w-full h-full object-cover" />
525
+ ) : (
526
+ <span className="text-xs text-white">Video</span>
527
+ )}
528
+ <button
529
+ type="button"
530
+ onClick={() => handleRemoveYoutube(url)}
531
+ className="absolute top-1.5 right-1.5 p-1 bg-black/70 hover:bg-rose-600 text-white rounded-full transition-colors"
532
+ title="Remove video"
533
+ >
534
+ <Trash className="w-3 h-3" />
535
+ </button>
536
+ </div>
537
+ <div className="p-2">
538
+ <span className="text-[10px] text-ui-fg-muted truncate block font-mono" title={url}>
539
+ {url}
540
+ </span>
541
+ </div>
542
+ </div>
543
+ )
544
+ })}
545
+ </div>
546
+ )}
547
+ </div>
548
+ )}
308
549
  </Drawer.Body>
309
550
 
310
551
  <Drawer.Footer className="flex justify-end gap-2 px-6 py-4 border-t border-ui-border-base">
@@ -315,7 +556,7 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
315
556
  onClick={() => onOpenChange(false)}
316
557
  disabled={isSubmitting || isDeleting}
317
558
  >
318
- Cancel
559
+ Close
319
560
  </Button>
320
561
  <Button
321
562
  type="submit"
@@ -323,7 +564,7 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
323
564
  isLoading={isSubmitting}
324
565
  disabled={isSubmitting || !name.trim() || isDeleting}
325
566
  >
326
- Save Changes
567
+ Save Category
327
568
  </Button>
328
569
  </Drawer.Footer>
329
570
  </form>