@setzkasten-cms/ui 0.4.2
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/LICENSE +37 -0
- package/dist/index.d.ts +271 -0
- package/dist/index.js +2936 -0
- package/package.json +41 -0
- package/src/adapters/proxy-asset-store.ts +210 -0
- package/src/adapters/proxy-content-repository.ts +259 -0
- package/src/components/admin-app.tsx +275 -0
- package/src/components/collection-view.tsx +103 -0
- package/src/components/entry-form.tsx +76 -0
- package/src/components/entry-list.tsx +119 -0
- package/src/components/page-builder.tsx +1134 -0
- package/src/components/toast.tsx +48 -0
- package/src/fields/array-field-renderer.tsx +101 -0
- package/src/fields/boolean-field-renderer.tsx +28 -0
- package/src/fields/field-renderer.tsx +60 -0
- package/src/fields/icon-field-renderer.tsx +130 -0
- package/src/fields/image-field-renderer.tsx +266 -0
- package/src/fields/number-field-renderer.tsx +38 -0
- package/src/fields/object-field-renderer.tsx +41 -0
- package/src/fields/override-field-renderer.tsx +48 -0
- package/src/fields/select-field-renderer.tsx +42 -0
- package/src/fields/text-field-renderer.tsx +313 -0
- package/src/hooks/use-field.ts +82 -0
- package/src/hooks/use-save.ts +46 -0
- package/src/index.ts +34 -0
- package/src/providers/setzkasten-provider.tsx +80 -0
- package/src/stores/app-store.ts +61 -0
- package/src/stores/form-store.test.ts +111 -0
- package/src/stores/form-store.ts +298 -0
- package/src/styles/admin.css +2017 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { memo, useState } from 'react'
|
|
2
|
+
import type { ObjectFieldDef, AnyFieldDef } from '@setzkasten-cms/core'
|
|
3
|
+
import { FieldRenderer, type FieldRendererProps } from './field-renderer'
|
|
4
|
+
|
|
5
|
+
export const ObjectFieldRenderer = memo(function ObjectFieldRenderer({
|
|
6
|
+
field,
|
|
7
|
+
path,
|
|
8
|
+
store,
|
|
9
|
+
}: FieldRendererProps) {
|
|
10
|
+
const objField = field as ObjectFieldDef
|
|
11
|
+
const [collapsed, setCollapsed] = useState(false)
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="sk-field sk-field--object">
|
|
15
|
+
<div className="sk-field__object-header">
|
|
16
|
+
<label className="sk-field__label">{objField.label}</label>
|
|
17
|
+
{objField.collapsible && (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
className="sk-field__object-toggle"
|
|
21
|
+
onClick={() => setCollapsed(!collapsed)}
|
|
22
|
+
>
|
|
23
|
+
{collapsed ? '▸' : '▾'}
|
|
24
|
+
</button>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
27
|
+
{!collapsed && (
|
|
28
|
+
<div className="sk-field__object-fields">
|
|
29
|
+
{Object.entries(objField.fields).map(([key, childField]) => (
|
|
30
|
+
<FieldRenderer
|
|
31
|
+
key={key}
|
|
32
|
+
field={childField as AnyFieldDef}
|
|
33
|
+
path={[...path, key]}
|
|
34
|
+
store={store}
|
|
35
|
+
/>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { memo } from 'react'
|
|
2
|
+
import type { OverrideFieldDef, AnyFieldDef } from '@setzkasten-cms/core'
|
|
3
|
+
import { useField } from '../hooks/use-field'
|
|
4
|
+
import { FieldRenderer, type FieldRendererProps } from './field-renderer'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Override field – native replacement for Keystatic's DOM-hack.
|
|
8
|
+
* Shows an "active" checkbox. When unchecked, child fields are hidden.
|
|
9
|
+
*/
|
|
10
|
+
export const OverrideFieldRenderer = memo(function OverrideFieldRenderer({
|
|
11
|
+
field,
|
|
12
|
+
path,
|
|
13
|
+
store,
|
|
14
|
+
}: FieldRendererProps) {
|
|
15
|
+
const overrideField = field as OverrideFieldDef
|
|
16
|
+
const { value, setValue } = useField(store, path)
|
|
17
|
+
|
|
18
|
+
const overrideValue = (value as { active: boolean } | undefined) ?? { active: false }
|
|
19
|
+
const isActive = overrideValue.active
|
|
20
|
+
|
|
21
|
+
const toggleActive = () => {
|
|
22
|
+
setValue({ ...overrideValue, active: !isActive })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="sk-field sk-field--override" data-active={isActive || undefined}>
|
|
27
|
+
<div className="sk-field__override-header">
|
|
28
|
+
<label className="sk-field__toggle">
|
|
29
|
+
<input type="checkbox" checked={isActive} onChange={toggleActive} />
|
|
30
|
+
<span className="sk-field__toggle-label">{overrideField.label}</span>
|
|
31
|
+
</label>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
{isActive && (
|
|
35
|
+
<div className="sk-field__override-fields">
|
|
36
|
+
{Object.entries(overrideField.fields).map(([key, childField]) => (
|
|
37
|
+
<FieldRenderer
|
|
38
|
+
key={key}
|
|
39
|
+
field={childField as AnyFieldDef}
|
|
40
|
+
path={[...path, key]}
|
|
41
|
+
store={store}
|
|
42
|
+
/>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { memo } from 'react'
|
|
2
|
+
import type { SelectFieldDef } from '@setzkasten-cms/core'
|
|
3
|
+
import { useField } from '../hooks/use-field'
|
|
4
|
+
import type { FieldRendererProps } from './field-renderer'
|
|
5
|
+
|
|
6
|
+
export const SelectFieldRenderer = memo(function SelectFieldRenderer({
|
|
7
|
+
field,
|
|
8
|
+
path,
|
|
9
|
+
store,
|
|
10
|
+
}: FieldRendererProps) {
|
|
11
|
+
const selectField = field as SelectFieldDef
|
|
12
|
+
const { value, errors, setValue, touch } = useField(store, path)
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="sk-field sk-field--select">
|
|
16
|
+
<label className="sk-field__label">{selectField.label}</label>
|
|
17
|
+
{selectField.description && (
|
|
18
|
+
<p className="sk-field__description">{selectField.description}</p>
|
|
19
|
+
)}
|
|
20
|
+
<select
|
|
21
|
+
className="sk-field__input sk-field__select"
|
|
22
|
+
value={(value as string) ?? ''}
|
|
23
|
+
onChange={(e) => setValue(e.target.value)}
|
|
24
|
+
onBlur={touch}
|
|
25
|
+
aria-label={selectField.label}
|
|
26
|
+
>
|
|
27
|
+
{selectField.options.map((opt) => (
|
|
28
|
+
<option key={opt.value} value={opt.value}>
|
|
29
|
+
{opt.label}
|
|
30
|
+
</option>
|
|
31
|
+
))}
|
|
32
|
+
</select>
|
|
33
|
+
{errors.length > 0 && (
|
|
34
|
+
<div className="sk-field__errors">
|
|
35
|
+
{errors.map((err, i) => (
|
|
36
|
+
<p key={i} className="sk-field__error">{err}</p>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
})
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { TextFieldDef } from '@setzkasten-cms/core'
|
|
3
|
+
import { useField } from '../hooks/use-field'
|
|
4
|
+
import type { FieldRendererProps } from './field-renderer'
|
|
5
|
+
import { useEditor, EditorContent } from '@tiptap/react'
|
|
6
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
7
|
+
import Placeholder from '@tiptap/extension-placeholder'
|
|
8
|
+
import Link from '@tiptap/extension-link'
|
|
9
|
+
import TextAlign from '@tiptap/extension-text-align'
|
|
10
|
+
import {
|
|
11
|
+
Type,
|
|
12
|
+
AlignLeft,
|
|
13
|
+
AlignCenter,
|
|
14
|
+
AlignRight,
|
|
15
|
+
AlignJustify,
|
|
16
|
+
} from 'lucide-react'
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// localStorage helpers for formatting toggle persistence
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const STORAGE_KEY = 'sk-formatting-fields'
|
|
23
|
+
|
|
24
|
+
function getFormattingState(fieldPath: string): boolean {
|
|
25
|
+
try {
|
|
26
|
+
const stored = localStorage.getItem(STORAGE_KEY)
|
|
27
|
+
if (!stored) return false
|
|
28
|
+
const map = JSON.parse(stored) as Record<string, boolean>
|
|
29
|
+
return map[fieldPath] ?? false
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function setFormattingState(fieldPath: string, enabled: boolean) {
|
|
36
|
+
try {
|
|
37
|
+
const stored = localStorage.getItem(STORAGE_KEY)
|
|
38
|
+
const map = stored ? (JSON.parse(stored) as Record<string, boolean>) : {}
|
|
39
|
+
if (enabled) {
|
|
40
|
+
map[fieldPath] = true
|
|
41
|
+
} else {
|
|
42
|
+
delete map[fieldPath]
|
|
43
|
+
}
|
|
44
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(map))
|
|
45
|
+
} catch {
|
|
46
|
+
// ignore
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Inline toolbar button (shared)
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const InlineToolbarButton = memo(function InlineToolbarButton({
|
|
55
|
+
label,
|
|
56
|
+
isActive,
|
|
57
|
+
onClick,
|
|
58
|
+
title,
|
|
59
|
+
children,
|
|
60
|
+
}: {
|
|
61
|
+
label?: string
|
|
62
|
+
isActive: boolean
|
|
63
|
+
onClick: () => void
|
|
64
|
+
title?: string
|
|
65
|
+
children?: React.ReactNode
|
|
66
|
+
}) {
|
|
67
|
+
return (
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
className={`sk-rte__btn${isActive ? ' sk-rte__btn--active' : ''}`}
|
|
71
|
+
onClick={onClick}
|
|
72
|
+
title={title ?? label}
|
|
73
|
+
>
|
|
74
|
+
{children ?? label}
|
|
75
|
+
</button>
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Mini rich-text editor for inline formatting
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
const FormattedTextInput = memo(function FormattedTextInput({
|
|
84
|
+
field,
|
|
85
|
+
path,
|
|
86
|
+
store,
|
|
87
|
+
}: FieldRendererProps) {
|
|
88
|
+
const textField = field as TextFieldDef
|
|
89
|
+
const { value, errors, isDirty, setValue, touch } = useField(store, path)
|
|
90
|
+
const isUpdatingRef = useRef(false)
|
|
91
|
+
|
|
92
|
+
const editor = useEditor({
|
|
93
|
+
extensions: [
|
|
94
|
+
StarterKit.configure({
|
|
95
|
+
heading: false,
|
|
96
|
+
horizontalRule: false,
|
|
97
|
+
codeBlock: false,
|
|
98
|
+
}),
|
|
99
|
+
Placeholder.configure({ placeholder: textField.placeholder ?? 'Schreibe hier...' }),
|
|
100
|
+
Link.configure({
|
|
101
|
+
openOnClick: false,
|
|
102
|
+
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
|
103
|
+
}),
|
|
104
|
+
TextAlign.configure({ types: ['paragraph'] }),
|
|
105
|
+
],
|
|
106
|
+
content: (value as string) || '',
|
|
107
|
+
onUpdate: ({ editor: ed }) => {
|
|
108
|
+
isUpdatingRef.current = true
|
|
109
|
+
setValue(ed.getHTML())
|
|
110
|
+
isUpdatingRef.current = false
|
|
111
|
+
},
|
|
112
|
+
onBlur: () => touch(),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!editor || isUpdatingRef.current) return
|
|
117
|
+
const current = editor.getHTML()
|
|
118
|
+
if (current !== value && typeof value === 'string') {
|
|
119
|
+
editor.commands.setContent(value, { emitUpdate: false })
|
|
120
|
+
}
|
|
121
|
+
}, [editor, value])
|
|
122
|
+
|
|
123
|
+
const handleLink = useCallback(() => {
|
|
124
|
+
if (!editor) return
|
|
125
|
+
if (editor.isActive('link')) {
|
|
126
|
+
editor.chain().focus().unsetLink().run()
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
const url = window.prompt('URL eingeben:')
|
|
130
|
+
if (!url) return
|
|
131
|
+
editor.chain().focus().setLink({ href: url }).run()
|
|
132
|
+
}, [editor])
|
|
133
|
+
|
|
134
|
+
if (!editor) return null
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="sk-rte sk-rte--inline">
|
|
138
|
+
<div className="sk-rte__toolbar">
|
|
139
|
+
<InlineToolbarButton
|
|
140
|
+
label="B"
|
|
141
|
+
isActive={editor.isActive('bold')}
|
|
142
|
+
onClick={() => { editor.chain().focus().toggleBold().run() }}
|
|
143
|
+
/>
|
|
144
|
+
<InlineToolbarButton
|
|
145
|
+
label="I"
|
|
146
|
+
isActive={editor.isActive('italic')}
|
|
147
|
+
onClick={() => { editor.chain().focus().toggleItalic().run() }}
|
|
148
|
+
/>
|
|
149
|
+
<InlineToolbarButton
|
|
150
|
+
label="S"
|
|
151
|
+
isActive={editor.isActive('strike')}
|
|
152
|
+
onClick={() => { editor.chain().focus().toggleStrike().run() }}
|
|
153
|
+
/>
|
|
154
|
+
<InlineToolbarButton
|
|
155
|
+
label="🔗"
|
|
156
|
+
isActive={editor.isActive('link')}
|
|
157
|
+
onClick={handleLink}
|
|
158
|
+
title="Link"
|
|
159
|
+
/>
|
|
160
|
+
<span className="sk-rte__sep" />
|
|
161
|
+
<InlineToolbarButton
|
|
162
|
+
label="•"
|
|
163
|
+
isActive={editor.isActive('bulletList')}
|
|
164
|
+
onClick={() => { editor.chain().focus().toggleBulletList().run() }}
|
|
165
|
+
/>
|
|
166
|
+
<InlineToolbarButton
|
|
167
|
+
label="1."
|
|
168
|
+
isActive={editor.isActive('orderedList')}
|
|
169
|
+
onClick={() => { editor.chain().focus().toggleOrderedList().run() }}
|
|
170
|
+
/>
|
|
171
|
+
<InlineToolbarButton
|
|
172
|
+
label="“"
|
|
173
|
+
isActive={editor.isActive('blockquote')}
|
|
174
|
+
onClick={() => { editor.chain().focus().toggleBlockquote().run() }}
|
|
175
|
+
/>
|
|
176
|
+
<span className="sk-rte__sep" />
|
|
177
|
+
<InlineToolbarButton
|
|
178
|
+
isActive={editor.isActive({ textAlign: 'left' })}
|
|
179
|
+
onClick={() => { editor.chain().focus().setTextAlign('left').run() }}
|
|
180
|
+
title="Linksbündig"
|
|
181
|
+
>
|
|
182
|
+
<AlignLeft size={14} />
|
|
183
|
+
</InlineToolbarButton>
|
|
184
|
+
<InlineToolbarButton
|
|
185
|
+
isActive={editor.isActive({ textAlign: 'center' })}
|
|
186
|
+
onClick={() => { editor.chain().focus().setTextAlign('center').run() }}
|
|
187
|
+
title="Zentriert"
|
|
188
|
+
>
|
|
189
|
+
<AlignCenter size={14} />
|
|
190
|
+
</InlineToolbarButton>
|
|
191
|
+
<InlineToolbarButton
|
|
192
|
+
isActive={editor.isActive({ textAlign: 'right' })}
|
|
193
|
+
onClick={() => { editor.chain().focus().setTextAlign('right').run() }}
|
|
194
|
+
title="Rechtsbündig"
|
|
195
|
+
>
|
|
196
|
+
<AlignRight size={14} />
|
|
197
|
+
</InlineToolbarButton>
|
|
198
|
+
<InlineToolbarButton
|
|
199
|
+
isActive={editor.isActive({ textAlign: 'justify' })}
|
|
200
|
+
onClick={() => { editor.chain().focus().setTextAlign('justify').run() }}
|
|
201
|
+
title="Blocksatz"
|
|
202
|
+
>
|
|
203
|
+
<AlignJustify size={14} />
|
|
204
|
+
</InlineToolbarButton>
|
|
205
|
+
</div>
|
|
206
|
+
<EditorContent editor={editor} className="sk-rte__content sk-rte__content--inline" />
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Plain text input (default)
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
const PlainTextInput = memo(function PlainTextInput({
|
|
216
|
+
field,
|
|
217
|
+
path,
|
|
218
|
+
store,
|
|
219
|
+
}: FieldRendererProps) {
|
|
220
|
+
const textField = field as TextFieldDef
|
|
221
|
+
const { value, errors, setValue, touch } = useField(store, path)
|
|
222
|
+
|
|
223
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
224
|
+
setValue(e.target.value)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const inputProps = {
|
|
228
|
+
value: (value as string) ?? '',
|
|
229
|
+
onChange: handleChange,
|
|
230
|
+
onBlur: touch,
|
|
231
|
+
placeholder: textField.placeholder,
|
|
232
|
+
maxLength: textField.maxLength,
|
|
233
|
+
'aria-label': textField.label,
|
|
234
|
+
'aria-invalid': errors.length > 0,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<>
|
|
239
|
+
{textField.multiline ? (
|
|
240
|
+
<textarea className="sk-field__input sk-field__textarea" rows={4} {...inputProps} />
|
|
241
|
+
) : (
|
|
242
|
+
<input className="sk-field__input" type="text" {...inputProps} />
|
|
243
|
+
)}
|
|
244
|
+
{textField.maxLength && (
|
|
245
|
+
<span className="sk-field__counter">
|
|
246
|
+
{((value as string) ?? '').length}/{textField.maxLength}
|
|
247
|
+
</span>
|
|
248
|
+
)}
|
|
249
|
+
</>
|
|
250
|
+
)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Main text field renderer with formatting toggle
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
export const TextFieldRenderer = memo(function TextFieldRenderer(props: FieldRendererProps) {
|
|
258
|
+
const textField = props.field as TextFieldDef
|
|
259
|
+
const { errors, isDirty } = useField(props.store, props.path)
|
|
260
|
+
const fieldPath = props.path.join('.')
|
|
261
|
+
|
|
262
|
+
// Schema can force formatting on; otherwise use localStorage toggle
|
|
263
|
+
const [formatting, setFormatting] = useState(() =>
|
|
264
|
+
textField.formatting || getFormattingState(fieldPath),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
const toggleFormatting = useCallback(() => {
|
|
268
|
+
setFormatting((prev) => {
|
|
269
|
+
const next = !prev
|
|
270
|
+
setFormattingState(fieldPath, next)
|
|
271
|
+
return next
|
|
272
|
+
})
|
|
273
|
+
}, [fieldPath])
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<div
|
|
277
|
+
className={`sk-field sk-field--text${formatting ? ' sk-field--formatted' : ''}`}
|
|
278
|
+
data-dirty={isDirty || undefined}
|
|
279
|
+
>
|
|
280
|
+
<div className="sk-field__header">
|
|
281
|
+
<label className="sk-field__label">
|
|
282
|
+
{textField.label}
|
|
283
|
+
{textField.required && <span className="sk-field__required">*</span>}
|
|
284
|
+
</label>
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
className={`sk-field__format-toggle${formatting ? ' sk-field__format-toggle--active' : ''}`}
|
|
288
|
+
onClick={toggleFormatting}
|
|
289
|
+
title={formatting ? 'Formatierung ausschalten' : 'Formatierung einschalten'}
|
|
290
|
+
>
|
|
291
|
+
<Type size={14} />
|
|
292
|
+
</button>
|
|
293
|
+
</div>
|
|
294
|
+
{textField.description && (
|
|
295
|
+
<p className="sk-field__description">{textField.description}</p>
|
|
296
|
+
)}
|
|
297
|
+
|
|
298
|
+
{formatting ? (
|
|
299
|
+
<FormattedTextInput {...props} />
|
|
300
|
+
) : (
|
|
301
|
+
<PlainTextInput {...props} />
|
|
302
|
+
)}
|
|
303
|
+
|
|
304
|
+
{errors.length > 0 && (
|
|
305
|
+
<div className="sk-field__errors">
|
|
306
|
+
{errors.map((err, i) => (
|
|
307
|
+
<p key={i} className="sk-field__error">{err}</p>
|
|
308
|
+
))}
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
)
|
|
313
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef } from 'react'
|
|
2
|
+
import { useStore } from 'zustand'
|
|
3
|
+
import type { FieldPath } from '@setzkasten-cms/core'
|
|
4
|
+
|
|
5
|
+
// Stable empty array to avoid new references on every render
|
|
6
|
+
const EMPTY_ERRORS: string[] = []
|
|
7
|
+
|
|
8
|
+
// Path-based deep get (duplicated from form-store to avoid circular deps)
|
|
9
|
+
function getIn(obj: unknown, path: FieldPath): unknown {
|
|
10
|
+
let current: unknown = obj
|
|
11
|
+
for (const key of path) {
|
|
12
|
+
if (current === null || current === undefined) return undefined
|
|
13
|
+
if (typeof current === 'object') {
|
|
14
|
+
current = (current as Record<string | number, unknown>)[key]
|
|
15
|
+
} else {
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return current
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hook for individual field state – subscribes only to its own path.
|
|
24
|
+
* This prevents unnecessary re-renders when other fields change.
|
|
25
|
+
*
|
|
26
|
+
* IMPORTANT: All selectors must return referentially stable values
|
|
27
|
+
* to avoid React 19 useSyncExternalStore infinite loops.
|
|
28
|
+
*/
|
|
29
|
+
export function useField(store: ReturnType<typeof import('../stores/form-store').createFormStore>, path: FieldPath) {
|
|
30
|
+
const pathKey = path.join('.')
|
|
31
|
+
|
|
32
|
+
// Use stable selector references
|
|
33
|
+
const value = useStore(store, useCallback(
|
|
34
|
+
(s) => getIn(s.values, path),
|
|
35
|
+
[pathKey],
|
|
36
|
+
))
|
|
37
|
+
|
|
38
|
+
const errors = useStore(store, useCallback(
|
|
39
|
+
(s) => {
|
|
40
|
+
const fieldErrors = s.errors[pathKey]
|
|
41
|
+
return fieldErrors && fieldErrors.length > 0 ? fieldErrors : EMPTY_ERRORS
|
|
42
|
+
},
|
|
43
|
+
[pathKey],
|
|
44
|
+
))
|
|
45
|
+
|
|
46
|
+
const isTouched = useStore(store, useCallback(
|
|
47
|
+
(s) => s.touched.has(pathKey),
|
|
48
|
+
[pathKey],
|
|
49
|
+
))
|
|
50
|
+
|
|
51
|
+
// Compute isDirty outside of useStore to avoid unstable selector
|
|
52
|
+
const initialValues = useStore(store, useCallback(
|
|
53
|
+
(s) => getIn(s.initialValues, path),
|
|
54
|
+
[pathKey],
|
|
55
|
+
))
|
|
56
|
+
|
|
57
|
+
const isDirty = JSON.stringify(value) !== JSON.stringify(initialValues)
|
|
58
|
+
|
|
59
|
+
const setValue = useCallback(
|
|
60
|
+
(newValue: unknown) => {
|
|
61
|
+
store.getState().setFieldValue(path, newValue)
|
|
62
|
+
},
|
|
63
|
+
[store, pathKey],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const touch = useCallback(() => {
|
|
67
|
+
store.getState().touchField(path)
|
|
68
|
+
}, [store, pathKey])
|
|
69
|
+
|
|
70
|
+
return useMemo(
|
|
71
|
+
() => ({
|
|
72
|
+
value,
|
|
73
|
+
errors,
|
|
74
|
+
isTouched,
|
|
75
|
+
isDirty,
|
|
76
|
+
setValue,
|
|
77
|
+
touch,
|
|
78
|
+
path,
|
|
79
|
+
}),
|
|
80
|
+
[value, errors, isTouched, isDirty, setValue, touch, path],
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import type { Result, CommitResult } from '@setzkasten-cms/core'
|
|
3
|
+
import { useRepository, useEventBus } from '../providers/setzkasten-provider'
|
|
4
|
+
import type { createFormStore } from '../stores/form-store'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hook for saving form content to the repository.
|
|
8
|
+
*/
|
|
9
|
+
export function useSave(
|
|
10
|
+
store: ReturnType<typeof createFormStore>,
|
|
11
|
+
collection: string,
|
|
12
|
+
slug: string,
|
|
13
|
+
) {
|
|
14
|
+
const repository = useRepository()
|
|
15
|
+
const eventBus = useEventBus()
|
|
16
|
+
const [saving, setSaving] = useState(false)
|
|
17
|
+
|
|
18
|
+
const save = useCallback(async (): Promise<Result<CommitResult>> => {
|
|
19
|
+
const state = store.getState()
|
|
20
|
+
|
|
21
|
+
if (!state.isDirty()) {
|
|
22
|
+
return { ok: true, value: { sha: '', message: 'No changes' } }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
setSaving(true)
|
|
26
|
+
state.setStatus('saving')
|
|
27
|
+
|
|
28
|
+
const result = await repository.saveEntry(collection, slug, {
|
|
29
|
+
content: state.values,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
if (result.ok) {
|
|
33
|
+
// Update initial values to current (no longer dirty)
|
|
34
|
+
state.init(state.schema!, state.values)
|
|
35
|
+
state.setStatus('idle')
|
|
36
|
+
eventBus.emit({ type: 'entry-saved', collection, slug })
|
|
37
|
+
} else {
|
|
38
|
+
state.setStatus('error', result.error.message)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setSaving(false)
|
|
42
|
+
return result
|
|
43
|
+
}, [store, repository, eventBus, collection, slug])
|
|
44
|
+
|
|
45
|
+
return { save, saving }
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @setzkasten-cms/ui – Public API
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
// Providers
|
|
6
|
+
export { SetzKastenProvider, useSetzKasten, useRepository, useAuth, useAssets, useEventBus, useConfig } from './providers/setzkasten-provider'
|
|
7
|
+
export type { SetzKastenContext } from './providers/setzkasten-provider'
|
|
8
|
+
|
|
9
|
+
// Stores
|
|
10
|
+
export { createFormStore } from './stores/form-store'
|
|
11
|
+
export type { FormState, FormActions, FormStore } from './stores/form-store'
|
|
12
|
+
export { createAppStore } from './stores/app-store'
|
|
13
|
+
export type { AppState, AppActions, AppStore, RecentEntry } from './stores/app-store'
|
|
14
|
+
|
|
15
|
+
// Fields
|
|
16
|
+
export { FieldRenderer } from './fields/field-renderer'
|
|
17
|
+
export type { FieldRendererProps } from './fields/field-renderer'
|
|
18
|
+
|
|
19
|
+
// Components
|
|
20
|
+
export { EntryForm } from './components/entry-form'
|
|
21
|
+
export { EntryList } from './components/entry-list'
|
|
22
|
+
export { CollectionView } from './components/collection-view'
|
|
23
|
+
export { AdminApp } from './components/admin-app'
|
|
24
|
+
export { ToastProvider, useToast } from './components/toast'
|
|
25
|
+
|
|
26
|
+
// Hooks
|
|
27
|
+
export { useField } from './hooks/use-field'
|
|
28
|
+
export { useSave } from './hooks/use-save'
|
|
29
|
+
|
|
30
|
+
// Adapters
|
|
31
|
+
export { ProxyContentRepository } from './adapters/proxy-content-repository'
|
|
32
|
+
export type { ProxyConfig } from './adapters/proxy-content-repository'
|
|
33
|
+
export { ProxyAssetStore } from './adapters/proxy-asset-store'
|
|
34
|
+
export type { ProxyAssetConfig } from './adapters/proxy-asset-store'
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
|
2
|
+
import type {
|
|
3
|
+
ContentRepository,
|
|
4
|
+
AuthProvider,
|
|
5
|
+
AssetStore,
|
|
6
|
+
SetzKastenConfig,
|
|
7
|
+
ContentEventBus,
|
|
8
|
+
} from '@setzkasten-cms/core'
|
|
9
|
+
import { createEventBus } from '@setzkasten-cms/core'
|
|
10
|
+
|
|
11
|
+
export interface SetzKastenContext {
|
|
12
|
+
config: SetzKastenConfig
|
|
13
|
+
repository: ContentRepository
|
|
14
|
+
auth: AuthProvider
|
|
15
|
+
assets: AssetStore
|
|
16
|
+
eventBus: ContentEventBus
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const Context = createContext<SetzKastenContext | null>(null)
|
|
20
|
+
|
|
21
|
+
interface SetzKastenProviderProps {
|
|
22
|
+
config: SetzKastenConfig
|
|
23
|
+
repository: ContentRepository
|
|
24
|
+
auth: AuthProvider
|
|
25
|
+
assets: AssetStore
|
|
26
|
+
children: ReactNode
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Root provider – the composition root injects all concrete adapters here.
|
|
31
|
+
* UI components access ports only via this context (Dependency Inversion).
|
|
32
|
+
*/
|
|
33
|
+
export function SetzKastenProvider({
|
|
34
|
+
config,
|
|
35
|
+
repository,
|
|
36
|
+
auth,
|
|
37
|
+
assets,
|
|
38
|
+
children,
|
|
39
|
+
}: SetzKastenProviderProps) {
|
|
40
|
+
const value = useMemo(
|
|
41
|
+
() => ({
|
|
42
|
+
config,
|
|
43
|
+
repository,
|
|
44
|
+
auth,
|
|
45
|
+
assets,
|
|
46
|
+
eventBus: createEventBus(),
|
|
47
|
+
}),
|
|
48
|
+
[config, repository, auth, assets],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return <Context value={value}>{children}</Context>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useSetzKasten(): SetzKastenContext {
|
|
55
|
+
const context = useContext(Context)
|
|
56
|
+
if (!context) {
|
|
57
|
+
throw new Error('useSetzKasten must be used within a <SetzKastenProvider>')
|
|
58
|
+
}
|
|
59
|
+
return context
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useRepository(): ContentRepository {
|
|
63
|
+
return useSetzKasten().repository
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useAuth(): AuthProvider {
|
|
67
|
+
return useSetzKasten().auth
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function useAssets(): AssetStore {
|
|
71
|
+
return useSetzKasten().assets
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function useEventBus(): ContentEventBus {
|
|
75
|
+
return useSetzKasten().eventBus
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function useConfig(): SetzKastenConfig {
|
|
79
|
+
return useSetzKasten().config
|
|
80
|
+
}
|