@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,280 @@
|
|
|
1
|
+
import { useState, useEffect } from "react"
|
|
2
|
+
import { toast } from "sonner"
|
|
3
|
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
4
|
+
import { useDyrected } from "../../providers/dyrected-provider"
|
|
5
|
+
import { FormEngine } from "../../components/forms/form-engine"
|
|
6
|
+
import { useNavigate, useParams } from "react-router-dom"
|
|
7
|
+
import { ChevronLeft } from "lucide-react"
|
|
8
|
+
import { Button } from "../../components/ui/button"
|
|
9
|
+
import { Badge } from "../../components/ui/badge"
|
|
10
|
+
import { cn } from "../../lib/utils"
|
|
11
|
+
import { Archive, Eye, EyeOff, Save } from "lucide-react"
|
|
12
|
+
import { LivePreviewPane } from "../../components/live-preview/LivePreviewPane"
|
|
13
|
+
import jexl from 'jexl'
|
|
14
|
+
|
|
15
|
+
export function EditEntryPage() {
|
|
16
|
+
const { slug, id } = useParams()
|
|
17
|
+
const { client } = useDyrected()
|
|
18
|
+
const navigate = useNavigate()
|
|
19
|
+
const queryClient = useQueryClient()
|
|
20
|
+
const [showPreview, setShowPreview] = useState(false)
|
|
21
|
+
const [isDirty, setIsDirty] = useState(false)
|
|
22
|
+
const [previewData, setPreviewData] = useState<any>(null)
|
|
23
|
+
const isEdit = !!id
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
27
|
+
if (isDirty) {
|
|
28
|
+
e.preventDefault()
|
|
29
|
+
e.returnValue = ""
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
window.addEventListener("beforeunload", handleBeforeUnload)
|
|
33
|
+
return () => window.removeEventListener("beforeunload", handleBeforeUnload)
|
|
34
|
+
}, [isDirty])
|
|
35
|
+
|
|
36
|
+
// Cmd+S to save
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const handleSave = (e: KeyboardEvent) => {
|
|
39
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
40
|
+
e.preventDefault()
|
|
41
|
+
document.getElementById('dyrected-form-submit')?.click()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
window.addEventListener("keydown", handleSave)
|
|
45
|
+
return () => window.removeEventListener("keydown", handleSave)
|
|
46
|
+
}, [])
|
|
47
|
+
|
|
48
|
+
// Fetch schema
|
|
49
|
+
const { data: schemas } = useQuery({
|
|
50
|
+
queryKey: ["schemas"],
|
|
51
|
+
queryFn: () => client!.getSchemas(),
|
|
52
|
+
enabled: !!client,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const schema = schemas?.collections.find((c: any) => c.slug === slug)
|
|
56
|
+
|
|
57
|
+
// Effect to default preview if available
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (schema?.admin?.previewUrl) {
|
|
60
|
+
setShowPreview(true)
|
|
61
|
+
}
|
|
62
|
+
}, [schema])
|
|
63
|
+
|
|
64
|
+
// Fetch entry data if in edit mode
|
|
65
|
+
const { data: entry, isLoading: isEntryLoading } = useQuery({
|
|
66
|
+
queryKey: ["entry", slug, id],
|
|
67
|
+
queryFn: () => client!.collection(slug!).findOne(id!),
|
|
68
|
+
enabled: !!client && isEdit,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (entry) {
|
|
73
|
+
setPreviewData(entry)
|
|
74
|
+
}
|
|
75
|
+
}, [entry])
|
|
76
|
+
|
|
77
|
+
const saveMutation = useMutation({
|
|
78
|
+
mutationFn: (data: any) => {
|
|
79
|
+
if (isEdit) {
|
|
80
|
+
return client!.collection(slug!).update(id!, data)
|
|
81
|
+
} else {
|
|
82
|
+
return client!.collection(slug!).create(data)
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
onSuccess: (data: any) => {
|
|
86
|
+
setIsDirty(false)
|
|
87
|
+
queryClient.invalidateQueries({ queryKey: ["collection", slug] })
|
|
88
|
+
if (isEdit) {
|
|
89
|
+
queryClient.invalidateQueries({ queryKey: ["entry", slug, id] })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
toast.success(isEdit ? "Entry updated successfully" : "Entry created successfully", {
|
|
93
|
+
description: `${schema.label || schema.slug} has been saved.`
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (!isEdit && data?.id) {
|
|
97
|
+
navigate(`/collections/${slug}/edit/${data.id}`, { replace: true })
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
onError: (error: any) => {
|
|
101
|
+
toast.error("Failed to save entry", {
|
|
102
|
+
description: error.message || "An unexpected error occurred."
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
if (!schema) return <div>Collection not found</div>
|
|
108
|
+
if (isEdit && isEntryLoading) return <div>Loading entry...</div>
|
|
109
|
+
|
|
110
|
+
const hasStatus = schema?.fields.some((f: any) => f.name === "status")
|
|
111
|
+
const currentStatus = entry?.status || "draft"
|
|
112
|
+
|
|
113
|
+
let previewUrl = typeof schema.admin?.previewUrl === 'function'
|
|
114
|
+
? schema.admin.previewUrl(previewData || entry, { locale: 'en' })
|
|
115
|
+
: schema.admin?.previewUrl
|
|
116
|
+
|
|
117
|
+
if (typeof previewUrl === 'string' && previewUrl.includes('{{')) {
|
|
118
|
+
previewUrl = previewUrl.replace(/{{(.*?)}}/g, (_, key) => entry?.[key.trim()] || "")
|
|
119
|
+
} else if (typeof previewUrl === 'string' && (previewData || entry)) {
|
|
120
|
+
try {
|
|
121
|
+
// Provide current window origin to Jexl context so users can use it in expressions
|
|
122
|
+
const context = { ...(previewData || entry), siteUrl: window.location.origin };
|
|
123
|
+
|
|
124
|
+
if (previewUrl.includes('+') || previewUrl.includes('?') || previewUrl.includes('==') || previewUrl.includes('siteUrl')) {
|
|
125
|
+
previewUrl = jexl.evalSync(previewUrl, context)
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error("[PreviewDebug] Jexl Evaluation Failed:", e)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// If the resolved URL is relative, prepend the current origin
|
|
133
|
+
if (typeof previewUrl === 'string' && previewUrl.startsWith('/')) {
|
|
134
|
+
previewUrl = `${window.location.origin}${previewUrl}`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const canCreate = (schema.access as any)?.create !== false
|
|
138
|
+
const canUpdate = (schema.access as any)?.update !== false
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="flex h-[calc(100vh-0px)] overflow-hidden -mt-6 -mx-4 lg:-mt-10 lg:-mx-6">
|
|
142
|
+
{/* Left Column: Header + Form */}
|
|
143
|
+
<div className={cn(
|
|
144
|
+
"flex-1 overflow-y-auto px-6 py-6 lg:px-10 lg:py-10 transition-all duration-500",
|
|
145
|
+
showPreview ? "max-w-2xl xl:max-w-3xl" : "max-w-5xl mx-auto w-full"
|
|
146
|
+
)}>
|
|
147
|
+
<div className="space-y-8">
|
|
148
|
+
{/* Header */}
|
|
149
|
+
<div className="flex items-center justify-between gap-4 border-b border-border/50 pb-6">
|
|
150
|
+
<div className="flex items-center gap-4">
|
|
151
|
+
<Button
|
|
152
|
+
variant="ghost"
|
|
153
|
+
size="icon"
|
|
154
|
+
className="h-8 w-8 rounded-lg hover:bg-muted shrink-0"
|
|
155
|
+
onClick={() => navigate(`/collections/${slug}`)}
|
|
156
|
+
>
|
|
157
|
+
<ChevronLeft className="h-4 w-4" />
|
|
158
|
+
</Button>
|
|
159
|
+
<div>
|
|
160
|
+
<div className="flex items-center gap-3">
|
|
161
|
+
<h1 className="text-lg font-serif font-bold tracking-tight text-foreground truncate">
|
|
162
|
+
{isEdit ? `Edit ${schema.label || schema.slug}` : `New ${schema.label || schema.slug}`}
|
|
163
|
+
</h1>
|
|
164
|
+
{hasStatus && (
|
|
165
|
+
<Badge className={cn(
|
|
166
|
+
"px-2 py-0 rounded-full text-[10px] font-bold uppercase tracking-wider",
|
|
167
|
+
currentStatus === "published" ? "bg-emerald-100 text-emerald-700 border-emerald-200" : "bg-amber-100 text-amber-700 border-amber-200"
|
|
168
|
+
)} variant="outline">
|
|
169
|
+
{currentStatus === "published" ? "Live" : "Draft"}
|
|
170
|
+
</Badge>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div className="flex items-center gap-2">
|
|
177
|
+
{previewUrl && (
|
|
178
|
+
<Button
|
|
179
|
+
variant="ghost"
|
|
180
|
+
size="icon"
|
|
181
|
+
className={cn(
|
|
182
|
+
"h-9 w-9 rounded-lg transition-colors",
|
|
183
|
+
showPreview ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-muted"
|
|
184
|
+
)}
|
|
185
|
+
onClick={() => setShowPreview(!showPreview)}
|
|
186
|
+
title={showPreview ? "Hide Preview" : "Live Preview"}
|
|
187
|
+
>
|
|
188
|
+
{showPreview ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
189
|
+
</Button>
|
|
190
|
+
)}
|
|
191
|
+
<Button
|
|
192
|
+
size="icon"
|
|
193
|
+
className="h-9 w-9 rounded-lg shadow-sm"
|
|
194
|
+
onClick={() => document.getElementById('dyrected-form-submit')?.click()}
|
|
195
|
+
disabled={saveMutation.isPending || (isEdit ? !canUpdate : !canCreate)}
|
|
196
|
+
title={isEdit ? "Save Changes (⌘S)" : "Create Entry (⌘S)"}
|
|
197
|
+
>
|
|
198
|
+
{saveMutation.isPending ? (
|
|
199
|
+
<div className="h-4 w-4 animate-spin border-2 border-current border-t-transparent rounded-full" />
|
|
200
|
+
) : (
|
|
201
|
+
<Save className="h-4 w-4" />
|
|
202
|
+
)}
|
|
203
|
+
</Button>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{/* Form */}
|
|
208
|
+
<div className="animate-in space-y-8 pb-20">
|
|
209
|
+
{!canUpdate && isEdit && (
|
|
210
|
+
<div className="p-4 rounded-lg bg-amber-50 border border-amber-200 text-amber-800 text-sm flex items-center gap-3">
|
|
211
|
+
<Archive className="h-4 w-4" />
|
|
212
|
+
You have read-only access to this collection.
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
<FormEngine
|
|
216
|
+
collection={slug!}
|
|
217
|
+
fields={schema.fields}
|
|
218
|
+
defaultValues={entry}
|
|
219
|
+
onSubmit={(data) => saveMutation.mutate(data)}
|
|
220
|
+
onDataChange={(newData) => setPreviewData({ ...entry, ...newData })}
|
|
221
|
+
onChange={(dirty) => setIsDirty(dirty)}
|
|
222
|
+
isLoading={saveMutation.isPending}
|
|
223
|
+
submitLabel={isEdit ? "Save Changes" : "Create Entry"}
|
|
224
|
+
readOnly={isEdit ? !canUpdate : !canCreate}
|
|
225
|
+
/>
|
|
226
|
+
<button id="dyrected-form-submit" type="submit" className="hidden" />
|
|
227
|
+
|
|
228
|
+
{/* Document Meta */}
|
|
229
|
+
<div className="pt-8 border-t border-border/40">
|
|
230
|
+
<div className="flex flex-wrap items-center gap-x-8 gap-y-4">
|
|
231
|
+
<div className="space-y-1">
|
|
232
|
+
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/40 text-nowrap">Document ID</p>
|
|
233
|
+
<code className="text-xs font-mono text-muted-foreground/80 select-all">
|
|
234
|
+
{isEdit ? id : "Pending..."}
|
|
235
|
+
</code>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
{isEdit && (
|
|
239
|
+
<>
|
|
240
|
+
<div className="space-y-1">
|
|
241
|
+
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/40 text-nowrap">Created At</p>
|
|
242
|
+
<p className="text-xs font-medium text-muted-foreground/80">
|
|
243
|
+
{entry?.createdAt ? new Date(entry.createdAt).toLocaleString() : 'N/A'}
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
<div className="space-y-1">
|
|
247
|
+
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/40 text-nowrap">Last Updated</p>
|
|
248
|
+
<p className="text-xs font-medium text-muted-foreground/80">
|
|
249
|
+
{entry?.updatedAt ? new Date(entry.updatedAt).toLocaleString() : 'N/A'}
|
|
250
|
+
</p>
|
|
251
|
+
</div>
|
|
252
|
+
</>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
{/* Right Column: Preview (starts from top) */}
|
|
261
|
+
{previewUrl && (
|
|
262
|
+
<div className={cn(
|
|
263
|
+
"hidden lg:block border-l border-border/50 bg-muted/5 transition-all duration-500 overflow-hidden",
|
|
264
|
+
showPreview ? "flex-1 opacity-100" : "w-0 opacity-0 border-l-0"
|
|
265
|
+
)}>
|
|
266
|
+
{/* We use negative margins to pull the preview up and out to the shell's padding edges if possible,
|
|
267
|
+
but since we're inside a parent with padding, we'll just make it height-full.
|
|
268
|
+
*/}
|
|
269
|
+
<div className="h-full">
|
|
270
|
+
<LivePreviewPane
|
|
271
|
+
previewUrl={previewUrl}
|
|
272
|
+
data={previewData || entry}
|
|
273
|
+
mode={schema.admin?.previewMode}
|
|
274
|
+
/>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
3
|
+
import { toast } from "sonner"
|
|
4
|
+
import { Link } from "react-router-dom"
|
|
5
|
+
import { useDyrected } from "../../providers/dyrected-provider"
|
|
6
|
+
import { DataTable } from "../../components/ui/data-table"
|
|
7
|
+
import { type ColumnDef } from "@tanstack/react-table"
|
|
8
|
+
import { Badge } from "../../components/ui/badge"
|
|
9
|
+
import { Button } from "../../components/ui/button"
|
|
10
|
+
import { Checkbox } from "../../components/ui/checkbox"
|
|
11
|
+
import {
|
|
12
|
+
MoreHorizontal,
|
|
13
|
+
Plus,
|
|
14
|
+
Pencil,
|
|
15
|
+
Trash2,
|
|
16
|
+
Calendar,
|
|
17
|
+
Database,
|
|
18
|
+
Image as ImageIcon,
|
|
19
|
+
} from "lucide-react"
|
|
20
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "../../components/ui/dropdown-menu"
|
|
21
|
+
import { RenderCell } from "../../components/ui/render-cell"
|
|
22
|
+
import { PageHeader } from "../../components/ui/page-header"
|
|
23
|
+
import { Pagination } from "../../components/ui/pagination"
|
|
24
|
+
import { MediaGrid } from "../../components/media/media-grid"
|
|
25
|
+
|
|
26
|
+
interface CollectionListPageProps {
|
|
27
|
+
slug: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function CollectionListPage({ slug }: CollectionListPageProps) {
|
|
31
|
+
const { client } = useDyrected()
|
|
32
|
+
const queryClient = useQueryClient()
|
|
33
|
+
const [page, setPage] = React.useState(1)
|
|
34
|
+
|
|
35
|
+
// Reset to page 1 when collection slug changes
|
|
36
|
+
React.useEffect(() => { setPage(1) }, [slug])
|
|
37
|
+
|
|
38
|
+
// Fetch schema to know fields
|
|
39
|
+
const { data: schemas } = useQuery({
|
|
40
|
+
queryKey: ["schemas"],
|
|
41
|
+
queryFn: () => client!.getSchemas(),
|
|
42
|
+
enabled: !!client,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const schema = schemas?.collections.find((c: any) => c.slug === slug)
|
|
46
|
+
|
|
47
|
+
// Fetch collection data
|
|
48
|
+
const { data: response, isLoading } = useQuery({
|
|
49
|
+
queryKey: ["collection", slug, page],
|
|
50
|
+
queryFn: () => client!.collection(slug).find({ page, limit: 20, depth: 1 }).exec(),
|
|
51
|
+
enabled: !!client,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const totalPages = response?.totalPages ?? 1
|
|
55
|
+
const hasNextPage = page < totalPages
|
|
56
|
+
const hasPrevPage = page > 1
|
|
57
|
+
|
|
58
|
+
const deleteMutation = useMutation({
|
|
59
|
+
mutationFn: (id: string) => client!.collection(slug).delete(id),
|
|
60
|
+
onSuccess: () => {
|
|
61
|
+
queryClient.invalidateQueries({ queryKey: ["collection", slug] })
|
|
62
|
+
setRowSelection({})
|
|
63
|
+
toast.success("Entry deleted successfully")
|
|
64
|
+
},
|
|
65
|
+
onError: (error: any) => {
|
|
66
|
+
toast.error("Failed to delete entry", {
|
|
67
|
+
description: error.message
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const bulkDeleteMutation = useMutation({
|
|
73
|
+
mutationFn: async (ids: string[]) => {
|
|
74
|
+
for (const id of ids) {
|
|
75
|
+
await client!.collection(slug).delete(id)
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
onSuccess: () => {
|
|
79
|
+
queryClient.invalidateQueries({ queryKey: ["collection", slug] })
|
|
80
|
+
setRowSelection({})
|
|
81
|
+
toast.success("Selected entries deleted")
|
|
82
|
+
},
|
|
83
|
+
onError: (error: any) => {
|
|
84
|
+
toast.error("Failed to delete entries", {
|
|
85
|
+
description: error.message
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
function handleDelete(id: string) {
|
|
91
|
+
if (window.confirm("Delete this entry? This cannot be undone.")) {
|
|
92
|
+
deleteMutation.mutate(id)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function handleBulkDelete(ids: string[]) {
|
|
97
|
+
if (window.confirm(`Delete ${ids.length} entries? This cannot be undone.`)) {
|
|
98
|
+
bulkDeleteMutation.mutate(ids)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({})
|
|
103
|
+
|
|
104
|
+
const columns: ColumnDef<any>[] = React.useMemo(() => {
|
|
105
|
+
if (!schema) return []
|
|
106
|
+
|
|
107
|
+
const cols: ColumnDef<any>[] = [
|
|
108
|
+
{
|
|
109
|
+
id: "select",
|
|
110
|
+
header: ({ table }) => (
|
|
111
|
+
<Checkbox
|
|
112
|
+
checked={table.getIsAllPageRowsSelected() ? true : table.getIsSomePageRowsSelected() ? "indeterminate" : false}
|
|
113
|
+
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
|
114
|
+
aria-label="Select all"
|
|
115
|
+
/>
|
|
116
|
+
),
|
|
117
|
+
cell: ({ row }) => (
|
|
118
|
+
<Checkbox
|
|
119
|
+
checked={row.getIsSelected()}
|
|
120
|
+
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
121
|
+
aria-label="Select row"
|
|
122
|
+
/>
|
|
123
|
+
),
|
|
124
|
+
enableSorting: false,
|
|
125
|
+
enableHiding: false,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
accessorKey: "id",
|
|
129
|
+
header: "ID",
|
|
130
|
+
cell: ({ row }) => <span className="font-mono text-xs">{row.getValue("id")}</span>,
|
|
131
|
+
},
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
const hasStatus = schema.fields.some((f: any) => f.name === "status")
|
|
135
|
+
|
|
136
|
+
if (hasStatus) {
|
|
137
|
+
cols.push({
|
|
138
|
+
accessorKey: "status",
|
|
139
|
+
header: "Status",
|
|
140
|
+
cell: ({ row }) => {
|
|
141
|
+
const status = row.getValue("status")
|
|
142
|
+
return (
|
|
143
|
+
<Badge variant={status === "published" ? "default" : "secondary"}>
|
|
144
|
+
{status === "published" ? "Published" : "Draft"}
|
|
145
|
+
</Badge>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Include all non-hidden fields as columns
|
|
152
|
+
const allDisplayFields = schema.fields.filter((f: any) => f.name !== "status" && !f.admin?.hidden)
|
|
153
|
+
|
|
154
|
+
allDisplayFields.forEach((field: any) => {
|
|
155
|
+
cols.push({
|
|
156
|
+
accessorKey: field.name,
|
|
157
|
+
header: field.label || field.name,
|
|
158
|
+
cell: ({ row }) => (
|
|
159
|
+
<RenderCell
|
|
160
|
+
value={row.getValue(field.name)}
|
|
161
|
+
field={field}
|
|
162
|
+
client={client}
|
|
163
|
+
schemas={schemas}
|
|
164
|
+
/>
|
|
165
|
+
),
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// Add metadata columns
|
|
170
|
+
cols.push({
|
|
171
|
+
accessorKey: "updatedAt",
|
|
172
|
+
header: "Last Updated",
|
|
173
|
+
cell: ({ row }) => {
|
|
174
|
+
const date = new Date(row.getValue("updatedAt"))
|
|
175
|
+
return (
|
|
176
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
177
|
+
<Calendar className="h-3 w-3" />
|
|
178
|
+
<span className="text-xs">{date.toLocaleDateString()}</span>
|
|
179
|
+
</div>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// Actions
|
|
185
|
+
cols.push({
|
|
186
|
+
id: "actions",
|
|
187
|
+
cell: ({ row }) => {
|
|
188
|
+
const item = row.original
|
|
189
|
+
return (
|
|
190
|
+
<DropdownMenu>
|
|
191
|
+
<DropdownMenuTrigger asChild>
|
|
192
|
+
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
193
|
+
<span className="sr-only">Open menu</span>
|
|
194
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
195
|
+
</Button>
|
|
196
|
+
</DropdownMenuTrigger>
|
|
197
|
+
<DropdownMenuContent align="end">
|
|
198
|
+
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
199
|
+
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(item.id)}>
|
|
200
|
+
Copy ID
|
|
201
|
+
</DropdownMenuItem>
|
|
202
|
+
<DropdownMenuSeparator />
|
|
203
|
+
<Link to={`/collections/${slug}/edit/${item.id}`}>
|
|
204
|
+
<DropdownMenuItem className="flex gap-2">
|
|
205
|
+
<Pencil className="h-4 w-4" />
|
|
206
|
+
Edit
|
|
207
|
+
</DropdownMenuItem>
|
|
208
|
+
</Link>
|
|
209
|
+
<DropdownMenuItem
|
|
210
|
+
className="flex gap-2 text-destructive focus:text-destructive"
|
|
211
|
+
onClick={() => handleDelete(item.id)}
|
|
212
|
+
disabled={deleteMutation.isPending}
|
|
213
|
+
>
|
|
214
|
+
<Trash2 className="h-4 w-4" />
|
|
215
|
+
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
|
216
|
+
</DropdownMenuItem>
|
|
217
|
+
</DropdownMenuContent>
|
|
218
|
+
</DropdownMenu>
|
|
219
|
+
)
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
return cols
|
|
224
|
+
}, [schema, client, deleteMutation.isPending])
|
|
225
|
+
|
|
226
|
+
const initialColumnVisibility = React.useMemo(() => {
|
|
227
|
+
if (!schema) return {}
|
|
228
|
+
|
|
229
|
+
const visibility: Record<string, boolean> = {}
|
|
230
|
+
const displayFields = schema.fields.filter((f: any) => f.name !== "status" && !f.admin?.hidden)
|
|
231
|
+
|
|
232
|
+
let visibleFieldNames: string[] = []
|
|
233
|
+
if (schema.admin?.defaultColumns && Array.isArray(schema.admin.defaultColumns)) {
|
|
234
|
+
visibleFieldNames = schema.admin.defaultColumns
|
|
235
|
+
} else {
|
|
236
|
+
visibleFieldNames = displayFields.slice(0, 3).map((f: any) => f.name)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Set visibility for all fields
|
|
240
|
+
displayFields.forEach((f: any) => {
|
|
241
|
+
visibility[f.name] = visibleFieldNames.includes(f.name)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
return visibility
|
|
245
|
+
}, [schema])
|
|
246
|
+
|
|
247
|
+
if (isLoading) {
|
|
248
|
+
return (
|
|
249
|
+
<div className="flex h-[400px] items-center justify-center">
|
|
250
|
+
<div className="animate-spin rounded-full border-4 border-primary border-t-transparent h-8 w-8"></div>
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!schema) {
|
|
256
|
+
return <div>Collection not found: {slug}</div>
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (slug === "media") {
|
|
260
|
+
return (
|
|
261
|
+
<div className="space-y-8 animate-in">
|
|
262
|
+
<PageHeader
|
|
263
|
+
title="Media Library"
|
|
264
|
+
description="Manage your media assets and uploads."
|
|
265
|
+
icon={ImageIcon}
|
|
266
|
+
>
|
|
267
|
+
<Link to={`/collections/${slug}/new`}>
|
|
268
|
+
<Button className="h-8 px-4 text-[11px] rounded-md bg-primary hover:bg-primary/90 shadow-sm transition-all active:scale-95">
|
|
269
|
+
<Plus className="mr-1.5 h-3 w-3" />
|
|
270
|
+
Upload New
|
|
271
|
+
</Button>
|
|
272
|
+
</Link>
|
|
273
|
+
</PageHeader>
|
|
274
|
+
|
|
275
|
+
<MediaGrid
|
|
276
|
+
items={response?.docs || []}
|
|
277
|
+
baseUrl={client?.getBaseUrl() || ""}
|
|
278
|
+
onDelete={handleDelete}
|
|
279
|
+
slug={slug}
|
|
280
|
+
/>
|
|
281
|
+
|
|
282
|
+
<Pagination
|
|
283
|
+
page={page}
|
|
284
|
+
totalPages={totalPages}
|
|
285
|
+
hasPrevPage={hasPrevPage}
|
|
286
|
+
hasNextPage={hasNextPage}
|
|
287
|
+
onPageChange={setPage}
|
|
288
|
+
className="mt-8"
|
|
289
|
+
/>
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div className="space-y-8 animate-in">
|
|
296
|
+
<PageHeader
|
|
297
|
+
title={schema.labels?.plural || schema.label || schema.slug}
|
|
298
|
+
description={`Manage your ${schema.slug} entries and update content.`}
|
|
299
|
+
icon={Database}
|
|
300
|
+
>
|
|
301
|
+
<Link to={`/collections/${slug}/new`}>
|
|
302
|
+
<Button className="h-8 px-4 text-[11px] rounded-md bg-primary hover:bg-primary/90 shadow-sm transition-all active:scale-95">
|
|
303
|
+
<Plus className="mr-1.5 h-3 w-3" />
|
|
304
|
+
Create New
|
|
305
|
+
</Button>
|
|
306
|
+
</Link>
|
|
307
|
+
</PageHeader>
|
|
308
|
+
|
|
309
|
+
<div className="overflow-hidden">
|
|
310
|
+
<DataTable
|
|
311
|
+
key={slug}
|
|
312
|
+
columns={columns}
|
|
313
|
+
data={response?.docs || []}
|
|
314
|
+
searchKey={schema.admin?.useAsTitle || schema.fields.find((f: any) => !f.admin?.hidden)?.name || "id"}
|
|
315
|
+
onRowSelectionChange={setRowSelection}
|
|
316
|
+
rowSelection={rowSelection}
|
|
317
|
+
persistenceKey={slug}
|
|
318
|
+
initialColumnVisibility={initialColumnVisibility}
|
|
319
|
+
bulkActions={(selectedIds) => (
|
|
320
|
+
<Button
|
|
321
|
+
variant="destructive"
|
|
322
|
+
size="sm"
|
|
323
|
+
className="h-8"
|
|
324
|
+
onClick={() => handleBulkDelete(selectedIds)}
|
|
325
|
+
disabled={bulkDeleteMutation.isPending}
|
|
326
|
+
>
|
|
327
|
+
<Trash2 className="h-4 w-4 mr-2" />
|
|
328
|
+
Delete Selected ({selectedIds.length})
|
|
329
|
+
</Button>
|
|
330
|
+
)}
|
|
331
|
+
/>
|
|
332
|
+
<Pagination
|
|
333
|
+
page={page}
|
|
334
|
+
totalPages={totalPages}
|
|
335
|
+
total={response?.total}
|
|
336
|
+
hasPrevPage={hasPrevPage}
|
|
337
|
+
hasNextPage={hasNextPage}
|
|
338
|
+
onPageChange={setPage}
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
)
|
|
343
|
+
}
|