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