@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.
- package/CHANGELOG.md +40 -0
- package/LICENSE.md +50 -0
- package/README.md +73 -0
- package/components.json +17 -0
- package/eslint.config.js +22 -0
- package/index.html +13 -0
- package/package.json +99 -0
- package/postcss.config.js +6 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/src/App.css +184 -0
- package/src/App.tsx +25 -0
- package/src/assets/dyrected.svg +155 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/components/auth/auth-gate.tsx +64 -0
- package/src/components/error-boundary.tsx +45 -0
- package/src/components/forms/field-renderer.tsx +111 -0
- package/src/components/forms/fields/block-builder.tsx +213 -0
- package/src/components/forms/fields/date-picker.tsx +60 -0
- package/src/components/forms/fields/json-editor.tsx +62 -0
- package/src/components/forms/fields/media-picker.tsx +286 -0
- package/src/components/forms/fields/multi-select.tsx +145 -0
- package/src/components/forms/fields/radio-field.tsx +51 -0
- package/src/components/forms/fields/relationship-picker.tsx +143 -0
- package/src/components/forms/fields/rich-text-editor.tsx +224 -0
- package/src/components/forms/fields/select-field.tsx +35 -0
- package/src/components/forms/fields/switch-field.tsx +16 -0
- package/src/components/forms/fields/text-area-field.tsx +15 -0
- package/src/components/forms/fields/text-field.tsx +24 -0
- package/src/components/forms/form-engine.tsx +87 -0
- package/src/components/forms/form-field-renderer.tsx +269 -0
- package/src/components/forms/utils.ts +97 -0
- package/src/components/layout/admin-shell.tsx +479 -0
- package/src/components/layout/branding-provider.tsx +112 -0
- package/src/components/live-preview/LivePreviewPane.tsx +128 -0
- package/src/components/media/focal-point-picker.tsx +66 -0
- package/src/components/media/media-card.tsx +44 -0
- package/src/components/media/media-grid.tsx +32 -0
- package/src/components/media/media-library-dialog.tsx +465 -0
- package/src/components/ui/aspect-ratio.tsx +7 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/calendar.tsx +214 -0
- package/src/components/ui/card.tsx +79 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/command.tsx +151 -0
- package/src/components/ui/data-table.tsx +219 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/form.tsx +178 -0
- package/src/components/ui/input.tsx +24 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/page-header.tsx +30 -0
- package/src/components/ui/pagination.tsx +57 -0
- package/src/components/ui/popover.tsx +29 -0
- package/src/components/ui/progress.tsx +26 -0
- package/src/components/ui/radio-group.tsx +42 -0
- package/src/components/ui/render-cell.tsx +110 -0
- package/src/components/ui/scroll-area.tsx +46 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +29 -0
- package/src/components/ui/sheet.tsx +140 -0
- package/src/components/ui/sidebar.tsx +771 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +27 -0
- package/src/components/ui/switch.tsx +27 -0
- package/src/components/ui/table.tsx +117 -0
- package/src/components/ui/tabs.tsx +53 -0
- package/src/components/ui/textarea.tsx +22 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +28 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/hooks/use-preferences.ts +56 -0
- package/src/index.css +111 -0
- package/src/index.tsx +198 -0
- package/src/lib/utils.ts +32 -0
- package/src/main.tsx +10 -0
- package/src/pages/auth/first-user-page.tsx +115 -0
- package/src/pages/auth/login-page.tsx +91 -0
- package/src/pages/collections/edit-page.tsx +280 -0
- package/src/pages/collections/list-page.tsx +343 -0
- package/src/pages/dashboard/dashboard.tsx +150 -0
- package/src/pages/globals/editor-page.tsx +122 -0
- package/src/pages/media/media-page.tsx +564 -0
- package/src/pages/setup/setup-prompt.tsx +152 -0
- package/src/providers/dyrected-provider.tsx +122 -0
- package/src/providers/query-provider.tsx +19 -0
- package/src/types/jexl.d.ts +11 -0
- package/tailwind.config.ts +102 -0
- package/tsconfig.app.json +29 -0
- package/tsconfig.json +12 -0
- package/tsconfig.node.json +27 -0
- 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,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 }
|