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