@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,87 @@
1
+ import { useEffect } from "react"
2
+ import { useForm, useWatch } from "react-hook-form"
3
+ import { zodResolver } from "@hookform/resolvers/zod"
4
+ import * as z from "zod"
5
+ import { Form } from "../ui/form"
6
+ import { Button } from "../ui/button"
7
+ import type { Field as FieldSchema, Block as BlockSchema } from "@dyrected/sdk"
8
+ import { buildSchemaShape, buildDefaultValues } from "./utils"
9
+ import { FormFieldRenderer } from "./form-field-renderer"
10
+
11
+ export type { FieldSchema, BlockSchema }
12
+
13
+ interface FormEngineProps {
14
+ collection: string
15
+ fields: FieldSchema[]
16
+ defaultValues?: Record<string, any>
17
+ onSubmit: (data: any) => void
18
+ onChange?: (isDirty: boolean) => void
19
+ isLoading?: boolean
20
+ submitLabel?: string
21
+ readOnly?: boolean
22
+ onDataChange?: (data: any) => void
23
+ }
24
+
25
+ export function FormEngine({
26
+ collection,
27
+ fields,
28
+ defaultValues = {},
29
+ onSubmit,
30
+ onChange,
31
+ isLoading,
32
+ submitLabel = "Save",
33
+ readOnly,
34
+ onDataChange
35
+ }: FormEngineProps) {
36
+ const schemaShape = buildSchemaShape(fields)
37
+ const formSchema = z.object(schemaShape)
38
+
39
+ const form = useForm<z.infer<typeof formSchema>>({
40
+ resolver: zodResolver(formSchema),
41
+ defaultValues: buildDefaultValues(fields, defaultValues),
42
+ })
43
+
44
+ const { isDirty } = form.formState
45
+
46
+ useEffect(() => {
47
+ onChange?.(isDirty)
48
+ }, [isDirty, onChange])
49
+
50
+ const watchedValues = useWatch({
51
+ control: form.control,
52
+ })
53
+
54
+ useEffect(() => {
55
+ if (onDataChange) {
56
+ const handler = setTimeout(() => {
57
+ onDataChange(watchedValues)
58
+ }, 100)
59
+ return () => clearTimeout(handler)
60
+ }
61
+ }, [watchedValues, onDataChange])
62
+
63
+ return (
64
+ <Form {...form}>
65
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
66
+ <div className="grid gap-6">
67
+ {fields.filter(f => !f.admin?.hidden).map((field) => (
68
+ <FormFieldRenderer
69
+ key={field.name}
70
+ schema={field}
71
+ basePath=""
72
+ control={form.control}
73
+ collection={collection}
74
+ />
75
+ ))}
76
+ </div>
77
+ <div className="flex justify-end gap-4">
78
+ {!readOnly && (
79
+ <Button type="submit" disabled={isLoading}>
80
+ {isLoading ? "Saving..." : submitLabel}
81
+ </Button>
82
+ )}
83
+ </div>
84
+ </form>
85
+ </Form>
86
+ )
87
+ }
@@ -0,0 +1,269 @@
1
+ import * as React from "react"
2
+ import { useWatch, useFieldArray } from "react-hook-form"
3
+ import { useDyrected } from "../../providers/dyrected-provider"
4
+ import {
5
+ FormControl,
6
+ FormField,
7
+ FormItem,
8
+ FormLabel,
9
+ FormMessage,
10
+ } from "../ui/form"
11
+ import { Button } from "../ui/button"
12
+ import { Plus, Trash2, Layers } from "lucide-react"
13
+ import { MediaLibraryDialog } from "../media/media-library-dialog"
14
+ import { cn } from "../../lib/utils"
15
+ import jexl from 'jexl'
16
+ import type { Field as FieldSchema } from "@dyrected/sdk"
17
+ import { FieldRenderer } from "./field-renderer"
18
+ import { BlockBuilder } from "./fields/block-builder"
19
+ import { buildDefaultValues } from "./utils"
20
+
21
+ interface FormFieldRendererProps {
22
+ schema: FieldSchema
23
+ basePath: string
24
+ control: any
25
+ collection: string
26
+ }
27
+
28
+ /**
29
+ * FormFieldRenderer (Field Orchestrator)
30
+ *
31
+ * This component handles the high-level logic for a single form field, including:
32
+ * - Field Lifecycle: Manages registration and state via react-hook-form Controller.
33
+ * - Conditional Visibility: Evaluates 'admin.condition' to show/hide fields dynamically.
34
+ * - Layout & Presentation: Renders labels, descriptions, and error states.
35
+ * - Validation: Displays Zod validation errors.
36
+ *
37
+ * It delegates the actual rendering of the input UI to the FieldRenderer.
38
+ */
39
+ export function FormFieldRenderer({ schema, basePath, control, collection }: FormFieldRendererProps) {
40
+ const { user, schemas } = useDyrected()
41
+
42
+ if (schema.admin?.hidden) return null
43
+
44
+ const formValues = useWatch({ control })
45
+ const siblingData = useWatch({ control, name: (basePath || undefined) as any }) || {}
46
+ const conditionData = basePath ? { ...formValues, ...siblingData } : formValues
47
+
48
+ let isVisible = true
49
+ const condition = schema.admin?.condition
50
+
51
+ if (typeof condition === 'function') {
52
+ isVisible = condition(conditionData, siblingData)
53
+ } else if (typeof condition === 'string') {
54
+ try {
55
+ const sanitizedCondition = condition.replace(/===/g, '==').replace(/!==/g, '!=')
56
+ isVisible = jexl.evalSync(sanitizedCondition, conditionData)
57
+ } catch (e) {
58
+ console.warn("Jexl eval failed:", e)
59
+ isVisible = true
60
+ }
61
+ }
62
+
63
+ if (!isVisible) return null
64
+
65
+ const readAccess = (schema.access as any)?.read
66
+ let canRead = true
67
+ if (readAccess === false) {
68
+ canRead = false
69
+ } else if (typeof readAccess === 'string') {
70
+ try {
71
+ canRead = jexl.evalSync(readAccess, { user, ...conditionData })
72
+ } catch (e) {
73
+ console.warn("Read access eval failed:", e)
74
+ }
75
+ }
76
+
77
+ if (!canRead) return null
78
+
79
+ const fullPath = basePath ? `${basePath}.${schema.name}` : schema.name
80
+
81
+ if (schema.type === "object") {
82
+ return (
83
+ <div className="left-accent space-y-6">
84
+ <div className="flex items-center gap-2 mb-2">
85
+ <h4 className="font-bold text-sm text-foreground/80 tracking-tight">{schema.label || schema.name.charAt(0).toUpperCase() + schema.name.slice(1)}</h4>
86
+ {schema.admin?.description && (
87
+ <p className="text-[10px] text-muted-foreground/50 italic">{schema.admin.description}</p>
88
+ )}
89
+ </div>
90
+ <div className="space-y-6">
91
+ {schema.fields?.map(subField => (
92
+ <FormFieldRenderer key={subField.name} schema={subField} basePath={fullPath} control={control} collection={collection} />
93
+ ))}
94
+ </div>
95
+ </div>
96
+ )
97
+ }
98
+
99
+ if (schema.type === "array") {
100
+ return <ArrayFieldRenderer schema={schema} basePath={fullPath} control={control} collection={collection} />
101
+ }
102
+
103
+ if (schema.type === "blocks" && schema.blocks) {
104
+ return <BlockBuilder schema={schema} basePath={fullPath} control={control} collection={collection} />
105
+ }
106
+
107
+ const isBoolean = schema.type === "boolean"
108
+
109
+ return (
110
+ <FormField
111
+ control={control}
112
+ name={fullPath}
113
+ render={({ field: formField }: { field: any }) => (
114
+ <FormItem className={cn(
115
+ isBoolean
116
+ ? "flex flex-row items-center justify-between rounded-xl border border-border/40 p-4 bg-white/50 shadow-sm space-y-0"
117
+ : "space-y-3"
118
+ )}>
119
+ <div className={cn(isBoolean ? "space-y-1" : "flex items-center gap-2 mb-1")}>
120
+ <FormLabel className="text-sm font-semibold text-foreground/80 cursor-pointer">
121
+ {schema.label || schema.name.charAt(0).toUpperCase() + schema.name.slice(1)}
122
+ {schema.required && <span className="text-destructive ml-1">*</span>}
123
+ </FormLabel>
124
+ {schema.admin?.description && (
125
+ <p className={cn(
126
+ "text-muted-foreground/60 italic",
127
+ isBoolean ? "text-[11px] leading-tight" : "text-[11px] leading-relaxed"
128
+ )}>
129
+ {schema.admin.description}
130
+ </p>
131
+ )}
132
+ {!isBoolean && schema.unique && (
133
+ <span className="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary ring-1 ring-inset ring-primary/10">
134
+ Unique
135
+ </span>
136
+ )}
137
+ </div>
138
+ <FormControl>
139
+ <FieldRenderer schema={schema} field={formField} collection={collection} context={{ user, schemas, siblingData: conditionData }} />
140
+ </FormControl>
141
+ {!isBoolean && schema.admin?.description && (
142
+ <p className="text-[11px] text-muted-foreground/70 leading-relaxed italic">{schema.admin.description}</p>
143
+ )}
144
+ <FormMessage className="text-xs font-medium" />
145
+ </FormItem>
146
+ )}
147
+ />
148
+ )
149
+ }
150
+
151
+ function ArrayFieldRenderer({ schema, basePath, control, collection }: { schema: FieldSchema, basePath: string, control: any, collection: string }) {
152
+ const { fields, append, remove } = useFieldArray({ control, name: basePath })
153
+ const { schemas } = useDyrected()
154
+ const [isBulkOpen, setIsBulkOpen] = React.useState(false)
155
+
156
+ // Find if there is an image field or a relationship to an upload collection
157
+ const imageField = React.useMemo(() => {
158
+ return schema.fields?.find(f => {
159
+ if (f.type === 'image') return true
160
+ if (f.type === 'relationship' && f.relationTo) {
161
+ const relatedSchema = schemas?.collections?.find(s => s.slug === f.relationTo)
162
+ return relatedSchema?.upload === true
163
+ }
164
+ return false
165
+ })
166
+ }, [schema.fields, schemas])
167
+
168
+ const bulkCollection = (imageField?.type === 'relationship' ? imageField.relationTo : 'media') || 'media'
169
+
170
+ const handleBulkAdd = (ids: string[]) => {
171
+ ids.forEach(id => {
172
+ const newItem = buildDefaultValues(schema.fields || [], {})
173
+ if (imageField) {
174
+ newItem[imageField.name] = id
175
+ }
176
+ append(newItem)
177
+ })
178
+ setIsBulkOpen(false)
179
+ }
180
+
181
+ return (
182
+ <div className="space-y-6 transition-all py-6">
183
+ <div className="flex justify-between items-end pb-2">
184
+ <div className="space-y-1">
185
+ <div className="flex items-center gap-2">
186
+ <Layers className="h-4 w-4 text-primary" />
187
+ <h4 className="font-serif font-bold text-base text-foreground tracking-tight">{schema.label || schema.name.charAt(0).toUpperCase() + schema.name.slice(1)}</h4>
188
+ </div>
189
+ {schema.admin?.description && (
190
+ <p className="text-[11px] text-muted-foreground/60 italic leading-relaxed">{schema.admin.description}</p>
191
+ )}
192
+ </div>
193
+ <div className="flex items-center gap-2">
194
+ <Button
195
+ type="button"
196
+ variant="outline"
197
+ size="sm"
198
+ className="h-9 text-[11px] font-bold rounded-xl border-primary/20 hover:bg-primary/5 hover:text-primary transition-all shadow-sm"
199
+ onClick={() => append(buildDefaultValues(schema.fields || [], {}))}
200
+ >
201
+ <Plus className="w-3.5 h-3.5 mr-1.5" />
202
+ Add Item
203
+ </Button>
204
+ </div>
205
+ </div>
206
+
207
+ <div className="space-y-8 pl-0 border-l border-muted/30">
208
+ {fields.map((item, index) => (
209
+ <div key={item.id} className="relative group animate-in slide-in-from-left-2 duration-300">
210
+ <div className="bg-muted/5 left-accent transition-all relative">
211
+ <Button
212
+ type="button"
213
+ variant="ghost"
214
+ size="icon"
215
+ className="absolute top-4 right-4 h-8 w-8 text-muted-foreground/20 hover:text-destructive hover:bg-destructive/10 rounded-xl opacity-0 group-hover:opacity-100 transition-all"
216
+ onClick={() => remove(index)}
217
+ >
218
+ <Trash2 className="w-4 h-4" />
219
+ </Button>
220
+ <div className="space-y-6">
221
+ {schema.fields?.map(subField => (
222
+ <FormFieldRenderer
223
+ key={subField.name}
224
+ schema={subField}
225
+ basePath={`${basePath}.${index}`}
226
+ control={control}
227
+ collection={collection}
228
+ />
229
+ ))}
230
+ </div>
231
+ </div>
232
+ </div>
233
+ ))}
234
+
235
+ {fields.length === 0 && (
236
+ <div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-3xl bg-muted/5 space-y-3">
237
+ <div className="p-3 bg-muted rounded-full">
238
+ <Layers className="h-6 w-6 text-muted-foreground/40" />
239
+ </div>
240
+ <p className="text-xs font-medium text-muted-foreground/50">No items added yet</p>
241
+ </div>
242
+ )}
243
+
244
+ <Button
245
+ type="button"
246
+ variant="outline"
247
+ size="sm"
248
+ className="w-full h-10 text-xs font-bold rounded-2xl border-dashed border-primary/20 hover:bg-primary/5 hover:text-primary transition-all shadow-sm"
249
+ onClick={() => append(buildDefaultValues(schema.fields || [], {}))}
250
+ >
251
+ <Plus className="w-4 h-4 mr-2" />
252
+ Add Item
253
+ </Button>
254
+ </div>
255
+
256
+ {imageField && (
257
+ <MediaLibraryDialog
258
+ collection={bulkCollection}
259
+ isOpen={isBulkOpen}
260
+ onOpenChange={setIsBulkOpen}
261
+ selectedValues={[]}
262
+ multiple={true}
263
+ onSelect={() => { }}
264
+ onConfirm={(ids: string[]) => handleBulkAdd(ids)}
265
+ />
266
+ )}
267
+ </div>
268
+ )
269
+ }
@@ -0,0 +1,97 @@
1
+ import * as z from "zod"
2
+ import type { Field as FieldSchema } from "@dyrected/sdk"
3
+
4
+ export function normalizeOptions(options: string[] | { label: string; value: string }[] | undefined): { label: string; value: string }[] {
5
+ if (!options) return []
6
+ return options.map(opt => typeof opt === "string" ? { label: opt, value: opt } : opt)
7
+ }
8
+
9
+ export function buildSchemaShape(fields: FieldSchema[]) {
10
+ const shape: Record<string, z.ZodTypeAny> = {}
11
+ fields.forEach((field) => {
12
+ let validator: any = z.any()
13
+ const label = field.label || field.name.charAt(0).toUpperCase() + field.name.slice(1)
14
+
15
+ if (field.type === "object" && field.fields) {
16
+ validator = z.object(buildSchemaShape(field.fields))
17
+ if (!field.required) validator = validator.optional()
18
+ shape[field.name] = validator
19
+ return
20
+ }
21
+
22
+ if (field.type === "blocks") {
23
+ validator = z.array(z.any())
24
+ if (!field.required) validator = validator.optional()
25
+ shape[field.name] = validator
26
+ return
27
+ }
28
+
29
+ if (field.type === "array" && field.fields) {
30
+ validator = z.array(z.object(buildSchemaShape(field.fields)))
31
+ if (!field.required) validator = validator.optional()
32
+ shape[field.name] = validator
33
+ return
34
+ }
35
+
36
+ const fieldType = field.type as string
37
+ if (fieldType === "text" || fieldType === "textarea" || fieldType === "select" || fieldType === "image" || fieldType === "richText" || fieldType === "relationship" || fieldType === "date") {
38
+ validator = z.string()
39
+ if (field.required) validator = validator.min(1, `${label} is required`)
40
+ } else if (field.type === "email") {
41
+ validator = z.string().email(`${label} must be a valid email`)
42
+ if (field.required) validator = validator.min(1, `${label} is required`)
43
+ } else if (field.type === "url") {
44
+ validator = z.string().url(`${label} must be a valid URL`)
45
+ if (field.required) validator = validator.min(1, `${label} is required`)
46
+ } else if (field.type === "number") {
47
+ validator = z.coerce.number()
48
+ } else if (field.type === "boolean") {
49
+ validator = z.boolean()
50
+ } else if (field.type === "json") {
51
+ validator = z.any()
52
+ } else if (field.type === "multiSelect") {
53
+ validator = z.array(z.string())
54
+ if (field.required) validator = validator.min(1, `${label} requires at least one selection`)
55
+ }
56
+
57
+ if (!field.required && field.type !== "multiSelect") {
58
+ validator = validator.optional().or(z.literal(""))
59
+ } else if (!field.required && field.type === "multiSelect") {
60
+ validator = validator.optional()
61
+ }
62
+
63
+ shape[field.name] = validator
64
+ })
65
+ return shape
66
+ }
67
+
68
+ export function buildDefaultValues(fields: FieldSchema[], defaults: any) {
69
+ return fields.reduce((acc, field) => {
70
+ let defaultVal = defaults[field.name] ?? field.defaultValue
71
+
72
+ if (field.type === "object" && field.fields) {
73
+ acc[field.name] = buildDefaultValues(field.fields, defaultVal || {})
74
+ return acc
75
+ }
76
+
77
+ if (field.type === "array") {
78
+ acc[field.name] = Array.isArray(defaultVal) ? defaultVal : []
79
+ return acc
80
+ }
81
+
82
+ if (field.type === "blocks") {
83
+ acc[field.name] = Array.isArray(defaultVal) ? defaultVal : []
84
+ return acc
85
+ }
86
+
87
+ if (defaultVal === undefined) {
88
+ if (field.type === "boolean") defaultVal = false
89
+ else if (field.type === "multiSelect") defaultVal = []
90
+ else if (field.type === "json") defaultVal = {}
91
+ else defaultVal = ""
92
+ }
93
+
94
+ acc[field.name] = defaultVal
95
+ return acc
96
+ }, {} as any)
97
+ }