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