@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,145 @@
1
+ import * as React from "react"
2
+ import { Check, ChevronsUpDown, X } from "lucide-react"
3
+
4
+ import { cn } from "../../../lib/utils"
5
+ import { Button } from "../../ui/button"
6
+ import {
7
+ Command,
8
+ CommandEmpty,
9
+ CommandGroup,
10
+ CommandInput,
11
+ CommandItem,
12
+ CommandList,
13
+ } from "../../ui/command"
14
+ import {
15
+ Popover,
16
+ PopoverContent,
17
+ PopoverTrigger,
18
+ } from "../../ui/popover"
19
+ import { Badge } from "../../ui/badge"
20
+
21
+ interface Option {
22
+ label: string
23
+ value: string
24
+ }
25
+
26
+ interface MultiSelectProps {
27
+ options: Option[]
28
+ value?: string[]
29
+ onChange: (value: string[]) => void
30
+ label?: string
31
+ placeholder?: string
32
+ disabled?: boolean
33
+ }
34
+
35
+ /**
36
+ * MultiSelect Field component
37
+ *
38
+ * Provides a tag-based multi-selection UI using a searchable dropdown.
39
+ */
40
+ export function MultiSelect({
41
+ options,
42
+ value = [],
43
+ onChange,
44
+ label,
45
+ placeholder = "Select options...",
46
+ disabled,
47
+ }: MultiSelectProps) {
48
+ const [open, setOpen] = React.useState(false)
49
+
50
+ const handleSelect = (currentValue: string) => {
51
+ const isSelected = value.includes(currentValue)
52
+ if (isSelected) {
53
+ onChange(value.filter((val) => val !== currentValue))
54
+ } else {
55
+ onChange([...value, currentValue])
56
+ }
57
+ }
58
+
59
+ const handleRemove = (valueToRemove: string) => {
60
+ onChange(value.filter((val) => val !== valueToRemove))
61
+ }
62
+
63
+ return (
64
+ <div className="flex flex-col gap-2">
65
+ {label && <label className="text-sm font-medium leading-none">{label}</label>}
66
+ <Popover open={disabled ? false : open} onOpenChange={setOpen}>
67
+ <PopoverTrigger asChild>
68
+ <Button
69
+ variant="outline"
70
+ role="combobox"
71
+ aria-expanded={open}
72
+ disabled={disabled}
73
+ className="w-full justify-between h-auto min-h-10 font-normal"
74
+ >
75
+ <div className="flex flex-wrap gap-1 items-center">
76
+ {value.length === 0 && (
77
+ <span className="text-muted-foreground">{placeholder}</span>
78
+ )}
79
+ {value.map((val) => {
80
+ const option = options.find((opt) => opt.value === val)
81
+ return (
82
+ <Badge
83
+ key={val}
84
+ variant="secondary"
85
+ className="mr-1 mb-1 items-center gap-1"
86
+ >
87
+ {option?.label || val}
88
+ {!disabled && (
89
+ <div
90
+ role="button"
91
+ tabIndex={0}
92
+ className="ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
93
+ onKeyDown={(e) => {
94
+ if (e.key === "Enter") {
95
+ handleRemove(val)
96
+ }
97
+ }}
98
+ onMouseDown={(e) => {
99
+ e.preventDefault()
100
+ e.stopPropagation()
101
+ }}
102
+ onClick={() => handleRemove(val)}
103
+ >
104
+ <X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
105
+ </div>
106
+ )}
107
+ </Badge>
108
+ )
109
+ })}
110
+ </div>
111
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
112
+ </Button>
113
+ </PopoverTrigger>
114
+ <PopoverContent className="w-[400px] p-0" align="start">
115
+ <Command>
116
+ <CommandInput placeholder="Search options..." />
117
+ <CommandList>
118
+ <CommandEmpty>No option found.</CommandEmpty>
119
+ <CommandGroup>
120
+ {options.map((option) => {
121
+ const isSelected = value.includes(option.value)
122
+ return (
123
+ <CommandItem
124
+ key={option.value}
125
+ value={option.label as any}
126
+ onSelect={() => handleSelect(option.value)}
127
+ >
128
+ <Check
129
+ className={cn(
130
+ "mr-2 h-4 w-4",
131
+ isSelected ? "opacity-100" : "opacity-0"
132
+ )}
133
+ />
134
+ {option.label}
135
+ </CommandItem>
136
+ )
137
+ })}
138
+ </CommandGroup>
139
+ </CommandList>
140
+ </Command>
141
+ </PopoverContent>
142
+ </Popover>
143
+ </div>
144
+ )
145
+ }
@@ -0,0 +1,51 @@
1
+ import { RadioGroup, RadioGroupItem } from "../../ui/radio-group"
2
+ import { Label } from "../../ui/label"
3
+ import { cn } from "../../../lib/utils"
4
+ import { normalizeOptions } from "../utils"
5
+ import type { Field as FieldSchema } from "@dyrected/sdk"
6
+
7
+ interface RadioFieldProps {
8
+ schema: FieldSchema
9
+ field: any
10
+ disabled?: boolean
11
+ }
12
+
13
+ export function RadioField({ schema, field, disabled }: RadioFieldProps) {
14
+ const options = normalizeOptions(schema.options)
15
+ const isHorizontal = schema.admin?.direction === "horizontal"
16
+
17
+ return (
18
+ <RadioGroup
19
+ onValueChange={field.onChange}
20
+ defaultValue={field.value}
21
+ disabled={disabled}
22
+ className={cn(
23
+ "gap-4",
24
+ isHorizontal ? "flex flex-wrap items-center" : "flex flex-col"
25
+ )}
26
+ >
27
+ {options.map((opt) => (
28
+ <div key={opt.value} className={cn(
29
+ "relative flex items-center",
30
+ isHorizontal ? "min-w-[120px]" : "w-full"
31
+ )}>
32
+ <RadioGroupItem
33
+ value={opt.value}
34
+ id={`${field.name}-${opt.value}`}
35
+ className="peer absolute left-4 z-10"
36
+ />
37
+ <Label
38
+ htmlFor={`${field.name}-${opt.value}`}
39
+ className={cn(
40
+ "flex flex-1 items-center pl-12 pr-4 py-3 rounded-xl border border-border/40 bg-white/50 cursor-pointer transition-all hover:bg-white/80 hover:shadow-sm",
41
+ "peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5 peer-data-[state=checked]:shadow-md peer-data-[state=checked]:ring-1 peer-data-[state=checked]:ring-primary/20",
42
+ "text-sm font-medium text-foreground/70 peer-data-[state=checked]:text-primary"
43
+ )}
44
+ >
45
+ {opt.label}
46
+ </Label>
47
+ </div>
48
+ ))}
49
+ </RadioGroup>
50
+ )
51
+ }
@@ -0,0 +1,143 @@
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 { Badge } from "../../ui/badge"
6
+ import {
7
+ Command,
8
+ CommandEmpty,
9
+ CommandGroup,
10
+ CommandInput,
11
+ CommandItem,
12
+ CommandList,
13
+ } from "../../ui/command"
14
+ import {
15
+ Popover,
16
+ PopoverContent,
17
+ PopoverTrigger,
18
+ } from "../../ui/popover"
19
+ import { Check, ChevronsUpDown } from "lucide-react"
20
+ import { cn, getMediaUrl } from "../../../lib/utils"
21
+
22
+ interface RelationshipPickerProps {
23
+ value?: string | string[]
24
+ onChange: (value: string | string[]) => void
25
+ label?: string
26
+ relationTo: string // The collection slug this field relates to
27
+ multiple?: boolean
28
+ disabled?: boolean
29
+ }
30
+
31
+ export function RelationshipPicker({ value, onChange, label, relationTo, multiple, disabled }: RelationshipPickerProps) {
32
+ const { client, schemas } = useDyrected()
33
+ const [open, setOpen] = React.useState(false)
34
+ const [search, setSearch] = React.useState("")
35
+
36
+ const relatedCollection = schemas?.collections.find((c: any) => c.slug === relationTo)
37
+ if (!relationTo) console.warn("[RelationshipPicker] No relationTo/collection defined for field:", label)
38
+ const isUpload = !!relatedCollection?.upload
39
+ const displayField = relatedCollection?.admin?.useAsTitle || "title"
40
+
41
+ // Fetch the related collection documents
42
+ const { data, isLoading } = useQuery({
43
+ queryKey: ["collection", relationTo, "picker", search],
44
+ queryFn: () => {
45
+ let qb = client!.collection(relationTo).find({ limit: 20 })
46
+ if (search) {
47
+ qb = qb.where({ [displayField]: { like: `%${search}%` } })
48
+ }
49
+ return qb.exec().then((res: any) => res.docs)
50
+ },
51
+ enabled: !!client && !!relationTo,
52
+ })
53
+
54
+ // Determine a display label for an item.
55
+ // We'll fallback to ID if no title or name exists.
56
+ const getDisplayLabel = (item: any) => {
57
+ return item[displayField] || item.name || item.slug || item.id
58
+ }
59
+
60
+ const values = Array.isArray(value) ? value : value ? [value] : []
61
+ const selectedItems = values.map(v => data?.find((item: any) => item.id === v)).filter(Boolean)
62
+
63
+ return (
64
+ <div className="flex flex-col gap-2">
65
+ {label && <label className="text-sm font-medium leading-none">{label}</label>}
66
+ <Popover open={disabled ? false : open} onOpenChange={setOpen}>
67
+ <PopoverTrigger asChild>
68
+ <Button
69
+ variant="outline"
70
+ role="combobox"
71
+ aria-expanded={open}
72
+ disabled={disabled}
73
+ className="w-full justify-between font-normal"
74
+ >
75
+ {isLoading ? (
76
+ "Loading..."
77
+ ) : selectedItems.length > 0 ? (
78
+ <div className="flex flex-wrap gap-1">
79
+ {selectedItems.map((item: any) => (
80
+ <Badge key={item.id} variant="secondary" className="text-[10px] h-5 px-1.5">
81
+ {getDisplayLabel(item)}
82
+ </Badge>
83
+ ))}
84
+ </div>
85
+ ) : (
86
+ <span className="text-muted-foreground">Select {relationTo}...</span>
87
+ )}
88
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
89
+ </Button>
90
+ </PopoverTrigger>
91
+ <PopoverContent className="w-[400px] p-0" align="start">
92
+ <Command>
93
+ <CommandInput
94
+ placeholder={`Search ${relationTo}...`}
95
+ onValueChange={setSearch}
96
+ />
97
+ <CommandList>
98
+ <CommandEmpty>{isLoading ? "Searching..." : "No item found."}</CommandEmpty>
99
+ <CommandGroup>
100
+ {data?.map((item: any) => (
101
+ <CommandItem
102
+ key={item.id}
103
+ value={item.id}
104
+ onSelect={() => {
105
+ if (multiple) {
106
+ const newValues = values.includes(item.id)
107
+ ? values.filter(v => v !== item.id)
108
+ : [...values, item.id]
109
+ onChange(newValues)
110
+ } else {
111
+ onChange(item.id === value ? "" : item.id)
112
+ setOpen(false)
113
+ }
114
+ }}
115
+ >
116
+ <div className="flex items-center gap-3 flex-1">
117
+ {isUpload && (
118
+ <div className="h-6 w-6 rounded border bg-muted overflow-hidden flex-shrink-0">
119
+ <img
120
+ src={getMediaUrl(item, client?.getBaseUrl() || "")}
121
+ className="h-full w-full object-cover"
122
+ alt=""
123
+ />
124
+ </div>
125
+ )}
126
+ <span className="flex-1">{getDisplayLabel(item)}</span>
127
+ <Check
128
+ className={cn(
129
+ "h-4 w-4",
130
+ values.includes(item.id) ? "opacity-100" : "opacity-0"
131
+ )}
132
+ />
133
+ </div>
134
+ </CommandItem>
135
+ ))}
136
+ </CommandGroup>
137
+ </CommandList>
138
+ </Command>
139
+ </PopoverContent>
140
+ </Popover>
141
+ </div>
142
+ )
143
+ }
@@ -0,0 +1,224 @@
1
+ import * as React from "react"
2
+ import { useEditor, EditorContent, type Editor } from "@tiptap/react"
3
+ import StarterKit from "@tiptap/starter-kit"
4
+ import TextAlign from "@tiptap/extension-text-align"
5
+ import Image from "@tiptap/extension-image"
6
+ import { Toggle } from "../../ui/toggle"
7
+ import { cn } from "../../../lib/utils"
8
+ import { MediaPicker } from "./media-picker"
9
+ import {
10
+ Bold,
11
+ Italic,
12
+ Underline as UnderlineIcon,
13
+ Strikethrough,
14
+ List,
15
+ ListOrdered,
16
+ AlignLeft,
17
+ AlignCenter,
18
+ AlignRight,
19
+ Link as LinkIcon,
20
+ Heading1,
21
+ Heading2,
22
+ Quote
23
+ } from "lucide-react"
24
+
25
+ interface RichTextEditorProps {
26
+ value: string
27
+ onChange: (value: string) => void
28
+ label?: string
29
+ disabled?: boolean
30
+ collection?: string
31
+ }
32
+
33
+ const MenuBar = ({ editor, collection = "media" }: { editor: Editor | null, collection?: string }) => {
34
+ if (!editor) {
35
+ return null
36
+ }
37
+
38
+ const addLink = () => {
39
+ const url = window.prompt("URL")
40
+ if (url) {
41
+ editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run()
42
+ } else if (url === "") {
43
+ editor.chain().focus().extendMarkRange("link").unsetLink().run()
44
+ }
45
+ }
46
+
47
+ return (
48
+ <div className="border border-input rounded-t-md p-1 flex flex-wrap gap-1 items-center bg-muted/50">
49
+ <Toggle
50
+ size="sm"
51
+ pressed={editor.isActive("bold")}
52
+ onPressedChange={() => editor.chain().focus().toggleBold().run()}
53
+ >
54
+ <Bold className="h-4 w-4" />
55
+ </Toggle>
56
+ <Toggle
57
+ size="sm"
58
+ pressed={editor.isActive("italic")}
59
+ onPressedChange={() => editor.chain().focus().toggleItalic().run()}
60
+ >
61
+ <Italic className="h-4 w-4" />
62
+ </Toggle>
63
+ <Toggle
64
+ size="sm"
65
+ pressed={editor.isActive("underline")}
66
+ onPressedChange={() => editor.chain().focus().toggleUnderline().run()}
67
+ >
68
+ <UnderlineIcon className="h-4 w-4" />
69
+ </Toggle>
70
+ <Toggle
71
+ size="sm"
72
+ pressed={editor.isActive("strike")}
73
+ onPressedChange={() => editor.chain().focus().toggleStrike().run()}
74
+ >
75
+ <Strikethrough className="h-4 w-4" />
76
+ </Toggle>
77
+
78
+ <div className="w-[1px] h-6 bg-border mx-1" />
79
+
80
+ <Toggle
81
+ size="sm"
82
+ pressed={editor.isActive("heading", { level: 1 })}
83
+ onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
84
+ >
85
+ <Heading1 className="h-4 w-4" />
86
+ </Toggle>
87
+ <Toggle
88
+ size="sm"
89
+ pressed={editor.isActive("heading", { level: 2 })}
90
+ onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
91
+ >
92
+ <Heading2 className="h-4 w-4" />
93
+ </Toggle>
94
+ <Toggle
95
+ size="sm"
96
+ pressed={editor.isActive("bulletList")}
97
+ onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
98
+ >
99
+ <List className="h-4 w-4" />
100
+ </Toggle>
101
+ <Toggle
102
+ size="sm"
103
+ pressed={editor.isActive("orderedList")}
104
+ onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
105
+ >
106
+ <ListOrdered className="h-4 w-4" />
107
+ </Toggle>
108
+ <Toggle
109
+ size="sm"
110
+ pressed={editor.isActive("blockquote")}
111
+ onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
112
+ >
113
+ <Quote className="h-4 w-4" />
114
+ </Toggle>
115
+
116
+ <div className="w-[1px] h-6 bg-border mx-1" />
117
+
118
+ <Toggle
119
+ size="sm"
120
+ pressed={editor.isActive({ textAlign: "left" })}
121
+ onPressedChange={() => editor.chain().focus().setTextAlign("left").run()}
122
+ >
123
+ <AlignLeft className="h-4 w-4" />
124
+ </Toggle>
125
+ <Toggle
126
+ size="sm"
127
+ pressed={editor.isActive({ textAlign: "center" })}
128
+ onPressedChange={() => editor.chain().focus().setTextAlign("center").run()}
129
+ >
130
+ <AlignCenter className="h-4 w-4" />
131
+ </Toggle>
132
+ <Toggle
133
+ size="sm"
134
+ pressed={editor.isActive({ textAlign: "right" })}
135
+ onPressedChange={() => editor.chain().focus().setTextAlign("right").run()}
136
+ >
137
+ <AlignRight className="h-4 w-4" />
138
+ </Toggle>
139
+
140
+ <div className="w-[1px] h-6 bg-border mx-1" />
141
+
142
+ <Toggle
143
+ size="sm"
144
+ pressed={editor.isActive("link")}
145
+ onPressedChange={addLink}
146
+ >
147
+ <LinkIcon className="h-4 w-4" />
148
+ </Toggle>
149
+
150
+ <div className="ml-auto">
151
+ <MediaPicker
152
+ collection={collection}
153
+ variant="icon"
154
+ onChange={(val) => {
155
+ const filename = Array.isArray(val) ? val[0] : val
156
+ if (filename) {
157
+ const url = `/api/media/${filename}`
158
+ editor.chain().focus().setImage({ src: url, alt: filename }).run()
159
+ }
160
+ }}
161
+ />
162
+ </div>
163
+ </div>
164
+ )
165
+ }
166
+
167
+ export function RichTextEditor({ value, onChange, label, disabled, collection = "media" }: RichTextEditorProps) {
168
+ const editor = useEditor({
169
+ extensions: [
170
+ StarterKit.configure({
171
+ link: {
172
+ openOnClick: false,
173
+ },
174
+ }),
175
+ TextAlign.configure({
176
+ types: ["heading", "paragraph"],
177
+ }),
178
+ Image.configure({
179
+ HTMLAttributes: {
180
+ class: "rounded-md max-w-full h-auto my-4",
181
+ },
182
+ }),
183
+ ],
184
+ content: value,
185
+ editable: !disabled,
186
+ immediatelyRender: false,
187
+ onUpdate: ({ editor }) => {
188
+ // Extract HTML for standard rich text storage
189
+ onChange(editor.getHTML())
190
+ },
191
+ editorProps: {
192
+ attributes: {
193
+ class: "prose prose-sm dark:prose-invert max-w-none min-h-[150px] p-4 focus:outline-none border border-t-0 rounded-b-md border-input bg-transparent",
194
+ },
195
+ },
196
+ })
197
+
198
+ React.useEffect(() => {
199
+ if (editor) {
200
+ editor.setEditable(!disabled)
201
+ }
202
+ }, [disabled, editor])
203
+
204
+ // Update editor content if value changes externally (e.g. initial load)
205
+ React.useEffect(() => {
206
+ if (editor && value !== editor.getHTML()) {
207
+ // Prevent cursor jump by checking if the content is actually different text-wise
208
+ const currentHtml = editor.getHTML()
209
+ if (currentHtml !== value && value) {
210
+ editor.commands.setContent(value)
211
+ }
212
+ }
213
+ }, [value, editor])
214
+
215
+ return (
216
+ <div className="space-y-2">
217
+ {label && <label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{label}</label>}
218
+ <div className="flex flex-col w-full">
219
+ {!disabled && <MenuBar editor={editor} collection={collection} />}
220
+ <EditorContent editor={editor} className={cn(disabled && "opacity-80")} />
221
+ </div>
222
+ </div>
223
+ )
224
+ }
@@ -0,0 +1,35 @@
1
+ import {
2
+ Select,
3
+ SelectContent,
4
+ SelectItem,
5
+ SelectTrigger,
6
+ SelectValue,
7
+ } from "../../ui/select"
8
+ import { normalizeOptions } from "../utils"
9
+ import type { Field as FieldSchema } from "@dyrected/sdk"
10
+
11
+ interface SelectFieldProps {
12
+ schema: FieldSchema
13
+ field: any
14
+ disabled?: boolean
15
+ }
16
+
17
+ export function SelectField({ schema, field, disabled }: SelectFieldProps) {
18
+ const label = schema.label || schema.name.charAt(0).toUpperCase() + schema.name.slice(1)
19
+ const options = normalizeOptions(schema.options)
20
+
21
+ return (
22
+ <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
23
+ <SelectTrigger className="h-12 rounded-xl border-border/40 bg-white/50 focus:ring-0 focus:ring-offset-0 focus:bg-white shadow-sm transition-all hover:shadow-md">
24
+ <SelectValue placeholder={schema.admin?.placeholder || `Select ${label.toLowerCase()}`} />
25
+ </SelectTrigger>
26
+ <SelectContent className="rounded-xl border-border/40 shadow-xl animate-in fade-in zoom-in-95">
27
+ {options.map((opt) => (
28
+ <SelectItem key={opt.value} value={opt.value} className="rounded-lg focus:bg-primary/5 focus:text-primary transition-colors">
29
+ {opt.label}
30
+ </SelectItem>
31
+ ))}
32
+ </SelectContent>
33
+ </Select>
34
+ )
35
+ }
@@ -0,0 +1,16 @@
1
+ import { Switch } from "../../ui/switch"
2
+
3
+ interface SwitchFieldProps {
4
+ field: any
5
+ disabled?: boolean
6
+ }
7
+
8
+ export function SwitchField({ field, disabled }: SwitchFieldProps) {
9
+ return (
10
+ <Switch
11
+ checked={field.value}
12
+ onCheckedChange={field.onChange}
13
+ disabled={disabled}
14
+ />
15
+ )
16
+ }
@@ -0,0 +1,15 @@
1
+ import { Textarea } from "../../ui/textarea"
2
+ import type { Field as FieldSchema } from "@dyrected/sdk"
3
+
4
+ interface TextAreaFieldProps {
5
+ schema: FieldSchema
6
+ field: any
7
+ disabled?: boolean
8
+ }
9
+
10
+ export function TextAreaField({ schema, field, disabled }: TextAreaFieldProps) {
11
+ const label = schema.label || schema.name.charAt(0).toUpperCase() + schema.name.slice(1)
12
+ const placeholder = schema.admin?.placeholder || `Enter ${label.toLowerCase()}...`
13
+
14
+ return <Textarea {...field} value={field.value ?? ""} placeholder={placeholder} disabled={disabled} />
15
+ }
@@ -0,0 +1,24 @@
1
+ import { Input } from "../../ui/input"
2
+ import type { Field as FieldSchema } from "@dyrected/sdk"
3
+
4
+ interface TextFieldProps {
5
+ schema: FieldSchema
6
+ field: any
7
+ disabled?: boolean
8
+ }
9
+
10
+ export function TextField({ schema, field, disabled }: TextFieldProps) {
11
+ const label = schema.label || schema.name.charAt(0).toUpperCase() + schema.name.slice(1)
12
+ const placeholder = schema.admin?.placeholder || `Enter ${label.toLowerCase()}...`
13
+
14
+ switch (schema.type) {
15
+ case "number":
16
+ return <Input type="number" {...field} value={field.value ?? ""} placeholder={schema.admin?.placeholder || "0"} disabled={disabled} />
17
+ case "email":
18
+ return <Input type="email" {...field} value={field.value ?? ""} placeholder={placeholder} disabled={disabled} />
19
+ case "url":
20
+ return <Input type="url" {...field} value={field.value ?? ""} placeholder={schema.admin?.placeholder || "https://"} disabled={disabled} />
21
+ default:
22
+ return <Input {...field} value={field.value ?? ""} placeholder={placeholder} disabled={disabled} />
23
+ }
24
+ }