@dyrected/admin 1.0.1

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.
Files changed (95) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE.md +50 -0
  3. package/README.md +73 -0
  4. package/components.json +17 -0
  5. package/eslint.config.js +22 -0
  6. package/index.html +13 -0
  7. package/package.json +99 -0
  8. package/postcss.config.js +6 -0
  9. package/public/favicon.svg +1 -0
  10. package/public/icons.svg +24 -0
  11. package/src/App.css +184 -0
  12. package/src/App.tsx +25 -0
  13. package/src/assets/dyrected.svg +155 -0
  14. package/src/assets/hero.png +0 -0
  15. package/src/assets/react.svg +1 -0
  16. package/src/assets/vite.svg +1 -0
  17. package/src/components/auth/auth-gate.tsx +64 -0
  18. package/src/components/error-boundary.tsx +45 -0
  19. package/src/components/forms/field-renderer.tsx +111 -0
  20. package/src/components/forms/fields/block-builder.tsx +213 -0
  21. package/src/components/forms/fields/date-picker.tsx +60 -0
  22. package/src/components/forms/fields/json-editor.tsx +62 -0
  23. package/src/components/forms/fields/media-picker.tsx +286 -0
  24. package/src/components/forms/fields/multi-select.tsx +145 -0
  25. package/src/components/forms/fields/radio-field.tsx +51 -0
  26. package/src/components/forms/fields/relationship-picker.tsx +143 -0
  27. package/src/components/forms/fields/rich-text-editor.tsx +224 -0
  28. package/src/components/forms/fields/select-field.tsx +35 -0
  29. package/src/components/forms/fields/switch-field.tsx +16 -0
  30. package/src/components/forms/fields/text-area-field.tsx +15 -0
  31. package/src/components/forms/fields/text-field.tsx +24 -0
  32. package/src/components/forms/form-engine.tsx +87 -0
  33. package/src/components/forms/form-field-renderer.tsx +269 -0
  34. package/src/components/forms/utils.ts +97 -0
  35. package/src/components/layout/admin-shell.tsx +479 -0
  36. package/src/components/layout/branding-provider.tsx +112 -0
  37. package/src/components/live-preview/LivePreviewPane.tsx +128 -0
  38. package/src/components/media/focal-point-picker.tsx +66 -0
  39. package/src/components/media/media-card.tsx +44 -0
  40. package/src/components/media/media-grid.tsx +32 -0
  41. package/src/components/media/media-library-dialog.tsx +465 -0
  42. package/src/components/ui/aspect-ratio.tsx +7 -0
  43. package/src/components/ui/badge.tsx +36 -0
  44. package/src/components/ui/button.tsx +56 -0
  45. package/src/components/ui/calendar.tsx +214 -0
  46. package/src/components/ui/card.tsx +79 -0
  47. package/src/components/ui/checkbox.tsx +28 -0
  48. package/src/components/ui/command.tsx +151 -0
  49. package/src/components/ui/data-table.tsx +219 -0
  50. package/src/components/ui/dialog.tsx +122 -0
  51. package/src/components/ui/dropdown-menu.tsx +200 -0
  52. package/src/components/ui/form.tsx +178 -0
  53. package/src/components/ui/input.tsx +24 -0
  54. package/src/components/ui/label.tsx +24 -0
  55. package/src/components/ui/page-header.tsx +30 -0
  56. package/src/components/ui/pagination.tsx +57 -0
  57. package/src/components/ui/popover.tsx +29 -0
  58. package/src/components/ui/progress.tsx +26 -0
  59. package/src/components/ui/radio-group.tsx +42 -0
  60. package/src/components/ui/render-cell.tsx +110 -0
  61. package/src/components/ui/scroll-area.tsx +46 -0
  62. package/src/components/ui/select.tsx +160 -0
  63. package/src/components/ui/separator.tsx +29 -0
  64. package/src/components/ui/sheet.tsx +140 -0
  65. package/src/components/ui/sidebar.tsx +771 -0
  66. package/src/components/ui/skeleton.tsx +15 -0
  67. package/src/components/ui/sonner.tsx +27 -0
  68. package/src/components/ui/switch.tsx +27 -0
  69. package/src/components/ui/table.tsx +117 -0
  70. package/src/components/ui/tabs.tsx +53 -0
  71. package/src/components/ui/textarea.tsx +22 -0
  72. package/src/components/ui/toggle.tsx +43 -0
  73. package/src/components/ui/tooltip.tsx +28 -0
  74. package/src/hooks/use-mobile.tsx +19 -0
  75. package/src/hooks/use-preferences.ts +56 -0
  76. package/src/index.css +111 -0
  77. package/src/index.tsx +198 -0
  78. package/src/lib/utils.ts +32 -0
  79. package/src/main.tsx +10 -0
  80. package/src/pages/auth/first-user-page.tsx +115 -0
  81. package/src/pages/auth/login-page.tsx +91 -0
  82. package/src/pages/collections/edit-page.tsx +280 -0
  83. package/src/pages/collections/list-page.tsx +343 -0
  84. package/src/pages/dashboard/dashboard.tsx +150 -0
  85. package/src/pages/globals/editor-page.tsx +122 -0
  86. package/src/pages/media/media-page.tsx +564 -0
  87. package/src/pages/setup/setup-prompt.tsx +152 -0
  88. package/src/providers/dyrected-provider.tsx +122 -0
  89. package/src/providers/query-provider.tsx +19 -0
  90. package/src/types/jexl.d.ts +11 -0
  91. package/tailwind.config.ts +102 -0
  92. package/tsconfig.app.json +29 -0
  93. package/tsconfig.json +12 -0
  94. package/tsconfig.node.json +27 -0
  95. package/vite.config.ts +36 -0
@@ -0,0 +1,564 @@
1
+ import * as React from "react"
2
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
3
+ import { toast } from "sonner"
4
+ import { useDyrected } from "../../providers/dyrected-provider"
5
+ import { Button } from "../../components/ui/button"
6
+ import { Input } from "../../components/ui/input"
7
+ import { cn, getMediaUrl } from "../../lib/utils"
8
+ import {
9
+ Card,
10
+ CardContent,
11
+ CardHeader,
12
+ } from "../../components/ui/card"
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ DialogTrigger,
19
+ } from "../../components/ui/dialog"
20
+ import { ScrollArea } from "../../components/ui/scroll-area"
21
+ import { AspectRatio } from "../../components/ui/aspect-ratio"
22
+ import {
23
+ Upload,
24
+ Search,
25
+ FileIcon,
26
+ Trash2,
27
+ ExternalLink,
28
+ Image as ImageIcon,
29
+ Copy,
30
+ Info
31
+ } from "lucide-react"
32
+ import { useDropzone } from "react-dropzone"
33
+ import { Progress } from "../../components/ui/progress"
34
+ import {
35
+ Sheet,
36
+ SheetContent,
37
+ SheetHeader,
38
+ SheetTitle,
39
+ } from "../../components/ui/sheet"
40
+ import { Separator } from "../../components/ui/separator"
41
+ // import { FocalPointPicker } from "../../components/media/focal-point-picker"
42
+ import { Blurhash } from "react-blurhash"
43
+
44
+ export function MediaPage({ collectionSlug, schema }: { collectionSlug?: string, schema?: any }) {
45
+ const { client } = useDyrected()
46
+ const queryClient = useQueryClient()
47
+ const [search, setSearch] = React.useState("")
48
+ const [isUploadOpen, setIsUploadOpen] = React.useState(false)
49
+ const [selectedItem, setSelectedItem] = React.useState<any>(null)
50
+
51
+ const { data: mediaResponse, isLoading } = useQuery({
52
+ queryKey: ["media", collectionSlug, search],
53
+ queryFn: () => client!.listMedia({ where: search ? { filename: { contains: search } } : undefined }, collectionSlug).then(r => r.docs),
54
+ enabled: !!client,
55
+ })
56
+
57
+ const deleteMutation = useMutation({
58
+ mutationFn: (id: string) => client!.deleteMedia(id, collectionSlug),
59
+ onSuccess: () => {
60
+ queryClient.invalidateQueries({ queryKey: ["media"] })
61
+ toast.success("Asset deleted successfully")
62
+ },
63
+ onError: (error: any) => {
64
+ toast.error("Failed to delete asset", {
65
+ description: error.message
66
+ })
67
+ }
68
+ })
69
+
70
+ const updateMutation = useMutation({
71
+ mutationFn: (args: { id: string, data: any }) => client!.update(collectionSlug || "media", args.id, args.data),
72
+ onSuccess: (data) => {
73
+ queryClient.invalidateQueries({ queryKey: ["media"] })
74
+ setSelectedItem(data)
75
+ toast.success("Asset details updated")
76
+ },
77
+ onError: (error: any) => {
78
+ toast.error("Failed to update asset", {
79
+ description: error.message
80
+ })
81
+ }
82
+ })
83
+
84
+ const onDrop = React.useCallback((acceptedFiles: File[]) => {
85
+ if (acceptedFiles.length > 0) {
86
+ setIsUploadOpen(true)
87
+ }
88
+ }, [])
89
+
90
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
91
+ onDrop,
92
+ noClick: true, // Only trigger on drop, not on background click
93
+ })
94
+
95
+ return (
96
+ <div {...getRootProps()} className="min-h-full space-y-8 animate-in relative">
97
+ <input {...getInputProps()} />
98
+
99
+ {isDragActive && (
100
+ <div className="absolute inset-0 z-50 bg-primary/10 backdrop-blur-[2px] border-4 border-dashed border-primary rounded-2xl flex items-center justify-center pointer-events-none">
101
+ <div className="bg-white p-8 rounded-2xl shadow-2xl flex flex-col items-center gap-4">
102
+ <div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
103
+ <Upload className="h-8 w-8 text-primary animate-bounce" />
104
+ </div>
105
+ <p className="text-xl font-bold">Drop to upload assets</p>
106
+ </div>
107
+ </div>
108
+ )}
109
+ <div className="flex items-end justify-between border-b border-border/50 pb-6">
110
+ <div>
111
+ <div className="flex items-center gap-2 mb-1">
112
+ <ImageIcon className="h-5 w-5 text-primary" />
113
+ <h1 className="text-3xl font-bold tracking-tight text-foreground">
114
+ {schema?.labels?.plural ?? schema?.label ?? "Media Library"}
115
+ </h1>
116
+ </div>
117
+ <p className="text-sm text-muted-foreground">
118
+ Manage your images, documents, and other assets for this site.
119
+ </p>
120
+ </div>
121
+ <Dialog open={isUploadOpen} onOpenChange={setIsUploadOpen}>
122
+ <DialogTrigger asChild>
123
+ <Button className="h-10 px-4 rounded-lg bg-primary hover:bg-primary/90 shadow-md transition-all active:scale-95">
124
+ <Upload className="mr-2 h-4 w-4" />
125
+ Upload Assets
126
+ </Button>
127
+ </DialogTrigger>
128
+ <DialogContent className="sm:max-w-[600px] rounded-2xl overflow-hidden border-none shadow-2xl">
129
+ <DialogHeader className="pb-4 border-b border-border/40">
130
+ <DialogTitle className="text-xl font-bold">Upload Media Assets</DialogTitle>
131
+ </DialogHeader>
132
+ <FileUploader
133
+ collectionSlug={collectionSlug}
134
+ onComplete={() => {
135
+ setIsUploadOpen(false)
136
+ queryClient.invalidateQueries({ queryKey: ["media", collectionSlug] })
137
+ }}
138
+ />
139
+ </DialogContent>
140
+ </Dialog>
141
+ </div>
142
+
143
+ <div className="flex items-center gap-4">
144
+ <div className="relative flex-1 max-w-sm">
145
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60" />
146
+ <Input
147
+ placeholder="Search assets by filename..."
148
+ className="pl-10 h-11 bg-white border-border/60 rounded-xl shadow-sm focus-visible:ring-primary/20"
149
+ value={search}
150
+ onChange={(e) => setSearch(e.target.value)}
151
+ />
152
+ </div>
153
+ </div>
154
+
155
+ <ScrollArea className="h-[calc(100vh-320px)] pr-4">
156
+ {isLoading ? (
157
+ <div className="flex h-60 items-center justify-center">
158
+ <div className="animate-spin rounded-full border-4 border-primary/20 border-t-primary h-10 w-10"></div>
159
+ </div>
160
+ ) : mediaResponse?.length === 0 ? (
161
+ <div className="flex h-80 flex-col items-center justify-center rounded-2xl border-2 border-dashed border-border/60 bg-muted/5 text-center animate-in">
162
+ <div className="h-16 w-16 rounded-2xl bg-muted/40 flex items-center justify-center mb-4">
163
+ <FileIcon className="h-8 w-8 text-muted-foreground/50" />
164
+ </div>
165
+ <h3 className="text-lg font-bold text-foreground">No assets found</h3>
166
+ <p className="text-sm text-muted-foreground max-w-xs mx-auto">
167
+ Your media library is empty. Upload some files to start building your content.
168
+ </p>
169
+ </div>
170
+ ) : (
171
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6 pb-8">
172
+ {mediaResponse?.map((item) => (
173
+ <MediaCard
174
+ key={item.id}
175
+ item={item}
176
+ baseUrl={client!.getBaseUrl()}
177
+ onDelete={() => deleteMutation.mutate(item.id)}
178
+ onClick={() => setSelectedItem(item)}
179
+ isSelected={selectedItem?.id === item.id}
180
+ />
181
+ ))}
182
+ </div>
183
+ )}
184
+ </ScrollArea>
185
+
186
+ <MediaSidebar
187
+ item={selectedItem}
188
+ onClose={() => setSelectedItem(null)}
189
+ baseUrl={client!.getBaseUrl()}
190
+ onUpdate={(data) => updateMutation.mutate({ id: selectedItem.id, data })}
191
+ />
192
+ </div>
193
+ )
194
+ }
195
+
196
+ function MediaCard({ item, baseUrl, onDelete, onClick, isSelected }: {
197
+ item: any,
198
+ baseUrl: string,
199
+ onDelete: () => void,
200
+ onClick: () => void,
201
+ isSelected: boolean
202
+ }) {
203
+ const isImage = item.mimeType?.startsWith("image/")
204
+ const url = getMediaUrl(item, baseUrl)
205
+
206
+ return (
207
+ <Card
208
+ className={cn(
209
+ "overflow-hidden group relative border-border/40 bg-white shadow-sm hover:shadow-xl transition-all duration-300 rounded-xl cursor-pointer",
210
+ isSelected && "ring-2 ring-primary ring-offset-2 shadow-lg scale-[0.98]"
211
+ )}
212
+ onClick={onClick}
213
+ >
214
+ <CardHeader className="p-0 border-b border-border/10">
215
+ <AspectRatio ratio={1 / 1} className="bg-muted/30 overflow-hidden relative">
216
+ {isImage ? (
217
+ <>
218
+ {item.blurhash && (
219
+ <div className="absolute inset-0 z-0">
220
+ <Blurhash
221
+ hash={item.blurhash}
222
+ width="100%"
223
+ height="100%"
224
+ resolutionX={32}
225
+ resolutionY={32}
226
+ punch={1}
227
+ />
228
+ </div>
229
+ )}
230
+ <img
231
+ src={url}
232
+ alt={item.filename}
233
+ className="object-cover w-full h-full transition-transform duration-500 group-hover:scale-110 relative z-10"
234
+ loading="lazy"
235
+ />
236
+ </>
237
+ ) : (
238
+ <div className="flex items-center justify-center h-full bg-primary/5">
239
+ <FileIcon className="h-10 w-10 text-primary/40" />
240
+ </div>
241
+ )}
242
+ </AspectRatio>
243
+ </CardHeader>
244
+ <CardContent className="p-3 bg-white">
245
+ <p className="text-[11px] font-bold truncate text-foreground/90 mb-0.5" title={item.filename}>
246
+ {item.filename}
247
+ </p>
248
+ <div className="flex items-center justify-between">
249
+ <p className="text-[9px] text-muted-foreground font-bold uppercase tracking-wider">
250
+ {item.mimeType?.split("/")[1] || "file"}
251
+ </p>
252
+ <p className="text-[9px] text-muted-foreground font-medium">
253
+ {((item.filesize || item.size || 0) / 1024).toFixed(1)} KB
254
+ </p>
255
+ </div>
256
+ </CardContent>
257
+ <div className="absolute top-2 right-2 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
258
+ <Button
259
+ size="icon"
260
+ variant="destructive"
261
+ className="h-7 w-7 rounded-lg shadow-lg"
262
+ onClick={(e) => {
263
+ e.stopPropagation()
264
+ if (confirm("Are you sure you want to delete this file?")) {
265
+ onDelete()
266
+ }
267
+ }}
268
+ >
269
+ <Trash2 className="h-3.5 w-3.5" />
270
+ </Button>
271
+ </div>
272
+ </Card>
273
+ )
274
+ }
275
+
276
+ function MediaSidebar({ item, onClose, baseUrl, onUpdate }: {
277
+ item: any,
278
+ onClose: () => void,
279
+ baseUrl: string,
280
+ onUpdate: (data: any) => void
281
+ }) {
282
+ const [formData, setFormData] = React.useState<any>({})
283
+ const [isSaving, setIsSaving] = React.useState(false)
284
+
285
+ React.useEffect(() => {
286
+ if (item) {
287
+ setFormData({
288
+ alt: item.alt || "",
289
+ caption: item.caption || "",
290
+ filename: item.filename || "",
291
+ })
292
+ }
293
+ }, [item])
294
+
295
+ if (!item) return null
296
+
297
+ const isImage = item.mimeType?.startsWith("image/")
298
+ const url = getMediaUrl(item, baseUrl)
299
+
300
+ const handleSave = async () => {
301
+ setIsSaving(true)
302
+ try {
303
+ await onUpdate(formData)
304
+ } finally {
305
+ setIsSaving(false)
306
+ }
307
+ }
308
+
309
+ const hasChanges =
310
+ formData.alt !== (item.alt || "") ||
311
+ formData.caption !== (item.caption || "") ||
312
+ formData.filename !== (item.filename || "")
313
+
314
+ return (
315
+ <Sheet open={!!item} onOpenChange={onClose}>
316
+ <SheetContent className="sm:max-w-md p-0 flex flex-col h-full border-l border-border/40 bg-white shadow-2xl">
317
+ <SheetHeader className="p-6 border-b border-border/40 bg-white">
318
+ <SheetTitle className="flex items-center gap-2">
319
+ <Info className="h-5 w-5 text-primary" />
320
+ File Details
321
+ </SheetTitle>
322
+ </SheetHeader>
323
+
324
+ <ScrollArea className="flex-1 bg-white">
325
+ <div className="p-6 space-y-8">
326
+ <div className="rounded-xl overflow-hidden border border-border/40 bg-muted/10 relative shadow-inner">
327
+ <AspectRatio ratio={16 / 9}>
328
+ {isImage ? (
329
+ <>
330
+ {item.blurhash && (
331
+ <div className="absolute inset-0 z-0">
332
+ <Blurhash
333
+ hash={item.blurhash}
334
+ width="100%"
335
+ height="100%"
336
+ resolutionX={32}
337
+ resolutionY={32}
338
+ punch={1}
339
+ />
340
+ </div>
341
+ )}
342
+ <img src={url} alt={item.filename} className="object-contain w-full h-full bg-checkered relative z-10" />
343
+ </>
344
+ ) : (
345
+ <div className="flex items-center justify-center h-full">
346
+ <FileIcon className="h-16 w-16 text-muted-foreground/30" />
347
+ </div>
348
+ )}
349
+ </AspectRatio>
350
+ </div>
351
+
352
+ <div className="space-y-6">
353
+ <div className="space-y-4">
354
+ <div className="space-y-2">
355
+ <label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/80">Filename</label>
356
+ <Input
357
+ value={formData.filename}
358
+ onChange={(e) => setFormData({ ...formData, filename: e.target.value })}
359
+ className="h-10 rounded-lg bg-white border-border/60 focus:ring-1 focus:ring-primary/20"
360
+ />
361
+ </div>
362
+ <div className="space-y-2">
363
+ <label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/80">Alt Text</label>
364
+ <Input
365
+ value={formData.alt}
366
+ onChange={(e) => setFormData({ ...formData, alt: e.target.value })}
367
+ placeholder="Describe the image for accessibility..."
368
+ className="h-10 rounded-lg bg-white border-border/60 focus:ring-1 focus:ring-primary/20"
369
+ />
370
+ </div>
371
+ <div className="space-y-2">
372
+ <label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/80">Caption</label>
373
+ <textarea
374
+ value={formData.caption}
375
+ onChange={(e) => setFormData({ ...formData, caption: e.target.value })}
376
+ placeholder="Add a caption..."
377
+ className="flex min-h-[80px] w-full rounded-lg border border-border/60 bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50"
378
+ />
379
+ </div>
380
+ </div>
381
+
382
+ <Separator className="bg-border/40" />
383
+
384
+ <div className="grid grid-cols-2 gap-6">
385
+ <DetailItem label="File ID" value={item.id} copyable />
386
+ <DetailItem label="Size" value={`${((item.filesize || item.size || 0) / 1024).toFixed(1)} KB`} />
387
+ <DetailItem label="Type" value={item.mimeType || "Unknown"} />
388
+ <DetailItem label="Dimensions" value={item.width ? `${item.width}x${item.height}` : "N/A"} />
389
+ </div>
390
+
391
+ <DetailItem label="URL" value={url} copyable />
392
+ <DetailItem label="Created At" value={item?.createdAt ? new Date(item?.createdAt).toLocaleString() : "N/A"} />
393
+ </div>
394
+
395
+ {/* {isImage && (
396
+ <div className="space-y-4">
397
+ <Separator className="bg-border/40" />
398
+ <div className="space-y-4">
399
+ <label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/80">Focal Point</label>
400
+ <FocalPointPicker
401
+ url={url}
402
+ value={item.focalPoint}
403
+ onChange={(fp) => {
404
+ onUpdate({ focalPoint: fp })
405
+ }}
406
+ />
407
+ </div>
408
+ </div>
409
+ )} */}
410
+ </div>
411
+ </ScrollArea>
412
+
413
+ <div className="p-6 border-t border-border/40 bg-muted/5 space-y-3">
414
+ {hasChanges && (
415
+ <Button
416
+ className="w-full h-12 rounded-xl font-bold bg-primary text-white shadow-lg shadow-primary/20 animate-in fade-in slide-in-from-bottom-2"
417
+ onClick={handleSave}
418
+ disabled={isSaving}
419
+ >
420
+ {isSaving ? "Saving..." : "Save Changes"}
421
+ </Button>
422
+ )}
423
+ <Button className="w-full h-11 rounded-xl font-bold gap-2 bg-white" variant="outline" asChild>
424
+ <a href={url} target="_blank" rel="noreferrer">
425
+ <ExternalLink className="h-4 w-4" />
426
+ Open Original
427
+ </a>
428
+ </Button>
429
+ </div>
430
+ </SheetContent>
431
+ </Sheet>
432
+ )
433
+ }
434
+
435
+ function DetailItem({ label, value, copyable }: {
436
+ label: string,
437
+ value: string,
438
+ copyable?: boolean
439
+ }) {
440
+ return (
441
+ <div className="space-y-1.5">
442
+ <label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/80">{label}</label>
443
+ <div className="flex items-center gap-2 group">
444
+ <p className="text-sm font-medium text-foreground truncate flex-1">{value}</p>
445
+ {copyable && (
446
+ <Button
447
+ size="icon"
448
+ variant="ghost"
449
+ className="h-7 w-7 text-muted-foreground hover:text-primary transition-colors"
450
+ onClick={() => navigator.clipboard.writeText(value)}
451
+ >
452
+ <Copy className="h-3.5 w-3.5" />
453
+ </Button>
454
+ )}
455
+ </div>
456
+ </div>
457
+ )
458
+ }
459
+
460
+ function FileUploader({ collectionSlug, onComplete }: { collectionSlug?: string, onComplete: () => void }) {
461
+ const { client } = useDyrected()
462
+ const [files, setFiles] = React.useState<File[]>([])
463
+ const [uploading, setUploading] = React.useState(false)
464
+ const [progress, setProgress] = React.useState(0)
465
+
466
+ const onDrop = React.useCallback((acceptedFiles: File[]) => {
467
+ setFiles(prev => [...prev, ...acceptedFiles])
468
+ }, [])
469
+
470
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop })
471
+
472
+ const handleUpload = async () => {
473
+ if (files.length === 0) return
474
+ setUploading(true)
475
+ setProgress(0)
476
+
477
+ try {
478
+ for (let i = 0; i < files.length; i++) {
479
+ await client!.uploadMedia(files[i], collectionSlug)
480
+ setProgress(((i + 1) / files.length) * 100)
481
+ }
482
+ onComplete()
483
+ toast.success(`${files.length} assets uploaded successfully`)
484
+ } catch (error: any) {
485
+ console.error("Upload failed", error)
486
+ toast.error("Failed to upload assets", {
487
+ description: error.message
488
+ })
489
+ } finally {
490
+ setUploading(false)
491
+ }
492
+ }
493
+
494
+ return (
495
+ <div className="space-y-6 py-6 px-4">
496
+ <div
497
+ {...getRootProps()}
498
+ className={`border-2 border-dashed rounded-2xl p-12 text-center cursor-pointer transition-all duration-300 ${isDragActive
499
+ ? "border-primary bg-primary/5 scale-[0.98]"
500
+ : "border-muted-foreground/20 hover:border-primary/40 hover:bg-muted/5"
501
+ }`}
502
+ >
503
+ <input {...getInputProps()} />
504
+ <div className="h-16 w-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-4">
505
+ <Upload className="h-8 w-8 text-primary" />
506
+ </div>
507
+ <p className="text-xl font-bold text-foreground">Drag & drop assets</p>
508
+ <p className="text-sm text-muted-foreground mt-1">or click to browse your files</p>
509
+ </div>
510
+
511
+ {files.length > 0 && (
512
+ <div className="space-y-4 animate-in fade-in slide-in-from-bottom-4">
513
+ <div className="flex items-center justify-between">
514
+ <p className="text-sm font-bold text-foreground">{files.length} assets selected</p>
515
+ <Button variant="ghost" size="sm" onClick={() => setFiles([])} disabled={uploading} className="text-xs h-8">
516
+ Clear All
517
+ </Button>
518
+ </div>
519
+
520
+ <div className="max-h-[240px] overflow-auto space-y-2 pr-2 custom-scrollbar">
521
+ {files.map((file, idx) => (
522
+ <div key={idx} className="flex items-center justify-between p-3 bg-muted/30 border border-border/40 rounded-xl text-sm group transition-colors hover:bg-muted/50">
523
+ <div className="flex items-center gap-3 truncate">
524
+ <div className="h-8 w-8 rounded-lg bg-white border border-border/60 flex items-center justify-center flex-shrink-0">
525
+ <FileIcon className="h-4 w-4 text-muted-foreground" />
526
+ </div>
527
+ <span className="truncate font-medium text-foreground/80">{file.name}</span>
528
+ </div>
529
+ <span className="text-muted-foreground text-[10px] font-bold bg-white px-2 py-1 rounded border border-border/40 ml-4">
530
+ {(file.size / 1024).toFixed(1)} KB
531
+ </span>
532
+ </div>
533
+ ))}
534
+ </div>
535
+
536
+ {uploading && (
537
+ <div className="space-y-2 pt-2">
538
+ <div className="flex justify-between text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
539
+ <span>Uploading...</span>
540
+ <span>{Math.round(progress)}%</span>
541
+ </div>
542
+ <Progress value={progress} className="h-2 rounded-full" />
543
+ </div>
544
+ )}
545
+
546
+ <div className="flex justify-end pt-4 border-t border-border/40">
547
+ <Button
548
+ onClick={handleUpload}
549
+ disabled={uploading || files.length === 0}
550
+ className="w-full h-12 rounded-xl bg-primary hover:bg-primary/90 text-white font-bold shadow-lg shadow-primary/20 transition-all active:scale-[0.98]"
551
+ >
552
+ {uploading ? (
553
+ <span className="flex items-center gap-2">
554
+ <div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
555
+ Uploading Assets...
556
+ </span>
557
+ ) : `Upload ${files.length} Assets`}
558
+ </Button>
559
+ </div>
560
+ </div>
561
+ )}
562
+ </div>
563
+ )
564
+ }