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