@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,465 @@
1
+ import * as React from "react"
2
+ import { useQuery } from "@tanstack/react-query"
3
+ import { useDyrected } from "../../providers/dyrected-provider"
4
+ import { Button } from "../ui/button"
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogTitle,
9
+ } from "../ui/dialog"
10
+ import {
11
+ Tabs,
12
+ TabsContent,
13
+ TabsList,
14
+ TabsTrigger
15
+ } from "../ui/tabs"
16
+ import {
17
+ Image as ImageIcon,
18
+ Video,
19
+ Search,
20
+ Upload,
21
+ Library,
22
+ Check,
23
+ Link as LinkIcon,
24
+ Globe,
25
+ Info,
26
+ Sparkles
27
+ } from "lucide-react"
28
+ import { ScrollArea } from "../ui/scroll-area"
29
+ import { Input } from "../ui/input"
30
+ import { getMediaUrl, cn } from "../../lib/utils"
31
+
32
+ interface MediaLibraryDialogProps {
33
+ collection: string
34
+ isOpen: boolean
35
+ onOpenChange: (open: boolean) => void
36
+ selectedValues: string[]
37
+ onSelect: (id: string) => void
38
+ multiple?: boolean
39
+ onConfirm?: (selectedIds: string[]) => void
40
+ }
41
+
42
+ export function MediaLibraryDialog({
43
+ collection,
44
+ isOpen,
45
+ onOpenChange,
46
+ selectedValues,
47
+ onSelect,
48
+ multiple,
49
+ onConfirm
50
+ }: MediaLibraryDialogProps) {
51
+ const { client } = useDyrected()
52
+ const [searchQuery, setSearchQuery] = React.useState("")
53
+ const [externalUrl, setExternalUrl] = React.useState("")
54
+ const [activeTab, setActiveTab] = React.useState("library")
55
+ const [selectedItem, setSelectedItem] = React.useState<any>(null)
56
+ const [isUploading, setIsUploading] = React.useState(false)
57
+
58
+ const { data: media, refetch } = useQuery({
59
+ queryKey: [collection, searchQuery],
60
+ queryFn: () => client!.listMedia({
61
+ where: searchQuery ? { filename: { contains: searchQuery } } : undefined
62
+ }, collection).then((r: any) => r.docs),
63
+ enabled: isOpen && !!client,
64
+ })
65
+
66
+ const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
67
+ const file = e.target.files?.[0]
68
+ if (!file || !client) return
69
+
70
+ setIsUploading(true)
71
+ try {
72
+ const result = await client.collection(collection).upload(file, {})
73
+ await refetch()
74
+ onSelect(result.id)
75
+ if (!multiple) onOpenChange(false)
76
+ } catch (error) {
77
+ console.error("Upload failed:", error)
78
+ alert("Upload failed. Please try again.")
79
+ } finally {
80
+ setIsUploading(false)
81
+ }
82
+ }
83
+
84
+ const handleExternalUrlSubmit = async () => {
85
+ if (!externalUrl || !client) return
86
+
87
+ setIsUploading(true)
88
+ try {
89
+ let mimeType = 'application/octet-stream'
90
+ let filename = 'External Asset'
91
+ let idPrefix = 'ext'
92
+
93
+ // YouTube Detection
94
+ const ytMatch = externalUrl.match(/(?:youtu\.be\/|youtube\.com\/(?:v\/|u\/\w\/|embed\/|watch\?v=))([^#\&\?]*)/)
95
+ if (ytMatch && ytMatch[1]) {
96
+ mimeType = 'video/youtube'
97
+ filename = `YouTube: ${ytMatch[1]}`
98
+ idPrefix = `yt_${ytMatch[1]}`
99
+ }
100
+ // Vimeo Detection
101
+ else if (externalUrl.match(/vimeo\.com\/(?:video\/)?([0-9]+)/)) {
102
+ const vimeoId = externalUrl.match(/vimeo\.com\/(?:video\/)?([0-9]+)/)![1]
103
+ mimeType = 'video/vimeo'
104
+ filename = `Vimeo: ${vimeoId}`
105
+ idPrefix = `vm_${vimeoId}`
106
+ }
107
+ // Image Detection
108
+ else if (externalUrl.match(/\.(jpeg|jpg|gif|png|webp|svg|avif)(?:\?.*)?$/i)) {
109
+ mimeType = 'image/external'
110
+ filename = externalUrl.split('/').pop()?.split('?')[0] || 'External Image'
111
+ idPrefix = `img_${Math.random().toString(36).substring(7)}`
112
+ }
113
+ // Default / Generic
114
+ else {
115
+ filename = externalUrl.split('/').pop()?.split('?')[0] || 'External File'
116
+ }
117
+
118
+ const result = await client.collection(collection).create({
119
+ filename,
120
+ url: externalUrl,
121
+ mimeType,
122
+ filesize: 0,
123
+ id: idPrefix
124
+ })
125
+
126
+ await refetch()
127
+ onSelect(result.id)
128
+ if (!multiple) onOpenChange(false)
129
+ setExternalUrl("")
130
+ } catch (error) {
131
+ console.error("Failed to add external URL:", error)
132
+ alert("Failed to add URL. Please make sure it is valid.")
133
+ } finally {
134
+ setIsUploading(false)
135
+ }
136
+ }
137
+
138
+ const getPreviewUrl = (item: any) => {
139
+ if (!item) return ""
140
+ if (item.mimeType === 'video/youtube') {
141
+ const match = item.url?.match(/(?:youtu\.be\/|youtube\.com\/(?:v\/|u\/\w\/|embed\/|watch\?v=))([^#\&\?]*)/)
142
+ const videoId = match && match[1]
143
+ return `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`
144
+ }
145
+ if (item.mimeType === 'video/vimeo') {
146
+ // Vimeo thumbnails are harder to get purely client side without API,
147
+ // but we can use a placeholder or better, try to fetch if we had a proper utility.
148
+ // For now, let's use a generic vimeo-style placeholder or icon
149
+ return "https://vimeo.com/assets/images/logo_vimeo_blue.png"
150
+ }
151
+ if (item.mimeType === 'image/external') {
152
+ return item.url
153
+ }
154
+ return getMediaUrl(item, client?.getBaseUrl() || "");
155
+ }
156
+
157
+ const handleConfirm = () => {
158
+ if (onConfirm) {
159
+ onConfirm(selectedValues)
160
+ }
161
+ onOpenChange(false)
162
+ }
163
+
164
+ return (
165
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
166
+ <DialogContent className="sm:max-w-[900px] p-0 overflow-hidden gap-0 bg-background border-none shadow-2xl">
167
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-[650px]">
168
+ <div className="px-6 py-4 border-b flex items-center justify-between bg-muted/20">
169
+ <div className="flex items-center gap-4">
170
+ <DialogTitle className="text-xl font-serif font-bold tracking-tight">Media Library</DialogTitle>
171
+ {multiple && selectedValues.length > 0 && (
172
+ <div className="flex items-center gap-2 px-3 py-1 bg-primary/10 rounded-full border border-primary/20 animate-in fade-in slide-in-from-left-2">
173
+ <span className="text-xs font-bold text-primary">{selectedValues.length} Selected</span>
174
+ <Button variant="ghost" size="icon" className="h-4 w-4 text-primary hover:bg-transparent" onClick={handleConfirm}>
175
+ <Check className="h-3 w-3" />
176
+ </Button>
177
+ </div>
178
+ )}
179
+ </div>
180
+ <TabsList className="bg-muted/50 p-1 rounded-xl">
181
+ <TabsTrigger value="library" className="gap-2 rounded-lg px-4 font-bold text-xs uppercase tracking-wider transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm">
182
+ <Library className="h-3.5 w-3.5" /> Library
183
+ </TabsTrigger>
184
+ <TabsTrigger value="upload" className="gap-2 rounded-lg px-4 font-bold text-xs uppercase tracking-wider transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm">
185
+ <Upload className="h-3.5 w-3.5" /> Upload
186
+ </TabsTrigger>
187
+ <TabsTrigger value="external" className="gap-2 rounded-lg px-4 font-bold text-xs uppercase tracking-wider transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm">
188
+ <Globe className="h-3.5 w-3.5" /> External URL
189
+ </TabsTrigger>
190
+ </TabsList>
191
+ </div>
192
+
193
+ <div className="flex-1 overflow-hidden">
194
+ <TabsContent value="library" className="h-full m-0 p-0 focus-visible:ring-0">
195
+ <div className="flex h-full">
196
+ <div className="flex-1 flex flex-col p-6 space-y-4 border-r">
197
+ <div className="flex items-center gap-4">
198
+ <div className="relative flex-1 group">
199
+ <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors" />
200
+ <Input
201
+ placeholder="Search your media library..."
202
+ className="pl-11 h-11 rounded-xl border-muted bg-muted/10 focus:bg-background transition-all"
203
+ value={searchQuery}
204
+ onChange={(e) => setSearchQuery(e.target.value)}
205
+ />
206
+ </div>
207
+ {multiple && (
208
+ <div className="flex items-center gap-1 bg-muted/30 p-1 rounded-lg">
209
+ <Button
210
+ variant="ghost"
211
+ size="sm"
212
+ className="h-8 text-[10px] font-bold uppercase tracking-wider px-3 hover:bg-background rounded-md"
213
+ onClick={() => {
214
+ media?.forEach((item: any) => {
215
+ if (!selectedValues.includes(item.id)) onSelect(item.id)
216
+ })
217
+ }}
218
+ >
219
+ Select All
220
+ </Button>
221
+ <div className="w-px h-4 bg-border/50 mx-1" />
222
+ <Button
223
+ variant="ghost"
224
+ size="sm"
225
+ className="h-8 text-[10px] font-bold uppercase tracking-wider px-3 text-destructive hover:text-destructive hover:bg-destructive/10 rounded-md"
226
+ onClick={() => {
227
+ selectedValues.forEach(id => onSelect(id))
228
+ }}
229
+ >
230
+ Clear
231
+ </Button>
232
+ </div>
233
+ )}
234
+ </div>
235
+ <ScrollArea className="flex-1 -mx-2 px-2">
236
+ <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-4 pb-4">
237
+ {media?.map((item: any) => (
238
+ <button
239
+ key={item.id}
240
+ type="button"
241
+ onClick={() => {
242
+ if (multiple) {
243
+ onSelect(item.id)
244
+ setSelectedItem(item)
245
+ } else {
246
+ if (selectedItem?.id === item.id) {
247
+ onSelect(item.id)
248
+ onOpenChange(false)
249
+ } else {
250
+ setSelectedItem(item)
251
+ }
252
+ }
253
+ }}
254
+ className={cn(
255
+ "relative group rounded-2xl overflow-hidden border-2 aspect-square transition-all hover:scale-[1.02] active:scale-95 shadow-sm bg-muted/5",
256
+ selectedItem?.id === item.id
257
+ ? "border-primary ring-4 ring-primary/10 shadow-lg shadow-primary/5"
258
+ : "border-border/40 hover:border-border"
259
+ )}
260
+ >
261
+ <img
262
+ src={getPreviewUrl(item)}
263
+ alt={item.filename}
264
+ className="object-cover w-full h-full"
265
+ />
266
+ {selectedValues.includes(item.id) && (
267
+ <div className="absolute top-2.5 right-2.5 h-7 w-7 bg-primary rounded-full flex items-center justify-center text-white shadow-xl animate-in zoom-in border-2 border-white">
268
+ <Check className="h-4 w-4" />
269
+ </div>
270
+ )}
271
+ {(item.mimeType?.startsWith('video/') || item.mimeType === 'video/youtube' || item.mimeType === 'video/vimeo') && (
272
+ <div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/40 transition-colors">
273
+ <div className="h-10 w-10 bg-white/20 backdrop-blur-md rounded-full flex items-center justify-center border border-white/30 shadow-2xl">
274
+ <Video className="h-5 w-5 text-white" />
275
+ </div>
276
+ </div>
277
+ )}
278
+ <div className="absolute inset-x-0 bottom-0 p-2.5 bg-gradient-to-t from-black/80 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
279
+ <p className="text-[10px] text-white truncate font-bold uppercase tracking-wider">{item.filename}</p>
280
+ </div>
281
+ </button>
282
+ ))}
283
+ </div>
284
+ </ScrollArea>
285
+ </div>
286
+
287
+ <div className="w-80 bg-muted/5 p-6 flex flex-col gap-6 overflow-y-auto border-l border-muted/20">
288
+ {selectedItem ? (
289
+ <>
290
+ <div className="space-y-5">
291
+ <div className="aspect-square rounded-3xl overflow-hidden border bg-background shadow-2xl group relative ring-1 ring-border/50">
292
+ <img
293
+ src={getPreviewUrl(selectedItem)}
294
+ className="w-full h-full object-contain p-2"
295
+ alt=""
296
+ />
297
+ <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
298
+ <Button variant="secondary" size="sm" className="rounded-full shadow-lg font-bold" onClick={() => window.open(getPreviewUrl(selectedItem), '_blank')}>
299
+ View Full
300
+ </Button>
301
+ </div>
302
+ </div>
303
+ <div className="space-y-2">
304
+ <h4 className="font-bold text-sm truncate leading-tight" title={selectedItem.filename}>
305
+ {selectedItem.filename}
306
+ </h4>
307
+ <div className="flex flex-wrap items-center gap-2">
308
+ <span className="text-[9px] font-black uppercase tracking-widest bg-primary/10 text-primary px-2 py-1 rounded-md border border-primary/10">
309
+ {selectedItem.mimeType?.split('/')[1] || selectedItem.mimeType}
310
+ </span>
311
+ <span className="text-[10px] font-bold text-muted-foreground/60">
312
+ {selectedItem.filesize ? `${(selectedItem.filesize / 1024).toFixed(1)} KB` : 'External Asset'}
313
+ </span>
314
+ </div>
315
+ </div>
316
+ </div>
317
+
318
+ <div className="space-y-3 pt-6 border-t border-muted/20">
319
+ {multiple ? (
320
+ <>
321
+ <Button
322
+ className="w-full h-11 rounded-xl shadow-sm font-bold tracking-tight transition-all"
323
+ variant={selectedValues.includes(selectedItem.id) ? "outline" : "default"}
324
+ onClick={() => onSelect(selectedItem.id)}
325
+ >
326
+ {selectedValues.includes(selectedItem.id) ? "Deselect Item" : "Add to Selection"}
327
+ </Button>
328
+ {selectedValues.length > 0 && (
329
+ <Button
330
+ className="w-full h-11 rounded-xl shadow-xl bg-primary hover:bg-primary/90 font-bold tracking-tight transition-all group"
331
+ onClick={handleConfirm}
332
+ >
333
+ <span>Confirm {selectedValues.length} {selectedValues.length === 1 ? 'Asset' : 'Assets'}</span>
334
+ <Sparkles className="ml-2 h-4 w-4 opacity-50 group-hover:opacity-100 group-hover:scale-110 transition-all" />
335
+ </Button>
336
+ )}
337
+ </>
338
+ ) : (
339
+ <Button
340
+ className="w-full h-11 rounded-xl shadow-lg font-bold tracking-tight bg-primary hover:bg-primary/90 transition-all"
341
+ onClick={() => {
342
+ onSelect(selectedItem.id)
343
+ onOpenChange(false)
344
+ }}
345
+ >
346
+ Select Media
347
+ </Button>
348
+ )}
349
+ </div>
350
+ </>
351
+ ) : (
352
+ <div className="flex-1 flex flex-col items-center justify-center text-center space-y-5 text-muted-foreground/30">
353
+ <div className="p-6 bg-muted/10 rounded-full border border-muted/20 shadow-inner">
354
+ <ImageIcon className="h-10 w-10" />
355
+ </div>
356
+ <div className="space-y-1">
357
+ <p className="text-xs font-bold uppercase tracking-widest">No Selection</p>
358
+ <p className="text-[10px] font-medium max-w-[150px] leading-relaxed">Select an item from the library to view details and metadata</p>
359
+ </div>
360
+ </div>
361
+ )}
362
+ </div>
363
+ </div>
364
+ </TabsContent>
365
+
366
+ <TabsContent value="upload" className="h-full m-0 p-8 focus-visible:ring-0">
367
+ <div className="h-full flex flex-col items-center justify-center border-2 border-dashed border-primary/20 rounded-[2.5rem] bg-primary/5 hover:bg-primary/10 transition-all group relative overflow-hidden">
368
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,var(--primary)_0%,transparent_100%)] opacity-[0.03]" />
369
+ <input
370
+ type="file"
371
+ id="media-upload-dialog"
372
+ className="hidden"
373
+ onChange={handleUpload}
374
+ disabled={isUploading}
375
+ />
376
+ <label
377
+ htmlFor="media-upload-dialog"
378
+ className="flex flex-col items-center gap-8 cursor-pointer p-12 text-center relative z-10"
379
+ >
380
+ <div className="h-24 w-24 bg-background rounded-full flex items-center justify-center text-primary group-hover:scale-110 transition-all shadow-2xl shadow-primary/20 border border-primary/10">
381
+ <Upload className="h-10 w-10" />
382
+ </div>
383
+ <div className="space-y-2">
384
+ <p className="font-serif font-bold text-3xl tracking-tight">Upload new assets</p>
385
+ <p className="text-muted-foreground/60 font-medium">Drag and drop files here or click to browse your computer</p>
386
+ </div>
387
+ <Button variant="secondary" className="rounded-full px-8 h-12 font-bold shadow-sm pointer-events-none group-hover:bg-primary group-hover:text-white transition-all">
388
+ Choose Files
389
+ </Button>
390
+ </label>
391
+ </div>
392
+ </TabsContent>
393
+
394
+ <TabsContent value="external" className="h-full m-0 p-12 focus-visible:ring-0">
395
+ <div className="max-w-2xl mx-auto space-y-10 pt-4">
396
+ <div className="text-center space-y-4">
397
+ <div className="h-20 w-20 bg-primary/10 text-primary rounded-3xl flex items-center justify-center mx-auto mb-6 shadow-sm rotate-3 group-hover:rotate-0 transition-transform">
398
+ <Globe className="h-10 w-10" />
399
+ </div>
400
+ <h3 className="text-3xl font-serif font-bold tracking-tight">Add External Resource</h3>
401
+ <p className="text-sm text-muted-foreground/70 leading-relaxed max-w-md mx-auto">
402
+ Paste a link to any image, YouTube video, Vimeo link, or file to add it to your library without uploading.
403
+ </p>
404
+ </div>
405
+
406
+ <div className="space-y-6">
407
+ <div className="flex gap-3">
408
+ <div className="relative flex-1">
409
+ <LinkIcon className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
410
+ <Input
411
+ placeholder="https://example.com/image.jpg or video link..."
412
+ className="h-14 rounded-2xl shadow-xl border-muted bg-muted/5 pl-12 text-base font-medium focus:bg-background transition-all"
413
+ value={externalUrl}
414
+ onChange={(e) => setExternalUrl(e.target.value)}
415
+ />
416
+ </div>
417
+ <Button
418
+ onClick={handleExternalUrlSubmit}
419
+ disabled={isUploading || !externalUrl}
420
+ className="h-14 rounded-2xl px-10 font-bold shadow-xl shadow-primary/20 bg-primary hover:bg-primary/90 transition-all active:scale-95"
421
+ >
422
+ {isUploading ? "Adding..." : "Add URL"}
423
+ </Button>
424
+ </div>
425
+
426
+ <div className="grid grid-cols-2 gap-4">
427
+ <div className="p-4 rounded-2xl bg-red-50/50 border border-red-100 flex items-start gap-3">
428
+ <div className="mt-0.5 p-1.5 bg-red-100 rounded-lg text-red-600">
429
+ <Video className="h-4 w-4" />
430
+ </div>
431
+ <div>
432
+ <p className="text-xs font-bold text-red-900">Video Streaming</p>
433
+ <p className="text-[10px] text-red-700/70 leading-relaxed font-medium mt-0.5">Supports YouTube & Vimeo. We recommend these for the best performance and compatibility.</p>
434
+ </div>
435
+ </div>
436
+ <div className="p-4 rounded-2xl bg-blue-50/50 border border-blue-100 flex items-start gap-3">
437
+ <div className="mt-0.5 p-1.5 bg-blue-100 rounded-lg text-blue-600">
438
+ <ImageIcon className="h-4 w-4" />
439
+ </div>
440
+ <div>
441
+ <p className="text-xs font-bold text-blue-900">External Assets</p>
442
+ <p className="text-[10px] text-blue-700/70 leading-relaxed font-medium mt-0.5">Add direct links to images or files from other CDNs. We'll automatically detect the type.</p>
443
+ </div>
444
+ </div>
445
+ </div>
446
+
447
+ <div className="flex items-center gap-3 p-4 rounded-2xl bg-muted/20 border border-muted/30">
448
+ <div className="p-2 bg-background rounded-xl shadow-sm text-muted-foreground">
449
+ <Info className="h-4 w-4" />
450
+ </div>
451
+ <p className="text-[11px] text-muted-foreground/80 font-medium leading-relaxed">
452
+ <span className="font-bold text-foreground">Pro Tip:</span> External videos are better streamed from
453
+ <span className="text-red-600 font-bold ml-1">YouTube</span> or
454
+ <span className="text-blue-500 font-bold ml-1">Vimeo</span> to ensure smooth playback on all devices.
455
+ </p>
456
+ </div>
457
+ </div>
458
+ </div>
459
+ </TabsContent>
460
+ </div>
461
+ </Tabs>
462
+ </DialogContent>
463
+ </Dialog>
464
+ )
465
+ }
@@ -0,0 +1,7 @@
1
+ "use client"
2
+
3
+ import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
4
+
5
+ const AspectRatio = AspectRatioPrimitive.Root
6
+
7
+ export { AspectRatio }
@@ -0,0 +1,36 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "../../lib/utils"
5
+
6
+ const badgeVariants = cva(
7
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default:
12
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13
+ secondary:
14
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15
+ destructive:
16
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17
+ outline: "text-foreground",
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: "default",
22
+ },
23
+ }
24
+ )
25
+
26
+ export interface BadgeProps
27
+ extends React.HTMLAttributes<HTMLDivElement>,
28
+ VariantProps<typeof badgeVariants> {}
29
+
30
+ function Badge({ className, variant, ...props }: BadgeProps) {
31
+ return (
32
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
33
+ )
34
+ }
35
+
36
+ export { Badge, badgeVariants }
@@ -0,0 +1,56 @@
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+
5
+ import { cn } from "../../lib/utils"
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
13
+ destructive:
14
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15
+ outline:
16
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17
+ secondary:
18
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19
+ ghost: "hover:bg-accent hover:text-accent-foreground",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default: "h-10 px-4 py-2",
24
+ sm: "h-9 rounded-md px-3",
25
+ lg: "h-11 rounded-md px-8",
26
+ icon: "h-10 w-10",
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ variant: "default",
31
+ size: "default",
32
+ },
33
+ }
34
+ )
35
+
36
+ export interface ButtonProps
37
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
38
+ VariantProps<typeof buttonVariants> {
39
+ asChild?: boolean
40
+ }
41
+
42
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
43
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
44
+ const Comp = asChild ? Slot : "button"
45
+ return (
46
+ <Comp
47
+ className={cn(buttonVariants({ variant, size, className }))}
48
+ ref={ref}
49
+ {...props}
50
+ />
51
+ )
52
+ }
53
+ )
54
+ Button.displayName = "Button"
55
+
56
+ export { Button, buttonVariants }