@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,213 @@
1
+ import { useState } from "react"
2
+ import { useFieldArray } from "react-hook-form"
3
+ import { FormFieldRenderer } from "../form-field-renderer"
4
+ import { buildDefaultValues } from "../utils"
5
+ import type { FieldSchema, BlockSchema } from "../form-engine"
6
+ import { Button } from "../../ui/button"
7
+ import { X, GripVertical, ChevronDown, ChevronUp } from "lucide-react"
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuItem,
12
+ DropdownMenuTrigger,
13
+ } from "../../ui/dropdown-menu"
14
+ import {
15
+ DndContext,
16
+ closestCenter,
17
+ KeyboardSensor,
18
+ PointerSensor,
19
+ useSensor,
20
+ useSensors,
21
+ } from "@dnd-kit/core"
22
+ import type { DragEndEvent } from "@dnd-kit/core"
23
+ import {
24
+ SortableContext,
25
+ sortableKeyboardCoordinates,
26
+ verticalListSortingStrategy,
27
+ useSortable,
28
+ } from "@dnd-kit/sortable"
29
+ import { CSS } from "@dnd-kit/utilities"
30
+
31
+ interface BlockBuilderProps {
32
+ schema: FieldSchema
33
+ basePath: string
34
+ control: any
35
+ collection: string
36
+ }
37
+
38
+ function SortableBlockItem({
39
+ id,
40
+ index,
41
+ item,
42
+ schema,
43
+ basePath,
44
+ control,
45
+ collection,
46
+ remove
47
+ }: {
48
+ id: string;
49
+ index: number;
50
+ item: any;
51
+ schema: FieldSchema;
52
+ basePath: string;
53
+ control: any;
54
+ collection: string;
55
+ remove: (index: number) => void
56
+ }) {
57
+ const {
58
+ attributes,
59
+ listeners,
60
+ setNodeRef,
61
+ transform,
62
+ transition,
63
+ isDragging,
64
+ } = useSortable({ id })
65
+
66
+ const [isExpanded, setIsExpanded] = useState(true)
67
+
68
+ const style = {
69
+ transform: CSS.Transform.toString(transform),
70
+ transition,
71
+ zIndex: isDragging ? 10 : 1,
72
+ opacity: isDragging ? 0.8 : 1,
73
+ }
74
+
75
+ const blockConfig = schema.blocks?.find(b => b.slug === item.blockType)
76
+
77
+ if (!blockConfig) return null
78
+
79
+ return (
80
+ <div ref={setNodeRef} style={style} className="relative group left-accent mb-4 py-4 animate-in">
81
+ {/* Header / Drag Handle */}
82
+ <div className="flex items-center justify-between pb-3">
83
+ <div className="flex items-center justify-between gap-2">
84
+ <div {...attributes} {...listeners} className="cursor-grab opacity-20 group-hover:opacity-100 hover:bg-muted p-1 rounded-md transition-all">
85
+ <GripVertical className="w-3.5 h-3.5 text-muted-foreground" />
86
+ </div>
87
+ <span className="font-bold text-xs text-foreground/70 tracking-tight">
88
+ {blockConfig.labels?.singular || blockConfig.slug}
89
+ </span>
90
+ <span className="text-[10px] text-muted-foreground/40 ml-2 uppercase tracking-widest font-semibold">
91
+ Item {index + 1}
92
+ </span>
93
+ </div>
94
+ <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
95
+ <Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground/40" onClick={() => setIsExpanded(!isExpanded)}>
96
+ {isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
97
+ </Button>
98
+ <Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground/30 hover:text-destructive hover:bg-destructive/10" onClick={() => remove(index)}>
99
+ <X className="w-3.5 h-3.5" />
100
+ </Button>
101
+ </div>
102
+ </div>
103
+
104
+ {/* Content */}
105
+ {isExpanded && (
106
+ <div className="space-y-6">
107
+ {blockConfig.fields.map(subField => (
108
+ <FormFieldRenderer
109
+ key={subField.name}
110
+ schema={subField}
111
+ basePath={`${basePath}.${index}`}
112
+ control={control}
113
+ collection={collection}
114
+ />
115
+ ))}
116
+ </div>
117
+ )}
118
+ </div>
119
+ )
120
+ }
121
+
122
+ export function BlockBuilder({ schema, basePath, control, collection }: BlockBuilderProps) {
123
+ const { fields, append, remove, move } = useFieldArray({ control, name: basePath })
124
+
125
+ const sensors = useSensors(
126
+ useSensor(PointerSensor),
127
+ useSensor(KeyboardSensor, {
128
+ coordinateGetter: sortableKeyboardCoordinates,
129
+ })
130
+ )
131
+
132
+ function handleDragEnd(event: DragEndEvent) {
133
+ const { active, over } = event
134
+
135
+ if (over && active.id !== over.id) {
136
+ const oldIndex = fields.findIndex((f) => f.id === active.id)
137
+ const newIndex = fields.findIndex((f) => f.id === over.id)
138
+ move(oldIndex, newIndex)
139
+ }
140
+ }
141
+
142
+ const handleAddBlock = (block: BlockSchema) => {
143
+ const defaultVals = buildDefaultValues(block.fields, {})
144
+ append({
145
+ blockType: block.slug,
146
+ ...defaultVals
147
+ })
148
+ }
149
+
150
+ return (
151
+ <div className="space-y-4">
152
+ <div className="flex justify-between items-center pb-2">
153
+ <div>
154
+ <h4 className="font-bold text-sm text-foreground tracking-tight">{schema.label}</h4>
155
+ {schema.admin?.description && (
156
+ <p className="text-[11px] text-muted-foreground/60 italic">{schema.admin.description}</p>
157
+ )}
158
+ </div>
159
+
160
+ {schema.blocks && schema.blocks.length > 0 && (
161
+ <DropdownMenu>
162
+ <DropdownMenuTrigger asChild>
163
+ <Button type="button" variant="outline" size="sm" className="h-7 text-[11px] rounded-md border-primary/20 hover:bg-primary/5 hover:text-primary">
164
+ Add Block
165
+ <ChevronDown className="w-3 h-3 ml-1.5" />
166
+ </Button>
167
+ </DropdownMenuTrigger>
168
+ <DropdownMenuContent align="end" className="rounded-lg border-border/40 shadow-xl">
169
+ {schema.blocks.map((block) => (
170
+ <DropdownMenuItem key={block.slug} onClick={() => handleAddBlock(block)} className="text-[13px] rounded-md focus:bg-primary/5 focus:text-primary transition-colors">
171
+ {block.labels?.singular || block.slug}
172
+ </DropdownMenuItem>
173
+ ))}
174
+ </DropdownMenuContent>
175
+ </DropdownMenu>
176
+ )}
177
+ </div>
178
+
179
+ {fields.length === 0 ? (
180
+ <div className="text-center p-8 border border-dashed border-border/40 rounded-md">
181
+ <p className="text-[11px] text-muted-foreground/50">No blocks added yet.</p>
182
+ </div>
183
+ ) : (
184
+ <DndContext
185
+ sensors={sensors}
186
+ collisionDetection={closestCenter}
187
+ onDragEnd={handleDragEnd}
188
+ >
189
+ <SortableContext
190
+ items={fields.map(f => f.id)}
191
+ strategy={verticalListSortingStrategy}
192
+ >
193
+ <div className="pt-2">
194
+ {fields.map((item, index) => (
195
+ <SortableBlockItem
196
+ key={item.id}
197
+ id={item.id}
198
+ index={index}
199
+ item={item}
200
+ schema={schema}
201
+ basePath={basePath}
202
+ control={control}
203
+ collection={collection}
204
+ remove={remove}
205
+ />
206
+ ))}
207
+ </div>
208
+ </SortableContext>
209
+ </DndContext>
210
+ )}
211
+ </div>
212
+ )
213
+ }
@@ -0,0 +1,60 @@
1
+ import { format } from "date-fns"
2
+ import { Calendar as CalendarIcon } from "lucide-react"
3
+
4
+ import { cn } from "../../../lib/utils"
5
+ import { Button } from "../../ui/button"
6
+ import { Calendar } from "../../ui/calendar"
7
+ import {
8
+ Popover,
9
+ PopoverContent,
10
+ PopoverTrigger,
11
+ } from "../../ui/popover"
12
+
13
+ interface DatePickerProps {
14
+ value?: string | Date
15
+ onChange: (date?: string) => void
16
+ label?: string
17
+ disabled?: boolean
18
+ }
19
+
20
+ export function DatePicker({ value, onChange, label, disabled }: DatePickerProps) {
21
+ const date = value ? new Date(value) : undefined
22
+
23
+ return (
24
+ <div className="flex flex-col gap-2">
25
+ {label && <label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{label}</label>}
26
+ <Popover open={disabled ? false : undefined}>
27
+ <PopoverTrigger asChild>
28
+ <Button
29
+ variant={"outline"}
30
+ disabled={disabled}
31
+ className={cn(
32
+ "w-full justify-start text-left font-normal h-11 px-4 bg-white hover:bg-muted/50 border-border/60 shadow-sm transition-all hover:shadow-md",
33
+ !date && "text-muted-foreground"
34
+ )}
35
+ >
36
+ <CalendarIcon className="mr-3 h-4 w-4 text-primary" />
37
+ <span className="flex-1 truncate">
38
+ {date ? format(date, "PPP") : "Pick a date..."}
39
+ </span>
40
+ </Button>
41
+ </PopoverTrigger>
42
+ <PopoverContent className="w-auto p-0 border-border/50 shadow-2xl rounded-xl" align="start">
43
+ <Calendar
44
+ mode="single"
45
+ selected={date}
46
+ onSelect={(newDate) => {
47
+ if (newDate) {
48
+ // Store date as ISO string
49
+ onChange(newDate.toISOString())
50
+ } else {
51
+ onChange(undefined)
52
+ }
53
+ }}
54
+ initialFocus
55
+ />
56
+ </PopoverContent>
57
+ </Popover>
58
+ </div>
59
+ )
60
+ }
@@ -0,0 +1,62 @@
1
+ import * as React from "react"
2
+ import { Textarea } from "../../ui/textarea"
3
+ import { cn } from "../../../lib/utils"
4
+
5
+ interface JsonEditorProps {
6
+ value?: any
7
+ onChange: (value: any) => void
8
+ label?: string
9
+ disabled?: boolean
10
+ }
11
+
12
+ export function JsonEditor({ value, onChange, label, disabled }: JsonEditorProps) {
13
+ const [internalValue, setInternalValue] = React.useState("")
14
+ const [error, setError] = React.useState<string | null>(null)
15
+
16
+ // Initialize internal string value from the JSON prop
17
+ React.useEffect(() => {
18
+ try {
19
+ if (value !== undefined) {
20
+ setInternalValue(JSON.stringify(value, null, 2))
21
+ }
22
+ } catch (e) {
23
+ // Ignore initial parse errors if the value is somehow malformed
24
+ }
25
+ }, [value])
26
+
27
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
28
+ const val = e.target.value
29
+ setInternalValue(val)
30
+
31
+ if (!val.trim()) {
32
+ setError(null)
33
+ onChange(null)
34
+ return
35
+ }
36
+
37
+ try {
38
+ const parsed = JSON.parse(val)
39
+ setError(null)
40
+ onChange(parsed)
41
+ } catch (err: any) {
42
+ setError("Invalid JSON format")
43
+ }
44
+ }
45
+
46
+ return (
47
+ <div className="flex flex-col gap-2">
48
+ {label && <label className="text-sm font-medium leading-none">{label}</label>}
49
+ <Textarea
50
+ value={internalValue}
51
+ onChange={handleChange}
52
+ disabled={disabled}
53
+ className={cn(
54
+ "font-mono text-xs min-h-[150px]",
55
+ error && "border-destructive focus-visible:ring-destructive"
56
+ )}
57
+ placeholder='{ "key": "value" }'
58
+ />
59
+ {error && <span className="text-xs text-destructive">{error}</span>}
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,286 @@
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
+ Image as ImageIcon,
7
+ X,
8
+ Plus,
9
+ Trash2,
10
+ } from "lucide-react"
11
+ import { Input } from "../../ui/input"
12
+ import { cn, getMediaUrl } from "../../../lib/utils"
13
+ import { MediaLibraryDialog } from "../../media/media-library-dialog"
14
+
15
+ interface MediaPickerProps {
16
+ collection: string
17
+ value?: string | string[]
18
+ onChange: (value: string | string[]) => void
19
+ label?: string
20
+ variant?: "default" | "icon"
21
+ disabled?: boolean
22
+ multiple?: boolean
23
+ placeholder?: string
24
+ }
25
+
26
+ export function MediaPicker({
27
+ collection,
28
+ value,
29
+ onChange,
30
+ label,
31
+ variant = "default",
32
+ disabled,
33
+ multiple,
34
+ placeholder
35
+ }: MediaPickerProps) {
36
+ const { client } = useDyrected()
37
+ const [isOpen, setIsOpen] = React.useState(false)
38
+
39
+ const selectedValues = React.useMemo(() => {
40
+ if (!value) return []
41
+ return Array.isArray(value) ? value : [value]
42
+ }, [value])
43
+
44
+ const toggleValue = (id: string) => {
45
+ if (multiple) {
46
+ const next = selectedValues.includes(id)
47
+ ? selectedValues.filter(v => v !== id)
48
+ : [...selectedValues, id]
49
+ onChange(next)
50
+ } else {
51
+ onChange(id)
52
+ }
53
+ }
54
+
55
+ const handleConfirm = (ids: string[]) => {
56
+ if (multiple) {
57
+ const next = [...new Set([...selectedValues, ...ids])]
58
+ onChange(next)
59
+ } else if (ids.length > 0) {
60
+ onChange(ids[0])
61
+ }
62
+ setIsOpen(false)
63
+ }
64
+
65
+ // Fetch media for thumbnails in the field view
66
+ const { data: media } = useQuery({
67
+ queryKey: [collection, "previews", selectedValues],
68
+ queryFn: () => {
69
+ if (selectedValues.length === 0) return []
70
+ return client!.listMedia({
71
+ where: { id: { in: selectedValues } }
72
+ }, collection).then((r: any) => r.docs)
73
+ },
74
+ enabled: !!client && selectedValues.length > 0,
75
+ })
76
+
77
+ const getPreviewUrl = (item: any) => {
78
+ if (!item) return ""
79
+ if (item.mimeType === 'video/youtube') {
80
+ const match = item.url?.match(/(?:youtu\.be\/|youtube\.com\/(?:v\/|u\/\w\/|embed\/|watch\?v=))([^#\&\?]*)/)
81
+ const videoId = match && match[1]
82
+ return `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`
83
+ }
84
+ return getMediaUrl(item, client?.getBaseUrl() || "");
85
+ }
86
+
87
+ const isIcon = variant === "icon"
88
+ const getDisplayString = (val: any): string => {
89
+ if (!val) return ""
90
+ if (Array.isArray(val)) {
91
+ if (val.length === 0) return ""
92
+ return `${val.length} items selected`
93
+ }
94
+ if (typeof val === 'object') return val.filename || val.id || val.slug || "Object"
95
+ return String(val)
96
+ }
97
+ const displayValue = getDisplayString(value)
98
+
99
+ if (multiple && !isIcon) {
100
+ return (
101
+ <div className="space-y-4">
102
+ {label && (
103
+ <label className="text-sm font-semibold text-foreground/70 tracking-tight leading-none">
104
+ {label}
105
+ </label>
106
+ )}
107
+
108
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
109
+ <button
110
+ type="button"
111
+ onClick={() => setIsOpen(true)}
112
+ className="group relative aspect-square rounded-xl border-2 border-dashed border-muted hover:border-primary/40 hover:bg-primary/5 transition-all flex flex-col items-center justify-center gap-3 overflow-hidden"
113
+ >
114
+ <div className="absolute inset-0 bg-primary/5 opacity-0 group-hover:opacity-100 transition-opacity" />
115
+ <div className="h-12 w-12 bg-muted rounded-full flex items-center justify-center text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary transition-all shadow-inner">
116
+ <Plus className="h-6 w-6" />
117
+ </div>
118
+ <div className="text-center px-4">
119
+ <p className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground group-hover:text-primary transition-colors">Add Media</p>
120
+ <p className="text-[10px] text-muted-foreground/40 mt-1 font-medium group-hover:text-primary/60">Select or upload</p>
121
+ </div>
122
+ </button>
123
+
124
+ {selectedValues.map((val, index) => {
125
+ const item = media?.find((m: any) => m.id === val)
126
+ return (
127
+ <div key={val} className="relative group animate-in zoom-in duration-300">
128
+ <div className={cn(
129
+ "relative aspect-square rounded-xl overflow-hidden border-2 bg-muted/20 transition-all shadow-sm",
130
+ index === 0 ? "border-primary ring-4 ring-primary/10" : "border-border/40 hover:border-border/80"
131
+ )}>
132
+ {item ? (
133
+ <img
134
+ src={getPreviewUrl(item)}
135
+ alt=""
136
+ className="w-full h-full object-cover transition-transform group-hover:scale-110"
137
+ />
138
+ ) : (
139
+ <div className="w-full h-full animate-pulse bg-muted/50 flex items-center justify-center">
140
+ <ImageIcon className="h-6 w-6 text-muted-foreground/20" />
141
+ </div>
142
+ )}
143
+
144
+ {index === 0 && (
145
+ <div className="absolute top-0 left-0 w-full text-center z-10 px-3 py-1 bg-primary text-white text-[9px] font-black uppercase tracking-widest shadow-primary/20">
146
+ Main Image
147
+ </div>
148
+ )}
149
+
150
+ <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-all flex items-start justify-end backdrop-blur-[2px]">
151
+ <Button
152
+ type="button"
153
+ variant="outline"
154
+ size="icon"
155
+ className="h-8 w-8 rounded-lg text-destructive bg-destructive-foreground shadow-2xl scale-75 group-hover:scale-100 transition-all"
156
+ onClick={() => toggleValue(val)}
157
+ >
158
+ <Trash2 className="w-5 h-5" />
159
+ </Button>
160
+ </div>
161
+
162
+ {item && (
163
+ <div className="absolute inset-x-0 bottom-0 p-2 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
164
+ <p className="text-[10px] text-white truncate font-medium">{item.filename}</p>
165
+ </div>
166
+ )}
167
+ </div>
168
+ </div>
169
+ )
170
+ })}
171
+ </div>
172
+
173
+ <MediaLibraryDialog
174
+ collection={collection}
175
+ isOpen={isOpen}
176
+ onOpenChange={setIsOpen}
177
+ selectedValues={selectedValues}
178
+ onSelect={toggleValue}
179
+ multiple={multiple}
180
+ onConfirm={handleConfirm}
181
+ />
182
+ </div>
183
+ )
184
+ }
185
+
186
+ return (
187
+ <div className={isIcon ? "" : "space-y-3"}>
188
+ {label && !isIcon && (
189
+ <label className="text-sm font-semibold text-foreground/70 tracking-tight leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
190
+ {label}
191
+ </label>
192
+ )}
193
+
194
+ <div className={isIcon ? "" : "relative flex items-center gap-2"}>
195
+ {!isIcon && (
196
+ <div className="relative flex-1 group">
197
+ <Input
198
+ value={displayValue}
199
+ readOnly
200
+ disabled={disabled}
201
+ placeholder={placeholder || "No media selected"}
202
+ className="pr-24 bg-muted/30 border-dashed focus-visible:ring-offset-0 focus-visible:ring-1 h-10 rounded-xl"
203
+ />
204
+ <div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1 pr-1">
205
+ {value && (
206
+ <Button
207
+ type="button"
208
+ variant="ghost"
209
+ size="icon"
210
+ className="h-7 w-7 text-muted-foreground hover:text-destructive transition-colors rounded-lg"
211
+ onClick={(e) => {
212
+ e.preventDefault();
213
+ onChange(multiple ? [] : "");
214
+ }}
215
+ >
216
+ <X className="h-4 w-4" />
217
+ </Button>
218
+ )}
219
+ <Button
220
+ type="button"
221
+ variant="secondary"
222
+ size="sm"
223
+ className="h-8 text-xs font-bold px-3 rounded-lg shadow-sm border border-border/50"
224
+ disabled={disabled}
225
+ onClick={() => setIsOpen(true)}
226
+ >
227
+ {value ? "Change" : "Select"}
228
+ </Button>
229
+ </div>
230
+ </div>
231
+ )}
232
+
233
+ {isIcon && (
234
+ <Button variant="ghost" size="sm" className="px-2 h-8 w-8 rounded-lg" disabled={disabled} onClick={() => setIsOpen(true)}>
235
+ <ImageIcon className="h-4 w-4" />
236
+ </Button>
237
+ )}
238
+
239
+ <MediaLibraryDialog
240
+ collection={collection}
241
+ isOpen={isOpen}
242
+ onOpenChange={setIsOpen}
243
+ selectedValues={selectedValues}
244
+ onSelect={toggleValue}
245
+ multiple={multiple}
246
+ onConfirm={handleConfirm}
247
+ />
248
+ </div>
249
+
250
+ {!isIcon && selectedValues.length > 0 && !multiple && (
251
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 pt-2">
252
+ {selectedValues.map((val) => {
253
+ const item = media?.find((m: any) => m.id === val)
254
+ if (!item) return (
255
+ <div key={val} className="aspect-square rounded-xl bg-muted/20 animate-pulse border-2 border-dashed border-border/50" />
256
+ )
257
+ return (
258
+ <div
259
+ key={val}
260
+ className="relative aspect-square group rounded-2xl overflow-hidden border-2 border-border/50 hover:border-primary/50 transition-all bg-muted/20 shadow-sm"
261
+ >
262
+ <img
263
+ src={getPreviewUrl(item)}
264
+ alt=""
265
+ className="w-full h-full object-cover transition-transform group-hover:scale-110"
266
+ />
267
+ {!disabled && (
268
+ <button
269
+ type="button"
270
+ onClick={() => toggleValue(val)}
271
+ className="absolute top-2 right-2 p-1.5 bg-destructive text-destructive-foreground rounded-full opacity-0 group-hover:opacity-100 transition-all hover:scale-110 shadow-lg border-2 border-white"
272
+ >
273
+ <X className="h-3.5 w-3.5" />
274
+ </button>
275
+ )}
276
+ <div className="absolute inset-x-0 bottom-0 p-2 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
277
+ <p className="text-[10px] text-white truncate font-medium">{item.filename}</p>
278
+ </div>
279
+ </div>
280
+ )
281
+ })}
282
+ </div>
283
+ )}
284
+ </div>
285
+ )
286
+ }