@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,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
|
+
}
|