@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.
@@ -0,0 +1,48 @@
1
+ import { useState, useEffect, useCallback, createContext, useContext } from 'react'
2
+
3
+ type ToastType = 'success' | 'error' | 'info'
4
+
5
+ interface Toast {
6
+ id: number
7
+ message: string
8
+ type: ToastType
9
+ }
10
+
11
+ interface ToastContextValue {
12
+ toast: (message: string, type?: ToastType) => void
13
+ }
14
+
15
+ const ToastContext = createContext<ToastContextValue>({ toast: () => {} })
16
+
17
+ export function useToast() {
18
+ return useContext(ToastContext)
19
+ }
20
+
21
+ let nextId = 0
22
+
23
+ export function ToastProvider({ children }: { children: React.ReactNode }) {
24
+ const [toasts, setToasts] = useState<Toast[]>([])
25
+
26
+ const toast = useCallback((message: string, type: ToastType = 'info') => {
27
+ const id = nextId++
28
+ setToasts(prev => [...prev, { id, message, type }])
29
+ setTimeout(() => {
30
+ setToasts(prev => prev.filter(t => t.id !== id))
31
+ }, 3000)
32
+ }, [])
33
+
34
+ return (
35
+ <ToastContext.Provider value={{ toast }}>
36
+ {children}
37
+ {toasts.length > 0 && (
38
+ <div className="sk-toast-container">
39
+ {toasts.map(t => (
40
+ <div key={t.id} className={`sk-toast sk-toast--${t.type}`}>
41
+ {t.message}
42
+ </div>
43
+ ))}
44
+ </div>
45
+ )}
46
+ </ToastContext.Provider>
47
+ )
48
+ }
@@ -0,0 +1,101 @@
1
+ import { memo, useCallback } from 'react'
2
+ import type { ArrayFieldDef, AnyFieldDef } from '@setzkasten-cms/core'
3
+ import { useField } from '../hooks/use-field'
4
+ import { FieldRenderer, type FieldRendererProps } from './field-renderer'
5
+
6
+ export const ArrayFieldRenderer = memo(function ArrayFieldRenderer({
7
+ field,
8
+ path,
9
+ store,
10
+ }: FieldRendererProps) {
11
+ const arrayField = field as ArrayFieldDef
12
+ const { value, setValue } = useField(store, path)
13
+
14
+ const items = (value as unknown[]) ?? []
15
+
16
+ const addItem = useCallback(() => {
17
+ const defaultValue = arrayField.itemField.defaultValue ?? (
18
+ arrayField.itemField.type === 'object' ? {} :
19
+ arrayField.itemField.type === 'text' ? '' :
20
+ arrayField.itemField.type === 'number' ? 0 :
21
+ arrayField.itemField.type === 'boolean' ? false :
22
+ null
23
+ )
24
+ setValue([...items, defaultValue])
25
+ }, [items, arrayField.itemField, setValue])
26
+
27
+ const removeItem = useCallback(
28
+ (index: number) => {
29
+ setValue(items.filter((_, i) => i !== index))
30
+ },
31
+ [items, setValue],
32
+ )
33
+
34
+ const moveItem = useCallback(
35
+ (from: number, to: number) => {
36
+ const newItems = [...items]
37
+ const [moved] = newItems.splice(from, 1)
38
+ newItems.splice(to, 0, moved)
39
+ setValue(newItems)
40
+ },
41
+ [items, setValue],
42
+ )
43
+
44
+ const canAdd = arrayField.maxItems === undefined || items.length < arrayField.maxItems
45
+ const canRemove = arrayField.minItems === undefined || items.length > arrayField.minItems
46
+
47
+ return (
48
+ <div className="sk-field sk-field--array">
49
+ <div className="sk-array__header">
50
+ <label className="sk-field__label">
51
+ {arrayField.label}
52
+ <span className="sk-array__count">({items.length})</span>
53
+ </label>
54
+ </div>
55
+
56
+ <div className="sk-array__items">
57
+ {items.map((_, index) => {
58
+ const itemLabel = arrayField.itemLabel
59
+ ? arrayField.itemLabel(items[index], index)
60
+ : `#${index + 1}`
61
+
62
+ return (
63
+ <div key={index} className="sk-array__item">
64
+ <div className="sk-array__item-header">
65
+ <span className="sk-array__item-label">{itemLabel}</span>
66
+ <div className="sk-array__item-actions">
67
+ {index > 0 && (
68
+ <button type="button" className="sk-button sk-button--sm" onClick={() => moveItem(index, index - 1)} title="Nach oben">
69
+
70
+ </button>
71
+ )}
72
+ {index < items.length - 1 && (
73
+ <button type="button" className="sk-button sk-button--sm" onClick={() => moveItem(index, index + 1)} title="Nach unten">
74
+
75
+ </button>
76
+ )}
77
+ {canRemove && (
78
+ <button type="button" className="sk-button sk-button--sm sk-button--danger" onClick={() => removeItem(index)} title="Entfernen">
79
+ ×
80
+ </button>
81
+ )}
82
+ </div>
83
+ </div>
84
+ <FieldRenderer
85
+ field={arrayField.itemField as AnyFieldDef}
86
+ path={[...path, index]}
87
+ store={store}
88
+ />
89
+ </div>
90
+ )
91
+ })}
92
+ </div>
93
+
94
+ {canAdd && (
95
+ <button type="button" className="sk-button sk-button--sm sk-array__add" onClick={addItem}>
96
+ + Hinzufügen
97
+ </button>
98
+ )}
99
+ </div>
100
+ )
101
+ })
@@ -0,0 +1,28 @@
1
+ import { memo } from 'react'
2
+ import type { BooleanFieldDef } from '@setzkasten-cms/core'
3
+ import { useField } from '../hooks/use-field'
4
+ import type { FieldRendererProps } from './field-renderer'
5
+
6
+ export const BooleanFieldRenderer = memo(function BooleanFieldRenderer({
7
+ field,
8
+ path,
9
+ store,
10
+ }: FieldRendererProps) {
11
+ const boolField = field as BooleanFieldDef
12
+ const { value, setValue } = useField(store, path)
13
+
14
+ return (
15
+ <div className="sk-field sk-field--boolean">
16
+ <label className="sk-field__toggle">
17
+ <input
18
+ type="checkbox"
19
+ checked={Boolean(value)}
20
+ onChange={(e) => setValue(e.target.checked)}
21
+ aria-label={boolField.label}
22
+ />
23
+ <span className="sk-field__toggle-label">{boolField.label}</span>
24
+ </label>
25
+ {boolField.description && <p className="sk-field__description">{boolField.description}</p>}
26
+ </div>
27
+ )
28
+ })
@@ -0,0 +1,60 @@
1
+ import { memo, type ComponentType } from 'react'
2
+ import type { AnyFieldDef, FieldDefinition, FieldPath, FieldType } from '@setzkasten-cms/core'
3
+ import { TextFieldRenderer } from './text-field-renderer'
4
+ import { NumberFieldRenderer } from './number-field-renderer'
5
+ import { BooleanFieldRenderer } from './boolean-field-renderer'
6
+ import { SelectFieldRenderer } from './select-field-renderer'
7
+ import { IconFieldRenderer } from './icon-field-renderer'
8
+ import { ArrayFieldRenderer } from './array-field-renderer'
9
+ import { ObjectFieldRenderer } from './object-field-renderer'
10
+ import { ImageFieldRenderer } from './image-field-renderer'
11
+ import { OverrideFieldRenderer } from './override-field-renderer'
12
+ import type { createFormStore } from '../stores/form-store'
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Field renderer props (shared by all renderers)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface FieldRendererProps {
19
+ field: AnyFieldDef
20
+ path: FieldPath
21
+ store: ReturnType<typeof createFormStore>
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Renderer registry (Strategy Pattern via type map)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const rendererMap: Partial<Record<FieldType, ComponentType<FieldRendererProps>>> = {
29
+ text: TextFieldRenderer,
30
+ number: NumberFieldRenderer,
31
+ boolean: BooleanFieldRenderer,
32
+ select: SelectFieldRenderer,
33
+ icon: IconFieldRenderer,
34
+ array: ArrayFieldRenderer,
35
+ object: ObjectFieldRenderer,
36
+ image: ImageFieldRenderer,
37
+ override: OverrideFieldRenderer,
38
+ }
39
+
40
+ /**
41
+ * Generic field renderer – looks up the correct component by field type.
42
+ * Adding a new field type = adding an entry to rendererMap.
43
+ */
44
+ export const FieldRenderer = memo(function FieldRenderer({
45
+ field,
46
+ path,
47
+ store,
48
+ }: FieldRendererProps) {
49
+ const Renderer = rendererMap[field.type]
50
+
51
+ if (!Renderer) {
52
+ return (
53
+ <div style={{ color: '#ef4444', padding: '8px', fontSize: '14px' }}>
54
+ Unknown field type: {field.type}
55
+ </div>
56
+ )
57
+ }
58
+
59
+ return <Renderer field={field} path={path} store={store} />
60
+ })
@@ -0,0 +1,130 @@
1
+ import { memo, useState, useMemo, useCallback, useRef, useEffect } from 'react'
2
+ import { icons, type LucideIcon } from 'lucide-react'
3
+ import { useField } from '../hooks/use-field'
4
+ import type { FieldRendererProps } from './field-renderer'
5
+
6
+ /** Convert PascalCase icon name to kebab-case for storage */
7
+ function toKebab(name: string): string {
8
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
9
+ }
10
+
11
+ /** Convert kebab-case to PascalCase for lookup */
12
+ function toPascal(name: string): string {
13
+ return name.replace(/(^|-)([a-z])/g, (_, __, c) => c.toUpperCase())
14
+ }
15
+
16
+ /** All icon entries: [kebabName, PascalName, Component] */
17
+ const ALL_ICONS: [string, string, LucideIcon][] = Object.entries(icons).map(
18
+ ([pascal, comp]) => [toKebab(pascal), pascal, comp],
19
+ )
20
+
21
+ const MAX_VISIBLE = 120
22
+
23
+ export const IconFieldRenderer = memo(function IconFieldRenderer({
24
+ field,
25
+ path,
26
+ store,
27
+ }: FieldRendererProps) {
28
+ const { value, errors, setValue } = useField(store, path)
29
+ const [open, setOpen] = useState(false)
30
+ const [search, setSearch] = useState('')
31
+ const searchRef = useRef<HTMLInputElement>(null)
32
+ const panelRef = useRef<HTMLDivElement>(null)
33
+
34
+ const currentValue = (value as string) ?? ''
35
+ const CurrentIcon = currentValue ? ((icons as Record<string, LucideIcon>)[toPascal(currentValue)] ?? null) : null
36
+
37
+ const filtered = useMemo(() => {
38
+ if (!search) return ALL_ICONS.slice(0, MAX_VISIBLE)
39
+ const q = search.toLowerCase()
40
+ return ALL_ICONS.filter(([kebab]) => kebab.includes(q)).slice(0, MAX_VISIBLE)
41
+ }, [search])
42
+
43
+ const selectIcon = useCallback(
44
+ (kebab: string) => {
45
+ setValue(kebab)
46
+ setOpen(false)
47
+ setSearch('')
48
+ },
49
+ [setValue],
50
+ )
51
+
52
+ // Focus search when opening
53
+ useEffect(() => {
54
+ if (open) searchRef.current?.focus()
55
+ }, [open])
56
+
57
+ // Close on click outside
58
+ useEffect(() => {
59
+ if (!open) return
60
+ const handler = (e: MouseEvent) => {
61
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
62
+ setOpen(false)
63
+ setSearch('')
64
+ }
65
+ }
66
+ document.addEventListener('mousedown', handler)
67
+ return () => document.removeEventListener('mousedown', handler)
68
+ }, [open])
69
+
70
+ return (
71
+ <div className="sk-field sk-field--icon">
72
+ <label className="sk-field__label">{field.label}</label>
73
+ {field.description && (
74
+ <p className="sk-field__description">{field.description}</p>
75
+ )}
76
+
77
+ <button
78
+ type="button"
79
+ className="sk-icon-picker__trigger"
80
+ onClick={() => setOpen(!open)}
81
+ >
82
+ {CurrentIcon ? (
83
+ <>
84
+ <CurrentIcon size={20} />
85
+ <span>{currentValue}</span>
86
+ </>
87
+ ) : (
88
+ <span className="sk-icon-picker__placeholder">Icon wählen...</span>
89
+ )}
90
+ </button>
91
+
92
+ {open && (
93
+ <div className="sk-icon-picker__panel" ref={panelRef}>
94
+ <input
95
+ ref={searchRef}
96
+ type="text"
97
+ className="sk-icon-picker__search"
98
+ placeholder="Icon suchen..."
99
+ value={search}
100
+ onChange={(e) => setSearch(e.target.value)}
101
+ />
102
+ <div className="sk-icon-picker__grid">
103
+ {filtered.map(([kebab, , Icon]) => (
104
+ <button
105
+ key={kebab}
106
+ type="button"
107
+ className={`sk-icon-picker__item${kebab === currentValue ? ' sk-icon-picker__item--active' : ''}`}
108
+ onClick={() => selectIcon(kebab)}
109
+ title={kebab}
110
+ >
111
+ <Icon size={20} />
112
+ </button>
113
+ ))}
114
+ {filtered.length === 0 && (
115
+ <p className="sk-icon-picker__empty">Kein Icon gefunden</p>
116
+ )}
117
+ </div>
118
+ </div>
119
+ )}
120
+
121
+ {errors.length > 0 && (
122
+ <div className="sk-field__errors">
123
+ {errors.map((err, i) => (
124
+ <p key={i} className="sk-field__error">{err}</p>
125
+ ))}
126
+ </div>
127
+ )}
128
+ </div>
129
+ )
130
+ })
@@ -0,0 +1,266 @@
1
+ import { memo, useCallback, useRef, useState } from 'react'
2
+ import type { ImageFieldDef, ImageValue, AssetMetadata } from '@setzkasten-cms/core'
3
+ import { useField } from '../hooks/use-field'
4
+ import { useAssets } from '../providers/setzkasten-provider'
5
+ import type { FieldRendererProps } from './field-renderer'
6
+
7
+ /** Helper: get a preview URL that works in the admin (raw GitHub), falling back to public URL */
8
+ function previewUrl(assets: { getUrl(p: string): string; getPreviewUrl?(p: string): string }, path: string): string {
9
+ return assets.getPreviewUrl?.(path) ?? assets.getUrl(path)
10
+ }
11
+
12
+ /** Convert image File to WebP using Canvas API. Skips SVG, GIF, and already-WebP/AVIF files. */
13
+ async function compressToWebP(file: File, quality = 0.85): Promise<{ bytes: Uint8Array; filename: string; mimeType: string }> {
14
+ const skipTypes = ['image/svg+xml', 'image/gif', 'image/webp', 'image/avif']
15
+ if (skipTypes.includes(file.type)) {
16
+ return { bytes: new Uint8Array(await file.arrayBuffer()), filename: file.name, mimeType: file.type }
17
+ }
18
+
19
+ const bitmap = await createImageBitmap(file)
20
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height)
21
+ const ctx = canvas.getContext('2d')!
22
+ ctx.drawImage(bitmap, 0, 0)
23
+ bitmap.close()
24
+
25
+ const blob = await canvas.convertToBlob({ type: 'image/webp', quality })
26
+ const webpName = file.name.replace(/\.(jpe?g|png|bmp|tiff?)$/i, '.webp')
27
+ return { bytes: new Uint8Array(await blob.arrayBuffer()), filename: webpName, mimeType: 'image/webp' }
28
+ }
29
+
30
+ export const ImageFieldRenderer = memo(function ImageFieldRenderer({
31
+ field,
32
+ path,
33
+ store,
34
+ }: FieldRendererProps) {
35
+ const imgField = field as ImageFieldDef
36
+ const { value, errors, setValue, touch } = useField(store, path)
37
+ const assets = useAssets()
38
+ const fileInputRef = useRef<HTMLInputElement>(null)
39
+ const [uploading, setUploading] = useState(false)
40
+ const [dragOver, setDragOver] = useState(false)
41
+ const [showBrowser, setShowBrowser] = useState(false)
42
+ const [browserScope, setBrowserScope] = useState<'directory' | 'all'>('directory')
43
+ const [existing, setExisting] = useState<AssetMetadata[]>([])
44
+ const [loadingExisting, setLoadingExisting] = useState(false)
45
+
46
+ const imageValue = value as ImageValue | undefined
47
+ const acceptStr = imgField.accept.map((ext) => `.${ext}`).join(',')
48
+
49
+ // For the selected image thumbnail: use asset proxy for authenticated preview
50
+ const selectedThumbUrl = imageValue?.path && assets.getPreviewUrl
51
+ ? assets.getPreviewUrl(imageValue.path)
52
+ : imageValue?.path || undefined
53
+
54
+ const uploadFile = useCallback(
55
+ async (file: File) => {
56
+ setUploading(true)
57
+ try {
58
+ const { bytes, filename, mimeType } = await compressToWebP(file)
59
+ const result = await assets.upload(imgField.directory, filename, bytes, mimeType)
60
+ if (result.ok) {
61
+ const publicUrl = assets.getUrl(result.value.path)
62
+ setValue({ path: publicUrl, alt: imageValue?.alt ?? '' })
63
+ } else {
64
+ const dir = imgField.directory.replace(/^\/+|\/+$/g, '')
65
+ const publicUrl = assets.getUrl(`public/images/${dir}/${filename}`)
66
+ setValue({ path: publicUrl, alt: imageValue?.alt ?? '' })
67
+ }
68
+ } catch {
69
+ const dir = imgField.directory.replace(/^\/+|\/+$/g, '')
70
+ const publicUrl = assets.getUrl(`public/images/${dir}/${file.name}`)
71
+ setValue({ path: publicUrl, alt: imageValue?.alt ?? '' })
72
+ }
73
+ setUploading(false)
74
+ touch()
75
+ },
76
+ [assets, imgField.directory, imageValue?.alt, setValue, touch],
77
+ )
78
+
79
+ const loadExisting = useCallback(async (scope: 'directory' | 'all') => {
80
+ setLoadingExisting(true)
81
+ setBrowserScope(scope)
82
+ try {
83
+ const dir = scope === 'all' ? '' : imgField.directory
84
+ const result = await assets.list(dir)
85
+ if (result.ok) {
86
+ setExisting(result.value)
87
+ }
88
+ } catch {
89
+ // ignore
90
+ }
91
+ setLoadingExisting(false)
92
+ }, [assets, imgField.directory])
93
+
94
+ const handleBrowse = useCallback(() => {
95
+ setShowBrowser(true)
96
+ loadExisting('directory')
97
+ }, [loadExisting])
98
+
99
+ const handleSelectExisting = useCallback(
100
+ (asset: AssetMetadata) => {
101
+ const publicUrl = assets.getUrl(asset.path)
102
+ setValue({ path: publicUrl, alt: imageValue?.alt ?? '' })
103
+ setShowBrowser(false)
104
+ },
105
+ [assets, imageValue?.alt, setValue],
106
+ )
107
+
108
+ const handleFileSelect = useCallback(
109
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
110
+ const file = e.target.files?.[0]
111
+ if (!file) return
112
+ await uploadFile(file)
113
+ },
114
+ [uploadFile],
115
+ )
116
+
117
+ const handleDrop = useCallback(
118
+ async (e: React.DragEvent) => {
119
+ e.preventDefault()
120
+ setDragOver(false)
121
+ const file = e.dataTransfer.files[0]
122
+ if (file) await uploadFile(file)
123
+ },
124
+ [uploadFile],
125
+ )
126
+
127
+ const handleAltChange = useCallback(
128
+ (e: React.ChangeEvent<HTMLInputElement>) => {
129
+ setValue({ path: imageValue?.path ?? '', alt: e.target.value })
130
+ },
131
+ [imageValue?.path, setValue],
132
+ )
133
+
134
+ const handleRemove = useCallback(() => {
135
+ setValue({ path: '', alt: '' })
136
+ if (fileInputRef.current) {
137
+ fileInputRef.current.value = ''
138
+ }
139
+ }, [setValue])
140
+
141
+ return (
142
+ <div className="sk-field sk-field--image">
143
+ <label className="sk-field__label">
144
+ {imgField.label}
145
+ {imgField.required && <span className="sk-field__required">*</span>}
146
+ </label>
147
+ {imgField.description && <p className="sk-field__description">{imgField.description}</p>}
148
+
149
+ <div className="sk-field__image-zone">
150
+ {uploading ? (
151
+ <div className="sk-field__image-upload" style={{ borderColor: 'var(--sk-accent)' }}>
152
+ Wird hochgeladen...
153
+ </div>
154
+ ) : imageValue?.path ? (
155
+ <div className="sk-field__image-preview">
156
+ {selectedThumbUrl && (
157
+ <img src={selectedThumbUrl} alt={imageValue.alt ?? ''} className="sk-field__image-thumb" />
158
+ )}
159
+ <p className="sk-field__image-path">{imageValue.path}</p>
160
+ <div className="sk-field__image-actions">
161
+ <button type="button" className="sk-button sk-button--sm" onClick={() => fileInputRef.current?.click()}>
162
+ Neu hochladen
163
+ </button>
164
+ <button type="button" className="sk-button sk-button--sm" onClick={handleBrowse}>
165
+ Aus Bibliothek
166
+ </button>
167
+ <button type="button" className="sk-button sk-button--sm sk-button--danger" onClick={handleRemove}>
168
+ Entfernen
169
+ </button>
170
+ </div>
171
+ </div>
172
+ ) : (
173
+ <div className="sk-field__image-empty">
174
+ <button
175
+ type="button"
176
+ className={`sk-field__image-upload${dragOver ? ' sk-field__image-upload--drag' : ''}`}
177
+ onClick={() => fileInputRef.current?.click()}
178
+ onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
179
+ onDragLeave={() => setDragOver(false)}
180
+ onDrop={handleDrop}
181
+ >
182
+ Bild hochladen oder hierher ziehen
183
+ </button>
184
+ <button type="button" className="sk-button sk-button--sm sk-field__image-browse" onClick={handleBrowse}>
185
+ Aus Bibliothek wählen
186
+ </button>
187
+ </div>
188
+ )}
189
+ <input
190
+ ref={fileInputRef}
191
+ type="file"
192
+ accept={acceptStr}
193
+ onChange={handleFileSelect}
194
+ style={{ display: 'none' }}
195
+ />
196
+ </div>
197
+
198
+ {/* Image Browser */}
199
+ {showBrowser && (
200
+ <div className="sk-field__image-browser">
201
+ <div className="sk-field__image-browser-header">
202
+ <span className="sk-field__image-browser-title">
203
+ {browserScope === 'all' ? 'Alle Bilder' : `Bilder in ${imgField.directory || 'Bibliothek'}`}
204
+ </span>
205
+ <div className="sk-field__image-browser-controls">
206
+ {browserScope === 'directory' ? (
207
+ <button type="button" className="sk-button sk-button--sm" onClick={() => loadExisting('all')}>
208
+ Alle Bilder
209
+ </button>
210
+ ) : (
211
+ <button type="button" className="sk-button sk-button--sm" onClick={() => loadExisting('directory')}>
212
+ Nur {imgField.directory || 'Ordner'}
213
+ </button>
214
+ )}
215
+ <button type="button" className="sk-button sk-button--sm" onClick={() => setShowBrowser(false)}>
216
+ Schließen
217
+ </button>
218
+ </div>
219
+ </div>
220
+ {loadingExisting ? (
221
+ <p className="sk-field__image-browser-loading">Lade Bilder...</p>
222
+ ) : existing.length === 0 ? (
223
+ <p className="sk-field__image-browser-empty">Keine Bilder vorhanden</p>
224
+ ) : (
225
+ <div className="sk-field__image-browser-grid">
226
+ {existing.map((asset) => {
227
+ const thumbUrl = previewUrl(assets, asset.path)
228
+ return (
229
+ <button
230
+ key={asset.path}
231
+ type="button"
232
+ className="sk-field__image-browser-item"
233
+ onClick={() => handleSelectExisting(asset)}
234
+ title={asset.path.split('/').pop()}
235
+ >
236
+ <img src={thumbUrl} alt="" className="sk-field__image-browser-thumb" />
237
+ <span className="sk-field__image-browser-name">{asset.path.split('/').pop()}</span>
238
+ </button>
239
+ )
240
+ })}
241
+ </div>
242
+ )}
243
+ </div>
244
+ )}
245
+
246
+ {imageValue?.path && (
247
+ <input
248
+ className="sk-field__input"
249
+ type="text"
250
+ value={imageValue.alt ?? ''}
251
+ onChange={handleAltChange}
252
+ placeholder="Alt-Text (für SEO)"
253
+ aria-label="Alt text"
254
+ />
255
+ )}
256
+
257
+ {errors.length > 0 && (
258
+ <div className="sk-field__errors">
259
+ {errors.map((err, i) => (
260
+ <p key={i} className="sk-field__error">{err}</p>
261
+ ))}
262
+ </div>
263
+ )}
264
+ </div>
265
+ )
266
+ })
@@ -0,0 +1,38 @@
1
+ import { memo } from 'react'
2
+ import type { NumberFieldDef } from '@setzkasten-cms/core'
3
+ import { useField } from '../hooks/use-field'
4
+ import type { FieldRendererProps } from './field-renderer'
5
+
6
+ export const NumberFieldRenderer = memo(function NumberFieldRenderer({
7
+ field,
8
+ path,
9
+ store,
10
+ }: FieldRendererProps) {
11
+ const numField = field as NumberFieldDef
12
+ const { value, errors, setValue, touch } = useField(store, path)
13
+
14
+ return (
15
+ <div className="sk-field sk-field--number">
16
+ <label className="sk-field__label">{numField.label}</label>
17
+ {numField.description && <p className="sk-field__description">{numField.description}</p>}
18
+ <input
19
+ className="sk-field__input"
20
+ type="number"
21
+ value={(value as number) ?? 0}
22
+ onChange={(e) => setValue(Number(e.target.value))}
23
+ onBlur={touch}
24
+ min={numField.min}
25
+ max={numField.max}
26
+ step={numField.step}
27
+ aria-label={numField.label}
28
+ />
29
+ {errors.length > 0 && (
30
+ <div className="sk-field__errors">
31
+ {errors.map((err, i) => (
32
+ <p key={i} className="sk-field__error">{err}</p>
33
+ ))}
34
+ </div>
35
+ )}
36
+ </div>
37
+ )
38
+ })