@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,1134 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef, useSyncExternalStore, type ComponentType } from 'react'
|
|
2
|
+
import type { StoreApi } from 'zustand/vanilla'
|
|
3
|
+
import type { SetzKastenConfig, SectionDefinition, FieldRecord } from '@setzkasten-cms/core'
|
|
4
|
+
import { EntryForm } from './entry-form'
|
|
5
|
+
import { useConfig, useRepository } from '../providers/setzkasten-provider'
|
|
6
|
+
import { useToast } from './toast'
|
|
7
|
+
import type { FormStore } from '../stores/form-store'
|
|
8
|
+
import {
|
|
9
|
+
LayoutGrid, Star, Megaphone, Puzzle, Code,
|
|
10
|
+
Eye, EyeOff, Shield, Palette, Zap, Image,
|
|
11
|
+
Link, Settings, FileText, Folder, List,
|
|
12
|
+
Edit, Tag, Globe, Heart, Users, X,
|
|
13
|
+
GripVertical, Monitor, AppWindow, Tablet, Smartphone,
|
|
14
|
+
Save, ChevronUp, ChevronDown,
|
|
15
|
+
PanelRightClose, PanelRightOpen, RotateCcw,
|
|
16
|
+
Undo2, Redo2, Trash2,
|
|
17
|
+
type LucideProps,
|
|
18
|
+
} from 'lucide-react'
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Icon map (shared with admin-app)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const ICON_MAP: Record<string, ComponentType<LucideProps>> = {
|
|
25
|
+
layout: LayoutGrid, star: Star, megaphone: Megaphone, puzzle: Puzzle, code: Code,
|
|
26
|
+
eye: Eye, shield: Shield, palette: Palette, zap: Zap, image: Image,
|
|
27
|
+
link: Link, settings: Settings, file: FileText, folder: Folder, list: List,
|
|
28
|
+
edit: Edit, tag: Tag, globe: Globe, heart: Heart, users: Users,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Draggable panel hook
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function useDraggable() {
|
|
36
|
+
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
|
37
|
+
const dragging = useRef(false)
|
|
38
|
+
const start = useRef({ x: 0, y: 0 })
|
|
39
|
+
const startOffset = useRef({ x: 0, y: 0 })
|
|
40
|
+
|
|
41
|
+
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
|
42
|
+
// Only drag from the header area (closest .sk-pb__nav-header or .sk-pb__slide-over-header)
|
|
43
|
+
const target = e.target as HTMLElement
|
|
44
|
+
if (target.closest('button') || target.closest('input') || target.closest('select')) return
|
|
45
|
+
if (!target.closest('.sk-pb__nav-header, .sk-pb__slide-over-header, .sk-pb__drag-handle')) return
|
|
46
|
+
e.preventDefault()
|
|
47
|
+
dragging.current = true
|
|
48
|
+
start.current = { x: e.clientX, y: e.clientY }
|
|
49
|
+
startOffset.current = { ...offset }
|
|
50
|
+
|
|
51
|
+
const onMouseMove = (ev: MouseEvent) => {
|
|
52
|
+
if (!dragging.current) return
|
|
53
|
+
setOffset({
|
|
54
|
+
x: startOffset.current.x + ev.clientX - start.current.x,
|
|
55
|
+
y: startOffset.current.y + ev.clientY - start.current.y,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
const onMouseUp = () => {
|
|
59
|
+
dragging.current = false
|
|
60
|
+
document.removeEventListener('mousemove', onMouseMove)
|
|
61
|
+
document.removeEventListener('mouseup', onMouseUp)
|
|
62
|
+
}
|
|
63
|
+
document.addEventListener('mousemove', onMouseMove)
|
|
64
|
+
document.addEventListener('mouseup', onMouseUp)
|
|
65
|
+
}, [offset])
|
|
66
|
+
|
|
67
|
+
const reset = useCallback(() => setOffset({ x: 0, y: 0 }), [])
|
|
68
|
+
const hasMoved = offset.x !== 0 || offset.y !== 0
|
|
69
|
+
const style = hasMoved ? { transform: `translate(${offset.x}px, ${offset.y}px)` } : undefined
|
|
70
|
+
|
|
71
|
+
return { onMouseDown, style, offset, reset, hasMoved }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Resizable hook – single corner, opposite to CSS anchor. Dead simple.
|
|
76
|
+
// 'bl' = bottom-left handle (panel anchored top-right): left = wider, down = taller
|
|
77
|
+
// 'br' = bottom-right handle (panel anchored top-left): right = wider, down = taller
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
function useResizable(defaultWidth: number, minW = 280, maxW = 800, minH = 200, maxH = 900) {
|
|
81
|
+
const [width, setWidth] = useState(defaultWidth)
|
|
82
|
+
const [height, setHeight] = useState(0) // 0 = use CSS default
|
|
83
|
+
const resizing = useRef(false)
|
|
84
|
+
const startMouse = useRef({ x: 0, y: 0 })
|
|
85
|
+
const startW = useRef(defaultWidth)
|
|
86
|
+
const startH = useRef(0)
|
|
87
|
+
const mode = useRef<'bl' | 'br'>('bl')
|
|
88
|
+
|
|
89
|
+
const onResizeStart = useCallback((e: React.MouseEvent, corner: 'bl' | 'br') => {
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
e.stopPropagation()
|
|
92
|
+
resizing.current = true
|
|
93
|
+
startMouse.current = { x: e.clientX, y: e.clientY }
|
|
94
|
+
startW.current = width
|
|
95
|
+
startH.current = height || (e.currentTarget.parentElement?.offsetHeight ?? 400)
|
|
96
|
+
mode.current = corner
|
|
97
|
+
document.querySelector('.sk-pb')?.classList.add('sk-pb--splitting')
|
|
98
|
+
|
|
99
|
+
const onMouseMove = (ev: MouseEvent) => {
|
|
100
|
+
if (!resizing.current) return
|
|
101
|
+
const dx = ev.clientX - startMouse.current.x
|
|
102
|
+
const dy = ev.clientY - startMouse.current.y
|
|
103
|
+
// BL: mouse left (dx<0) = wider, BR: mouse right (dx>0) = wider
|
|
104
|
+
const newW = corner === 'bl'
|
|
105
|
+
? startW.current - dx
|
|
106
|
+
: startW.current + dx
|
|
107
|
+
const newH = startH.current + dy // down = taller for both
|
|
108
|
+
setWidth(Math.min(maxW, Math.max(minW, newW)))
|
|
109
|
+
setHeight(Math.min(maxH, Math.max(minH, newH)))
|
|
110
|
+
}
|
|
111
|
+
const onMouseUp = () => {
|
|
112
|
+
resizing.current = false
|
|
113
|
+
document.querySelector('.sk-pb')?.classList.remove('sk-pb--splitting')
|
|
114
|
+
document.removeEventListener('mousemove', onMouseMove)
|
|
115
|
+
document.removeEventListener('mouseup', onMouseUp)
|
|
116
|
+
}
|
|
117
|
+
document.addEventListener('mousemove', onMouseMove)
|
|
118
|
+
document.addEventListener('mouseup', onMouseUp)
|
|
119
|
+
}, [width, height, minW, maxW, minH, maxH])
|
|
120
|
+
|
|
121
|
+
const reset = useCallback(() => { setWidth(defaultWidth); setHeight(0) }, [defaultWidth])
|
|
122
|
+
const hasResized = width !== defaultWidth || height !== 0
|
|
123
|
+
|
|
124
|
+
return { width, height, onResizeStart, reset, hasResized }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Types
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
interface PageSection {
|
|
132
|
+
key: string
|
|
133
|
+
enabled: boolean
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface PageConfig {
|
|
137
|
+
label: string
|
|
138
|
+
sections: PageSection[]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type ViewportMode = 'compact' | 'desktop' | 'tablet' | 'mobile'
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Page Builder View
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
interface PageInfo {
|
|
148
|
+
path: string
|
|
149
|
+
pageKey: string
|
|
150
|
+
label: string
|
|
151
|
+
hasConfig: boolean
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface PageBuilderProps {
|
|
155
|
+
pageKey?: string
|
|
156
|
+
pages?: PageInfo[]
|
|
157
|
+
onPageChange?: (pageKey: string) => void
|
|
158
|
+
onExit: () => void
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function PageBuilder({ pageKey = 'index', pages = [], onPageChange, onExit }: PageBuilderProps) {
|
|
162
|
+
const config = useConfig()
|
|
163
|
+
const repository = useRepository()
|
|
164
|
+
const { toast } = useToast()
|
|
165
|
+
|
|
166
|
+
const [pageConfig, setPageConfig] = useState<PageConfig | null>(null)
|
|
167
|
+
// visibleSection: tracked by scroll (IntersectionObserver), shown in nav highlight
|
|
168
|
+
const [visibleSection, setVisibleSection] = useState<string | null>(null)
|
|
169
|
+
// editingSection: the section whose editor is open (does NOT change on scroll)
|
|
170
|
+
const [editingSection, setEditingSection] = useState<string | null>(null)
|
|
171
|
+
const [editorOpen, setEditorOpen] = useState(false)
|
|
172
|
+
const [viewport, setViewport] = useState<ViewportMode>('compact')
|
|
173
|
+
const [loading, setLoading] = useState(true)
|
|
174
|
+
const [previewKey, setPreviewKey] = useState(0)
|
|
175
|
+
const [configDirty, setConfigDirty] = useState(false)
|
|
176
|
+
const [savingConfig, setSavingConfig] = useState(false)
|
|
177
|
+
const [configSha, setConfigSha] = useState<string | undefined>()
|
|
178
|
+
|
|
179
|
+
// Collapse states – nav collapsed in compact/desktop (edit via inline preview buttons)
|
|
180
|
+
const [navCollapsed, setNavCollapsed] = useState(true)
|
|
181
|
+
const [editorCollapsed, setEditorCollapsed] = useState(false)
|
|
182
|
+
|
|
183
|
+
// Drag & Drop state
|
|
184
|
+
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
|
185
|
+
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
|
186
|
+
|
|
187
|
+
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
188
|
+
const navRef = useRef<HTMLDivElement>(null)
|
|
189
|
+
const scrollAfterLoadRef = useRef<string | null>(null)
|
|
190
|
+
// Cache unsaved draft values per section so switching sections preserves edits
|
|
191
|
+
const draftCacheRef = useRef<Record<string, Record<string, unknown>>>({})
|
|
192
|
+
|
|
193
|
+
// Draggable panels
|
|
194
|
+
const navDrag = useDraggable()
|
|
195
|
+
const editorDrag = useDraggable()
|
|
196
|
+
const editorResize = useResizable(380)
|
|
197
|
+
|
|
198
|
+
// Compact mode: draggable preview split (percentage of viewport width)
|
|
199
|
+
const [compactSplit, setCompactSplit] = useState(66)
|
|
200
|
+
const compactSplitDefault = 66
|
|
201
|
+
const pbRef = useRef<HTMLDivElement>(null)
|
|
202
|
+
|
|
203
|
+
const onSplitDragStart = useCallback((e: React.MouseEvent) => {
|
|
204
|
+
e.preventDefault()
|
|
205
|
+
const root = pbRef.current
|
|
206
|
+
if (!root) return
|
|
207
|
+
// Disable pointer events on iframe so mouse events aren't swallowed
|
|
208
|
+
root.classList.add('sk-pb--splitting')
|
|
209
|
+
|
|
210
|
+
const onMouseMove = (ev: MouseEvent) => {
|
|
211
|
+
const pct = (ev.clientX / window.innerWidth) * 100
|
|
212
|
+
const clamped = Math.min(95, Math.max(30, pct))
|
|
213
|
+
root.style.setProperty('--sk-split', `${clamped}`)
|
|
214
|
+
}
|
|
215
|
+
const onMouseUp = (ev: MouseEvent) => {
|
|
216
|
+
root.classList.remove('sk-pb--splitting')
|
|
217
|
+
const pct = (ev.clientX / window.innerWidth) * 100
|
|
218
|
+
setCompactSplit(Math.min(95, Math.max(30, pct)))
|
|
219
|
+
root.style.removeProperty('--sk-split')
|
|
220
|
+
document.removeEventListener('mousemove', onMouseMove)
|
|
221
|
+
document.removeEventListener('mouseup', onMouseUp)
|
|
222
|
+
}
|
|
223
|
+
document.addEventListener('mousemove', onMouseMove)
|
|
224
|
+
document.addEventListener('mouseup', onMouseUp)
|
|
225
|
+
}, [])
|
|
226
|
+
|
|
227
|
+
const resetAllPositions = useCallback(() => {
|
|
228
|
+
navDrag.reset()
|
|
229
|
+
editorDrag.reset()
|
|
230
|
+
editorResize.reset()
|
|
231
|
+
setCompactSplit(compactSplitDefault)
|
|
232
|
+
}, [navDrag.reset, editorDrag.reset, editorResize.reset])
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
const compactSplitMoved = compactSplit !== compactSplitDefault
|
|
236
|
+
const anyMoved = navDrag.hasMoved || editorDrag.hasMoved || editorResize.hasResized || compactSplitMoved
|
|
237
|
+
|
|
238
|
+
// Draft cleanup is handled by the Discard button in the editor header
|
|
239
|
+
// (no more blanket DELETE on mount)
|
|
240
|
+
|
|
241
|
+
// Load page config from content repo
|
|
242
|
+
const configKey = '_' + pageKey.replace(/\//g, '--')
|
|
243
|
+
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
loadPageConfig()
|
|
246
|
+
}, [pageKey])
|
|
247
|
+
|
|
248
|
+
async function loadPageConfig() {
|
|
249
|
+
try {
|
|
250
|
+
const result = await repository.getEntry('pages', configKey)
|
|
251
|
+
if (result.ok) {
|
|
252
|
+
setPageConfig(result.value.content as unknown as PageConfig)
|
|
253
|
+
setConfigSha(result.value.sha)
|
|
254
|
+
} else {
|
|
255
|
+
// No config yet — show empty sections (init in v0.5.0 will auto-detect)
|
|
256
|
+
setPageConfig({
|
|
257
|
+
label: pageKey === 'index' ? 'Startseite' : pageKey,
|
|
258
|
+
sections: [],
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
} catch (e) {
|
|
262
|
+
console.error('[PageBuilder] Failed to load page config:', e)
|
|
263
|
+
} finally {
|
|
264
|
+
setLoading(false)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Get section definition from config
|
|
269
|
+
function getSectionDef(key: string): SectionDefinition | null {
|
|
270
|
+
for (const product of Object.values(config.products)) {
|
|
271
|
+
if (product.sections[key]) return product.sections[key]
|
|
272
|
+
}
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Scroll preview to section (without changing editor)
|
|
277
|
+
const scrollToSection = useCallback((sectionKey: string) => {
|
|
278
|
+
const iframe = iframeRef.current
|
|
279
|
+
if (!iframe?.contentWindow) return
|
|
280
|
+
iframe.contentWindow.postMessage(
|
|
281
|
+
{ type: 'sk:scroll-to', section: sectionKey },
|
|
282
|
+
'*',
|
|
283
|
+
)
|
|
284
|
+
}, [])
|
|
285
|
+
|
|
286
|
+
// Send section order + visibility to iframe for client-side DOM reorder (no reload!)
|
|
287
|
+
const syncSectionsToPreview = useCallback((sections: PageSection[]) => {
|
|
288
|
+
const iframe = iframeRef.current
|
|
289
|
+
if (!iframe?.contentWindow) return
|
|
290
|
+
iframe.contentWindow.postMessage(
|
|
291
|
+
{ type: 'sk:update-sections', sections },
|
|
292
|
+
'*',
|
|
293
|
+
)
|
|
294
|
+
}, [])
|
|
295
|
+
|
|
296
|
+
// Keep a ref to current pageConfig so handleIframeLoad can access it without re-creating
|
|
297
|
+
const pageConfigRef = useRef(pageConfig)
|
|
298
|
+
pageConfigRef.current = pageConfig
|
|
299
|
+
|
|
300
|
+
// After iframe loads, scroll to the remembered section and sync section config
|
|
301
|
+
const handleIframeLoad = useCallback(() => {
|
|
302
|
+
setTimeout(() => {
|
|
303
|
+
if (scrollAfterLoadRef.current) {
|
|
304
|
+
scrollToSection(scrollAfterLoadRef.current!)
|
|
305
|
+
scrollAfterLoadRef.current = null
|
|
306
|
+
}
|
|
307
|
+
// Sync current section order + visibility to the iframe
|
|
308
|
+
if (pageConfigRef.current?.sections) {
|
|
309
|
+
syncSectionsToPreview(pageConfigRef.current.sections)
|
|
310
|
+
}
|
|
311
|
+
}, 200)
|
|
312
|
+
}, [scrollToSection, syncSectionsToPreview])
|
|
313
|
+
|
|
314
|
+
// Listen for scroll tracking from iframe
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
function handleMessage(event: MessageEvent) {
|
|
317
|
+
if (event.data?.type === 'sk:visible-section') {
|
|
318
|
+
setVisibleSection(event.data.section)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
window.addEventListener('message', handleMessage)
|
|
322
|
+
return () => window.removeEventListener('message', handleMessage)
|
|
323
|
+
}, [])
|
|
324
|
+
|
|
325
|
+
// Open section editor
|
|
326
|
+
const openEditor = useCallback((sectionKey: string) => {
|
|
327
|
+
setEditingSection(sectionKey)
|
|
328
|
+
setEditorOpen(true)
|
|
329
|
+
setEditorCollapsed(false)
|
|
330
|
+
scrollToSection(sectionKey)
|
|
331
|
+
}, [scrollToSection])
|
|
332
|
+
|
|
333
|
+
// Close editor
|
|
334
|
+
const closeEditor = useCallback(() => {
|
|
335
|
+
setEditorOpen(false)
|
|
336
|
+
setEditorCollapsed(false)
|
|
337
|
+
setEditingSection(null)
|
|
338
|
+
}, [])
|
|
339
|
+
|
|
340
|
+
// Reload iframe preserving scroll position (only for structural changes like reorder/toggle)
|
|
341
|
+
const reloadPreview = useCallback((scrollTo?: string) => {
|
|
342
|
+
scrollAfterLoadRef.current = scrollTo ?? visibleSection
|
|
343
|
+
setPreviewKey(k => k + 1)
|
|
344
|
+
}, [visibleSection])
|
|
345
|
+
|
|
346
|
+
// Check if a field change can be patched client-side (text only) or needs SSR reload.
|
|
347
|
+
// Recursively walks into arrays/objects to check sub-field types.
|
|
348
|
+
const needsSsrReload = useCallback((sectionKey: string, values: Record<string, unknown>) => {
|
|
349
|
+
const product = Object.values(config.products)[0]
|
|
350
|
+
const section = product?.sections[sectionKey]
|
|
351
|
+
if (!section) return true // unknown section → reload to be safe
|
|
352
|
+
|
|
353
|
+
const prev = draftCacheRef.current[sectionKey]
|
|
354
|
+
if (!prev) return false // first update → DOM patch is fine for initial text
|
|
355
|
+
|
|
356
|
+
// Check if changes in a set of fields are all text-patchable
|
|
357
|
+
function hasNonTextChanges(
|
|
358
|
+
prevObj: Record<string, unknown>,
|
|
359
|
+
nextObj: Record<string, unknown>,
|
|
360
|
+
fields: Record<string, { type: string; itemField?: any; fields?: any }>,
|
|
361
|
+
): boolean {
|
|
362
|
+
for (const [key, val] of Object.entries(nextObj)) {
|
|
363
|
+
if (JSON.stringify(prevObj[key]) === JSON.stringify(val)) continue
|
|
364
|
+
const fieldDef = fields[key]
|
|
365
|
+
if (!fieldDef) return true
|
|
366
|
+
if (fieldDef.type === 'text') continue
|
|
367
|
+
if (fieldDef.type === 'array' && fieldDef.itemField?.type === 'object' && fieldDef.itemField.fields) {
|
|
368
|
+
// Walk into array items and check sub-fields
|
|
369
|
+
const prevArr = (prevObj[key] as unknown[]) ?? []
|
|
370
|
+
const nextArr = (val as unknown[]) ?? []
|
|
371
|
+
if (prevArr.length !== nextArr.length) return true // structural change
|
|
372
|
+
for (let i = 0; i < nextArr.length; i++) {
|
|
373
|
+
const prevItem = (prevArr[i] ?? {}) as Record<string, unknown>
|
|
374
|
+
const nextItem = (nextArr[i] ?? {}) as Record<string, unknown>
|
|
375
|
+
if (hasNonTextChanges(prevItem, nextItem, fieldDef.itemField.fields)) return true
|
|
376
|
+
}
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
return true // non-text, non-array-of-objects
|
|
380
|
+
}
|
|
381
|
+
return false
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return hasNonTextChanges(prev as Record<string, unknown>, values, section.fields as any)
|
|
385
|
+
}, [config])
|
|
386
|
+
|
|
387
|
+
// Send draft values to iframe for client-side DOM patching,
|
|
388
|
+
// or store on server + reload if change requires SSR (icon, image, array, etc.)
|
|
389
|
+
const updateDraft = useCallback((sectionKey: string, values: Record<string, unknown>) => {
|
|
390
|
+
const requiresReload = needsSsrReload(sectionKey, values)
|
|
391
|
+
draftCacheRef.current[sectionKey] = values
|
|
392
|
+
|
|
393
|
+
if (requiresReload) {
|
|
394
|
+
// Store draft server-side so SSR preview can access it
|
|
395
|
+
fetch('/api/setzkasten/draft', {
|
|
396
|
+
method: 'POST',
|
|
397
|
+
headers: { 'Content-Type': 'application/json' },
|
|
398
|
+
body: JSON.stringify({ section: sectionKey, values }),
|
|
399
|
+
}).then(() => {
|
|
400
|
+
reloadPreview(sectionKey)
|
|
401
|
+
})
|
|
402
|
+
} else {
|
|
403
|
+
// Fast client-side DOM patching for text changes
|
|
404
|
+
const iframe = iframeRef.current
|
|
405
|
+
if (!iframe?.contentWindow) return
|
|
406
|
+
iframe.contentWindow.postMessage(
|
|
407
|
+
{ type: 'sk:draft-update', section: sectionKey, values },
|
|
408
|
+
'*',
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
}, [needsSsrReload, reloadPreview])
|
|
412
|
+
|
|
413
|
+
// Clear draft cache (client + server) for a section after successful save
|
|
414
|
+
const clearDraftCache = useCallback((sectionKey: string) => {
|
|
415
|
+
delete draftCacheRef.current[sectionKey]
|
|
416
|
+
// Also clear server-side draft so SSR preview uses saved content
|
|
417
|
+
fetch('/api/setzkasten/draft', {
|
|
418
|
+
method: 'DELETE',
|
|
419
|
+
headers: { 'Content-Type': 'application/json' },
|
|
420
|
+
body: JSON.stringify({ section: sectionKey }),
|
|
421
|
+
}).catch(() => {})
|
|
422
|
+
}, [])
|
|
423
|
+
|
|
424
|
+
// Discard: clear draft + reload preview to show original content
|
|
425
|
+
const discardDraft = useCallback((sectionKey: string) => {
|
|
426
|
+
clearDraftCache(sectionKey)
|
|
427
|
+
reloadPreview(sectionKey)
|
|
428
|
+
}, [clearDraftCache, reloadPreview])
|
|
429
|
+
|
|
430
|
+
// Toggle section enabled/disabled
|
|
431
|
+
const toggleSection = useCallback((sectionKey: string) => {
|
|
432
|
+
setPageConfig(prev => {
|
|
433
|
+
if (!prev) return prev
|
|
434
|
+
const updated = {
|
|
435
|
+
...prev,
|
|
436
|
+
sections: prev.sections.map(s =>
|
|
437
|
+
s.key === sectionKey ? { ...s, enabled: !s.enabled } : s
|
|
438
|
+
),
|
|
439
|
+
}
|
|
440
|
+
// Sync to preview via DOM manipulation (no reload)
|
|
441
|
+
syncSectionsToPreview(updated.sections)
|
|
442
|
+
return updated
|
|
443
|
+
})
|
|
444
|
+
setConfigDirty(true)
|
|
445
|
+
}, [syncSectionsToPreview])
|
|
446
|
+
|
|
447
|
+
// Drag & Drop handlers
|
|
448
|
+
const handleDragStart = useCallback((index: number) => {
|
|
449
|
+
setDragIndex(index)
|
|
450
|
+
}, [])
|
|
451
|
+
|
|
452
|
+
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
|
453
|
+
e.preventDefault()
|
|
454
|
+
setDragOverIndex(index)
|
|
455
|
+
}, [])
|
|
456
|
+
|
|
457
|
+
const handleDragEnd = useCallback(() => {
|
|
458
|
+
if (dragIndex !== null && dragOverIndex !== null && dragIndex !== dragOverIndex) {
|
|
459
|
+
setPageConfig(prev => {
|
|
460
|
+
if (!prev) return prev
|
|
461
|
+
const newSections = [...prev.sections]
|
|
462
|
+
const [moved] = newSections.splice(dragIndex, 1)
|
|
463
|
+
if (!moved) return prev
|
|
464
|
+
newSections.splice(dragOverIndex, 0, moved)
|
|
465
|
+
const updated = { ...prev, sections: newSections }
|
|
466
|
+
// Sync to preview via DOM manipulation (no reload)
|
|
467
|
+
syncSectionsToPreview(updated.sections)
|
|
468
|
+
return updated
|
|
469
|
+
})
|
|
470
|
+
setConfigDirty(true)
|
|
471
|
+
}
|
|
472
|
+
setDragIndex(null)
|
|
473
|
+
setDragOverIndex(null)
|
|
474
|
+
}, [dragIndex, dragOverIndex, syncSectionsToPreview])
|
|
475
|
+
|
|
476
|
+
// Save page config (order + enabled state)
|
|
477
|
+
const savePageConfig = useCallback(async () => {
|
|
478
|
+
if (!pageConfig || savingConfig) return
|
|
479
|
+
setSavingConfig(true)
|
|
480
|
+
try {
|
|
481
|
+
const result = await repository.saveEntry('pages', configKey, {
|
|
482
|
+
content: pageConfig as unknown as Record<string, unknown>,
|
|
483
|
+
sha: configSha,
|
|
484
|
+
})
|
|
485
|
+
if (result.ok) {
|
|
486
|
+
setConfigDirty(false)
|
|
487
|
+
setConfigSha(undefined)
|
|
488
|
+
toast('Seitenkonfiguration gespeichert', 'success')
|
|
489
|
+
} else {
|
|
490
|
+
toast(`Fehler: ${result.error?.message ?? 'Unbekannt'}`, 'error')
|
|
491
|
+
}
|
|
492
|
+
} catch (e) {
|
|
493
|
+
toast('Speichern fehlgeschlagen', 'error')
|
|
494
|
+
}
|
|
495
|
+
setSavingConfig(false)
|
|
496
|
+
}, [pageConfig, configSha, savingConfig, repository, toast])
|
|
497
|
+
|
|
498
|
+
const viewportWidths: Record<ViewportMode, string> = {
|
|
499
|
+
compact: `${compactSplit}vw`,
|
|
500
|
+
desktop: '100%',
|
|
501
|
+
tablet: '768px',
|
|
502
|
+
mobile: '375px',
|
|
503
|
+
}
|
|
504
|
+
const viewportWidth = viewportWidths[viewport]
|
|
505
|
+
const isCompact = viewport === 'compact'
|
|
506
|
+
|
|
507
|
+
if (loading) {
|
|
508
|
+
return (
|
|
509
|
+
<div className="sk-pb">
|
|
510
|
+
<div className="sk-pb__loading">Lade Page Builder...</div>
|
|
511
|
+
</div>
|
|
512
|
+
)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const sections = pageConfig?.sections ?? []
|
|
516
|
+
|
|
517
|
+
// Shared nav content (used in both compact sidebar and floating overlay)
|
|
518
|
+
const navContent = (
|
|
519
|
+
<>
|
|
520
|
+
<div className="sk-pb__nav-header">
|
|
521
|
+
{navCollapsed && visibleSection && (
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
className="sk-pb__inline-edit-btn"
|
|
525
|
+
onClick={() => openEditor(visibleSection)}
|
|
526
|
+
>
|
|
527
|
+
<Edit size={14} />
|
|
528
|
+
{getSectionDef(visibleSection)?.label ?? visibleSection}
|
|
529
|
+
</button>
|
|
530
|
+
)}
|
|
531
|
+
{!navCollapsed && <span className="sk-pb__nav-title">Sections</span>}
|
|
532
|
+
{!navCollapsed && (
|
|
533
|
+
<div className="sk-pb__nav-actions">
|
|
534
|
+
<button
|
|
535
|
+
type="button"
|
|
536
|
+
className={`sk-pb__viewport-btn${viewport === 'compact' ? ' sk-pb__viewport-btn--active' : ''}`}
|
|
537
|
+
onClick={() => { setViewport('compact'); setNavCollapsed(true) }}
|
|
538
|
+
title="Compact Desktop"
|
|
539
|
+
>
|
|
540
|
+
<AppWindow size={14} />
|
|
541
|
+
</button>
|
|
542
|
+
<button
|
|
543
|
+
type="button"
|
|
544
|
+
className={`sk-pb__viewport-btn${viewport === 'desktop' ? ' sk-pb__viewport-btn--active' : ''}`}
|
|
545
|
+
onClick={() => { setViewport('desktop'); setNavCollapsed(true) }}
|
|
546
|
+
title="Desktop"
|
|
547
|
+
>
|
|
548
|
+
<Monitor size={14} />
|
|
549
|
+
</button>
|
|
550
|
+
<button
|
|
551
|
+
type="button"
|
|
552
|
+
className={`sk-pb__viewport-btn${viewport === 'tablet' ? ' sk-pb__viewport-btn--active' : ''}`}
|
|
553
|
+
onClick={() => { setViewport('tablet'); setNavCollapsed(false) }}
|
|
554
|
+
title="Tablet"
|
|
555
|
+
>
|
|
556
|
+
<Tablet size={14} />
|
|
557
|
+
</button>
|
|
558
|
+
<button
|
|
559
|
+
type="button"
|
|
560
|
+
className={`sk-pb__viewport-btn${viewport === 'mobile' ? ' sk-pb__viewport-btn--active' : ''}`}
|
|
561
|
+
onClick={() => { setViewport('mobile'); setNavCollapsed(false) }}
|
|
562
|
+
title="Mobile"
|
|
563
|
+
>
|
|
564
|
+
<Smartphone size={14} />
|
|
565
|
+
</button>
|
|
566
|
+
</div>
|
|
567
|
+
)}
|
|
568
|
+
<div className="sk-pb__nav-header-right">
|
|
569
|
+
<button
|
|
570
|
+
type="button"
|
|
571
|
+
className="sk-pb__collapse-btn"
|
|
572
|
+
onClick={() => setNavCollapsed(c => !c)}
|
|
573
|
+
title={navCollapsed ? 'Navigation aufklappen' : 'Navigation einklappen'}
|
|
574
|
+
>
|
|
575
|
+
{navCollapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
|
576
|
+
</button>
|
|
577
|
+
<button
|
|
578
|
+
type="button"
|
|
579
|
+
className="sk-pb__exit-btn"
|
|
580
|
+
onClick={onExit}
|
|
581
|
+
title="Page Builder verlassen"
|
|
582
|
+
>
|
|
583
|
+
<X size={16} />
|
|
584
|
+
</button>
|
|
585
|
+
</div>
|
|
586
|
+
</div>
|
|
587
|
+
|
|
588
|
+
{!navCollapsed && (
|
|
589
|
+
<>
|
|
590
|
+
{pages.length > 1 && onPageChange && (
|
|
591
|
+
<div className="sk-pb__page-switcher">
|
|
592
|
+
<label className="sk-pb__page-switcher-label">Seite</label>
|
|
593
|
+
<select
|
|
594
|
+
className="sk-pb__page-select"
|
|
595
|
+
value={pageKey}
|
|
596
|
+
onChange={e => onPageChange(e.target.value)}
|
|
597
|
+
>
|
|
598
|
+
{pages.map(p => (
|
|
599
|
+
<option key={p.pageKey} value={p.pageKey}>{p.label}</option>
|
|
600
|
+
))}
|
|
601
|
+
</select>
|
|
602
|
+
</div>
|
|
603
|
+
)}
|
|
604
|
+
<div className="sk-pb__section-list">
|
|
605
|
+
{sections.map((section, index) => {
|
|
606
|
+
const def = getSectionDef(section.key)
|
|
607
|
+
const IconComp = def?.icon ? ICON_MAP[def.icon] : null
|
|
608
|
+
const isVisible = visibleSection === section.key
|
|
609
|
+
const isEditing = editingSection === section.key
|
|
610
|
+
const isDragging = dragIndex === index
|
|
611
|
+
const isDragOver = dragOverIndex === index
|
|
612
|
+
|
|
613
|
+
return (
|
|
614
|
+
<div
|
|
615
|
+
key={section.key}
|
|
616
|
+
className={`sk-pb__section-pill${isEditing ? ' sk-pb__section-pill--editing' : ''}${isVisible && !isEditing ? ' sk-pb__section-pill--visible' : ''}${!section.enabled ? ' sk-pb__section-pill--disabled' : ''}${isDragging ? ' sk-pb__section-pill--dragging' : ''}${isDragOver ? ' sk-pb__section-pill--dragover' : ''}`}
|
|
617
|
+
draggable
|
|
618
|
+
onDragStart={() => handleDragStart(index)}
|
|
619
|
+
onDragOver={(e) => handleDragOver(e, index)}
|
|
620
|
+
onDragEnd={handleDragEnd}
|
|
621
|
+
>
|
|
622
|
+
<span className="sk-pb__section-grip">
|
|
623
|
+
<GripVertical size={14} />
|
|
624
|
+
</span>
|
|
625
|
+
<button
|
|
626
|
+
type="button"
|
|
627
|
+
className="sk-pb__section-main"
|
|
628
|
+
onClick={() => openEditor(section.key)}
|
|
629
|
+
>
|
|
630
|
+
{IconComp && (
|
|
631
|
+
<span className="sk-pb__section-icon">
|
|
632
|
+
<IconComp size={16} />
|
|
633
|
+
</span>
|
|
634
|
+
)}
|
|
635
|
+
<span className="sk-pb__section-label">
|
|
636
|
+
{def?.label ?? section.key}
|
|
637
|
+
</span>
|
|
638
|
+
</button>
|
|
639
|
+
<button
|
|
640
|
+
type="button"
|
|
641
|
+
className="sk-pb__section-toggle"
|
|
642
|
+
onClick={(e) => { e.stopPropagation(); toggleSection(section.key) }}
|
|
643
|
+
title={section.enabled ? 'Section ausblenden' : 'Section einblenden'}
|
|
644
|
+
>
|
|
645
|
+
{section.enabled ? <Eye size={14} /> : <EyeOff size={14} />}
|
|
646
|
+
</button>
|
|
647
|
+
</div>
|
|
648
|
+
)
|
|
649
|
+
})}
|
|
650
|
+
</div>
|
|
651
|
+
|
|
652
|
+
{configDirty && (
|
|
653
|
+
<div className="sk-pb__nav-footer">
|
|
654
|
+
<button
|
|
655
|
+
type="button"
|
|
656
|
+
className="sk-pb__save-btn"
|
|
657
|
+
onClick={savePageConfig}
|
|
658
|
+
disabled={savingConfig}
|
|
659
|
+
>
|
|
660
|
+
<Save size={14} />
|
|
661
|
+
{savingConfig ? 'Speichert...' : 'Reihenfolge speichern'}
|
|
662
|
+
</button>
|
|
663
|
+
</div>
|
|
664
|
+
)}
|
|
665
|
+
</>
|
|
666
|
+
)}
|
|
667
|
+
</>
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
// Shared editor panel
|
|
671
|
+
const editorPanel = editorOpen && editingSection && !editorCollapsed && (
|
|
672
|
+
getSectionDef(editingSection) ? (
|
|
673
|
+
<SectionSlideOver
|
|
674
|
+
key={editingSection}
|
|
675
|
+
sectionKey={editingSection}
|
|
676
|
+
section={getSectionDef(editingSection)!}
|
|
677
|
+
cachedValues={draftCacheRef.current[editingSection]}
|
|
678
|
+
onClose={closeEditor}
|
|
679
|
+
onCollapse={() => setEditorCollapsed(true)}
|
|
680
|
+
onDraftUpdate={updateDraft}
|
|
681
|
+
onDiscard={discardDraft}
|
|
682
|
+
onSaved={clearDraftCache}
|
|
683
|
+
/>
|
|
684
|
+
) : (
|
|
685
|
+
<div className="sk-pb__slide-over">
|
|
686
|
+
<div className="sk-pb__slide-over-header">
|
|
687
|
+
<h3>{editingSection}</h3>
|
|
688
|
+
<div className="sk-pb__slide-over-actions">
|
|
689
|
+
<button type="button" className="sk-pb__collapse-btn" onClick={() => setEditorCollapsed(true)} title="Einklappen">
|
|
690
|
+
<PanelRightClose size={16} />
|
|
691
|
+
</button>
|
|
692
|
+
<button type="button" className="sk-pb__close-btn" onClick={closeEditor}>
|
|
693
|
+
<X size={18} />
|
|
694
|
+
</button>
|
|
695
|
+
</div>
|
|
696
|
+
</div>
|
|
697
|
+
<div className="sk-pb__slide-over-body">
|
|
698
|
+
<p className="sk-pb__slide-over-info">
|
|
699
|
+
Diese Section hat noch kein editierbares Schema.
|
|
700
|
+
</p>
|
|
701
|
+
</div>
|
|
702
|
+
</div>
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
// Editor float transform (drag only, no position offset needed with single-corner resize)
|
|
707
|
+
const editorFloatTransform = editorDrag.hasMoved
|
|
708
|
+
? { transform: `translate(${editorDrag.offset.x}px, ${editorDrag.offset.y}px)` }
|
|
709
|
+
: {}
|
|
710
|
+
|
|
711
|
+
return (
|
|
712
|
+
<div
|
|
713
|
+
ref={pbRef}
|
|
714
|
+
className={`sk-pb${isCompact ? ' sk-pb--compact' : ''}`}
|
|
715
|
+
style={isCompact ? { '--sk-split': `${compactSplit}` } as React.CSSProperties : undefined}
|
|
716
|
+
>
|
|
717
|
+
{/* Preview iframe (full background) */}
|
|
718
|
+
<div className="sk-pb__preview">
|
|
719
|
+
<div
|
|
720
|
+
className="sk-pb__iframe-container"
|
|
721
|
+
style={{
|
|
722
|
+
maxWidth: isCompact ? 'calc(var(--sk-split, 66) * 1vw)' : viewportWidth,
|
|
723
|
+
margin: viewport === 'desktop' ? undefined : isCompact ? '0' : '0 auto',
|
|
724
|
+
}}
|
|
725
|
+
>
|
|
726
|
+
<iframe
|
|
727
|
+
ref={iframeRef}
|
|
728
|
+
key={previewKey}
|
|
729
|
+
src={`/${pageKey === 'index' ? '' : pageKey}?_sk_preview=1&_t=${previewKey}`}
|
|
730
|
+
className="sk-pb__iframe"
|
|
731
|
+
title="Live Preview"
|
|
732
|
+
onLoad={handleIframeLoad}
|
|
733
|
+
/>
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
|
|
737
|
+
{/* Compact mode: draggable split divider */}
|
|
738
|
+
{isCompact && (
|
|
739
|
+
<div
|
|
740
|
+
className="sk-pb__split-divider"
|
|
741
|
+
style={{ left: 'calc(var(--sk-split, 66) * 1vw)' }}
|
|
742
|
+
onMouseDown={onSplitDragStart}
|
|
743
|
+
/>
|
|
744
|
+
)}
|
|
745
|
+
|
|
746
|
+
{/* Floating nav (all modes) */}
|
|
747
|
+
<div
|
|
748
|
+
ref={navRef}
|
|
749
|
+
className={`sk-pb__nav${navCollapsed ? ' sk-pb__nav--collapsed' : ''}`}
|
|
750
|
+
style={navDrag.style}
|
|
751
|
+
onMouseDown={navDrag.onMouseDown}
|
|
752
|
+
>
|
|
753
|
+
{navContent}
|
|
754
|
+
</div>
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
{/* Floating editor (all modes) */}
|
|
758
|
+
{editorPanel && (
|
|
759
|
+
<div
|
|
760
|
+
className="sk-pb__editor-float"
|
|
761
|
+
style={{
|
|
762
|
+
...(!isCompact || editorResize.hasResized ? { width: editorResize.width } : {}),
|
|
763
|
+
...(editorResize.height > 0 ? { height: editorResize.height } : {}),
|
|
764
|
+
...editorFloatTransform,
|
|
765
|
+
}}
|
|
766
|
+
onMouseDown={editorDrag.onMouseDown}
|
|
767
|
+
>
|
|
768
|
+
{/* Resize corner: always BL (panel always right-anchored) */}
|
|
769
|
+
<div
|
|
770
|
+
className="sk-pb__resize-corner sk-pb__resize-corner--bl"
|
|
771
|
+
onMouseDown={(e) => editorResize.onResizeStart(e, 'bl')}
|
|
772
|
+
/>
|
|
773
|
+
{editorPanel}
|
|
774
|
+
</div>
|
|
775
|
+
)}
|
|
776
|
+
|
|
777
|
+
{/* Collapsed editor expand button */}
|
|
778
|
+
{editorOpen && editingSection && editorCollapsed && (
|
|
779
|
+
<button
|
|
780
|
+
type="button"
|
|
781
|
+
className="sk-pb__editor-expand-btn"
|
|
782
|
+
onClick={() => setEditorCollapsed(false)}
|
|
783
|
+
title="Editor aufklappen"
|
|
784
|
+
>
|
|
785
|
+
<PanelRightOpen size={16} />
|
|
786
|
+
</button>
|
|
787
|
+
)}
|
|
788
|
+
|
|
789
|
+
{/* Reset positions button (shown when any panel was moved) */}
|
|
790
|
+
{anyMoved && (
|
|
791
|
+
<button
|
|
792
|
+
type="button"
|
|
793
|
+
className="sk-pb__reset-pos-btn"
|
|
794
|
+
onClick={resetAllPositions}
|
|
795
|
+
title="Panel-Positionen zurücksetzen"
|
|
796
|
+
>
|
|
797
|
+
<RotateCcw size={14} />
|
|
798
|
+
</button>
|
|
799
|
+
)}
|
|
800
|
+
</div>
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ---------------------------------------------------------------------------
|
|
805
|
+
// Section Slide-Over Editor
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
|
|
808
|
+
interface SectionSlideOverProps {
|
|
809
|
+
sectionKey: string
|
|
810
|
+
section: SectionDefinition
|
|
811
|
+
cachedValues?: Record<string, unknown>
|
|
812
|
+
onClose: () => void
|
|
813
|
+
onCollapse: () => void
|
|
814
|
+
onDraftUpdate: (sectionKey: string, values: Record<string, unknown>) => void
|
|
815
|
+
onDiscard: (sectionKey: string) => void
|
|
816
|
+
onSaved: (sectionKey: string) => void
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function SectionSlideOver({ sectionKey, section, cachedValues, onClose, onCollapse, onDraftUpdate, onDiscard, onSaved }: SectionSlideOverProps) {
|
|
820
|
+
const repository = useRepository()
|
|
821
|
+
const { toast } = useToast()
|
|
822
|
+
const [initialValues, setInitialValues] = useState<Record<string, unknown>>({})
|
|
823
|
+
const [loading, setLoading] = useState(true)
|
|
824
|
+
const [saving, setSaving] = useState(false)
|
|
825
|
+
const [sha, setSha] = useState<string | undefined>()
|
|
826
|
+
const [conflict, setConflict] = useState<{ local: Record<string, unknown>; remote: Record<string, unknown>; remoteSha: string | undefined } | null>(null)
|
|
827
|
+
const storeRef = useRef<StoreApi<FormStore> | null>(null)
|
|
828
|
+
const [formStore, setFormStore] = useState<StoreApi<FormStore> | null>(null)
|
|
829
|
+
const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
830
|
+
const saveRef = useRef<(() => void) | null>(null)
|
|
831
|
+
const undoRef = useRef<(() => void) | null>(null)
|
|
832
|
+
const redoRef = useRef<(() => void) | null>(null)
|
|
833
|
+
|
|
834
|
+
// Subscribe to store for dirty/undo/redo state (triggers re-render)
|
|
835
|
+
const storeSnapshotRef = useRef({ dirty: false, canUndo: false, canRedo: false })
|
|
836
|
+
const storeState = useSyncExternalStore(
|
|
837
|
+
useCallback((cb) => formStore?.subscribe(cb) ?? (() => {}), [formStore]),
|
|
838
|
+
useCallback(() => {
|
|
839
|
+
if (!formStore) return storeSnapshotRef.current
|
|
840
|
+
const s = formStore.getState()
|
|
841
|
+
const dirty = s.isDirty()
|
|
842
|
+
const canUndo = s.canUndo()
|
|
843
|
+
const canRedo = s.canRedo()
|
|
844
|
+
const prev = storeSnapshotRef.current
|
|
845
|
+
if (prev.dirty !== dirty || prev.canUndo !== canUndo || prev.canRedo !== canRedo) {
|
|
846
|
+
storeSnapshotRef.current = { dirty, canUndo, canRedo }
|
|
847
|
+
}
|
|
848
|
+
return storeSnapshotRef.current
|
|
849
|
+
}, [formStore]),
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
// Load section data (use cached draft values if available)
|
|
853
|
+
useEffect(() => {
|
|
854
|
+
setLoading(true)
|
|
855
|
+
repository.getEntry('_sections', sectionKey).then(result => {
|
|
856
|
+
if (result.ok) {
|
|
857
|
+
// Always use saved content as baseline (for dirty detection + discard)
|
|
858
|
+
setInitialValues(result.value.content)
|
|
859
|
+
setSha(result.value.sha)
|
|
860
|
+
}
|
|
861
|
+
setLoading(false)
|
|
862
|
+
})
|
|
863
|
+
}, [sectionKey, repository])
|
|
864
|
+
|
|
865
|
+
// Poll for store availability
|
|
866
|
+
useEffect(() => {
|
|
867
|
+
if (formStore) return
|
|
868
|
+
const check = setInterval(() => {
|
|
869
|
+
if (storeRef.current && !formStore) {
|
|
870
|
+
setFormStore(storeRef.current)
|
|
871
|
+
clearInterval(check)
|
|
872
|
+
}
|
|
873
|
+
}, 50)
|
|
874
|
+
return () => clearInterval(check)
|
|
875
|
+
}, [formStore, loading])
|
|
876
|
+
|
|
877
|
+
// Auto-update draft on field changes (debounced 500ms)
|
|
878
|
+
useEffect(() => {
|
|
879
|
+
if (!formStore) return
|
|
880
|
+
|
|
881
|
+
const unsub = formStore.subscribe((state) => {
|
|
882
|
+
if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
|
|
883
|
+
draftTimerRef.current = setTimeout(() => {
|
|
884
|
+
onDraftUpdate(sectionKey, state.values)
|
|
885
|
+
}, 500)
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
return () => {
|
|
889
|
+
unsub()
|
|
890
|
+
if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
|
|
891
|
+
}
|
|
892
|
+
}, [formStore, sectionKey, onDraftUpdate])
|
|
893
|
+
|
|
894
|
+
// Save handler
|
|
895
|
+
const handleSave = useCallback(async (values: Record<string, unknown>) => {
|
|
896
|
+
setSaving(true)
|
|
897
|
+
const result = await repository.saveEntry('_sections', sectionKey, {
|
|
898
|
+
content: values,
|
|
899
|
+
sha,
|
|
900
|
+
})
|
|
901
|
+
if (result.ok) {
|
|
902
|
+
setInitialValues(values)
|
|
903
|
+
setSha(result.value.sha)
|
|
904
|
+
setConflict(null)
|
|
905
|
+
onSaved(sectionKey)
|
|
906
|
+
toast('Gespeichert – Änderungen sind in 1–2 Minuten live.', 'success')
|
|
907
|
+
} else {
|
|
908
|
+
if (result.error?.type === 'conflict') {
|
|
909
|
+
// Fetch remote content for visual diff
|
|
910
|
+
const remoteResult = await repository.getEntry('_sections', sectionKey)
|
|
911
|
+
if (remoteResult.ok) {
|
|
912
|
+
setConflict({
|
|
913
|
+
local: values,
|
|
914
|
+
remote: remoteResult.value.content,
|
|
915
|
+
remoteSha: remoteResult.value.sha,
|
|
916
|
+
})
|
|
917
|
+
} else {
|
|
918
|
+
toast('Konflikt: Jemand hat gleichzeitig bearbeitet. Seite neu laden.', 'error')
|
|
919
|
+
}
|
|
920
|
+
} else {
|
|
921
|
+
toast(`Fehler: ${result.error?.message ?? 'Unbekannt'}`, 'error')
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
setSaving(false)
|
|
925
|
+
}, [repository, sectionKey, sha, toast])
|
|
926
|
+
|
|
927
|
+
// Save handler for header button + Cmd+S
|
|
928
|
+
saveRef.current = () => {
|
|
929
|
+
const store = storeRef.current
|
|
930
|
+
if (!store || saving) return
|
|
931
|
+
const { values } = store.getState()
|
|
932
|
+
handleSave(values)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Undo/Redo refs for keyboard shortcuts
|
|
936
|
+
undoRef.current = () => {
|
|
937
|
+
const store = storeRef.current
|
|
938
|
+
if (!store) return
|
|
939
|
+
store.getState().undo()
|
|
940
|
+
}
|
|
941
|
+
redoRef.current = () => {
|
|
942
|
+
const store = storeRef.current
|
|
943
|
+
if (!store) return
|
|
944
|
+
store.getState().redo()
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Conflict resolution: use local values (retry save with fresh SHA)
|
|
948
|
+
const resolveConflictLocal = useCallback(async () => {
|
|
949
|
+
if (!conflict) return
|
|
950
|
+
setSaving(true)
|
|
951
|
+
const result = await repository.saveEntry('_sections', sectionKey, {
|
|
952
|
+
content: conflict.local,
|
|
953
|
+
sha: conflict.remoteSha,
|
|
954
|
+
})
|
|
955
|
+
if (result.ok) {
|
|
956
|
+
setInitialValues(conflict.local)
|
|
957
|
+
setSha(result.value.sha)
|
|
958
|
+
setConflict(null)
|
|
959
|
+
onSaved(sectionKey)
|
|
960
|
+
toast('Gespeichert (deine Version übernommen).', 'success')
|
|
961
|
+
} else {
|
|
962
|
+
toast('Speichern fehlgeschlagen. Bitte erneut versuchen.', 'error')
|
|
963
|
+
}
|
|
964
|
+
setSaving(false)
|
|
965
|
+
}, [conflict, repository, sectionKey, onSaved, toast])
|
|
966
|
+
|
|
967
|
+
// Conflict resolution: use remote values (discard local changes)
|
|
968
|
+
const resolveConflictRemote = useCallback(() => {
|
|
969
|
+
if (!conflict) return
|
|
970
|
+
const store = storeRef.current
|
|
971
|
+
if (store) {
|
|
972
|
+
store.getState().init(section.fields, conflict.remote)
|
|
973
|
+
}
|
|
974
|
+
setInitialValues(conflict.remote)
|
|
975
|
+
setSha(conflict.remoteSha)
|
|
976
|
+
setConflict(null)
|
|
977
|
+
onDiscard(sectionKey)
|
|
978
|
+
toast('Remote-Version übernommen.', 'success')
|
|
979
|
+
}, [conflict, section.fields, sectionKey, onDiscard, toast])
|
|
980
|
+
|
|
981
|
+
// Discard handler: reset form + clear server draft + reload preview
|
|
982
|
+
const handleDiscard = useCallback(() => {
|
|
983
|
+
const store = storeRef.current
|
|
984
|
+
if (!store) return
|
|
985
|
+
store.getState().resetToInitial()
|
|
986
|
+
onDiscard(sectionKey)
|
|
987
|
+
}, [sectionKey, onDiscard])
|
|
988
|
+
|
|
989
|
+
// Keyboard shortcuts: Cmd+S, Cmd+Z, Cmd+Shift+Z, Escape
|
|
990
|
+
useEffect(() => {
|
|
991
|
+
const handler = (e: KeyboardEvent) => {
|
|
992
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
993
|
+
e.preventDefault()
|
|
994
|
+
saveRef.current?.()
|
|
995
|
+
}
|
|
996
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
|
|
997
|
+
e.preventDefault()
|
|
998
|
+
undoRef.current?.()
|
|
999
|
+
}
|
|
1000
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && e.shiftKey) {
|
|
1001
|
+
e.preventDefault()
|
|
1002
|
+
redoRef.current?.()
|
|
1003
|
+
}
|
|
1004
|
+
if (e.key === 'Escape') {
|
|
1005
|
+
onClose()
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
document.addEventListener('keydown', handler)
|
|
1009
|
+
return () => document.removeEventListener('keydown', handler)
|
|
1010
|
+
}, [onClose])
|
|
1011
|
+
|
|
1012
|
+
return (
|
|
1013
|
+
<div className="sk-pb__slide-over">
|
|
1014
|
+
<div className="sk-pb__slide-over-header">
|
|
1015
|
+
<div className="sk-pb__slide-over-title">
|
|
1016
|
+
{storeState.dirty && (
|
|
1017
|
+
<span className="sk-pb__draft-indicator" title="Ungespeicherte Änderungen">
|
|
1018
|
+
<span className="sk-pb__draft-dot" />
|
|
1019
|
+
Entwurf
|
|
1020
|
+
</span>
|
|
1021
|
+
)}
|
|
1022
|
+
</div>
|
|
1023
|
+
<div className="sk-pb__slide-over-actions">
|
|
1024
|
+
{storeState.dirty && (
|
|
1025
|
+
<button
|
|
1026
|
+
type="button"
|
|
1027
|
+
className="sk-pb__discard-btn"
|
|
1028
|
+
onClick={handleDiscard}
|
|
1029
|
+
title="Änderungen verwerfen"
|
|
1030
|
+
>
|
|
1031
|
+
<Trash2 size={14} />
|
|
1032
|
+
</button>
|
|
1033
|
+
)}
|
|
1034
|
+
<button
|
|
1035
|
+
type="button"
|
|
1036
|
+
className="sk-pb__undo-btn"
|
|
1037
|
+
onClick={() => undoRef.current?.()}
|
|
1038
|
+
disabled={!storeState.canUndo}
|
|
1039
|
+
title="Rückgängig (Cmd+Z)"
|
|
1040
|
+
>
|
|
1041
|
+
<Undo2 size={14} />
|
|
1042
|
+
</button>
|
|
1043
|
+
<button
|
|
1044
|
+
type="button"
|
|
1045
|
+
className="sk-pb__undo-btn"
|
|
1046
|
+
onClick={() => redoRef.current?.()}
|
|
1047
|
+
disabled={!storeState.canRedo}
|
|
1048
|
+
title="Wiederholen (Cmd+Shift+Z)"
|
|
1049
|
+
>
|
|
1050
|
+
<Redo2 size={14} />
|
|
1051
|
+
</button>
|
|
1052
|
+
<button
|
|
1053
|
+
type="button"
|
|
1054
|
+
className="sk-pb__header-save-btn"
|
|
1055
|
+
onClick={() => saveRef.current?.()}
|
|
1056
|
+
disabled={saving || !storeState.dirty}
|
|
1057
|
+
title="Speichern (Cmd+S)"
|
|
1058
|
+
>
|
|
1059
|
+
<Save size={14} />
|
|
1060
|
+
</button>
|
|
1061
|
+
<button type="button" className="sk-pb__collapse-btn" onClick={onCollapse} title="Einklappen">
|
|
1062
|
+
<PanelRightClose size={16} />
|
|
1063
|
+
</button>
|
|
1064
|
+
<button type="button" className="sk-pb__close-btn" onClick={onClose}>
|
|
1065
|
+
<X size={18} />
|
|
1066
|
+
</button>
|
|
1067
|
+
</div>
|
|
1068
|
+
</div>
|
|
1069
|
+
<div className="sk-pb__slide-over-body">
|
|
1070
|
+
{conflict && (
|
|
1071
|
+
<div className="sk-pb__conflict">
|
|
1072
|
+
<div className="sk-pb__conflict-header">
|
|
1073
|
+
<Shield size={16} />
|
|
1074
|
+
<strong>Konflikt</strong> — Die Datei wurde extern geändert.
|
|
1075
|
+
</div>
|
|
1076
|
+
<div className="sk-pb__conflict-diff">
|
|
1077
|
+
{Object.keys({ ...conflict.local, ...conflict.remote })
|
|
1078
|
+
.filter(key => JSON.stringify(conflict.local[key]) !== JSON.stringify(conflict.remote[key]))
|
|
1079
|
+
.map(key => {
|
|
1080
|
+
const fieldDef = section.fields[key]
|
|
1081
|
+
const label = fieldDef?.label ?? key
|
|
1082
|
+
const localVal = JSON.stringify(conflict.local[key], null, 2) ?? '—'
|
|
1083
|
+
const remoteVal = JSON.stringify(conflict.remote[key], null, 2) ?? '—'
|
|
1084
|
+
return (
|
|
1085
|
+
<div key={key} className="sk-pb__conflict-field">
|
|
1086
|
+
<div className="sk-pb__conflict-label">{label}</div>
|
|
1087
|
+
<div className="sk-pb__conflict-values">
|
|
1088
|
+
<div className="sk-pb__conflict-local">
|
|
1089
|
+
<span className="sk-pb__conflict-tag">Deine Version</span>
|
|
1090
|
+
<pre>{localVal}</pre>
|
|
1091
|
+
</div>
|
|
1092
|
+
<div className="sk-pb__conflict-remote">
|
|
1093
|
+
<span className="sk-pb__conflict-tag">Remote</span>
|
|
1094
|
+
<pre>{remoteVal}</pre>
|
|
1095
|
+
</div>
|
|
1096
|
+
</div>
|
|
1097
|
+
</div>
|
|
1098
|
+
)
|
|
1099
|
+
})}
|
|
1100
|
+
</div>
|
|
1101
|
+
<div className="sk-pb__conflict-actions">
|
|
1102
|
+
<button
|
|
1103
|
+
type="button"
|
|
1104
|
+
className="sk-button sk-button--primary"
|
|
1105
|
+
onClick={resolveConflictLocal}
|
|
1106
|
+
disabled={saving}
|
|
1107
|
+
>
|
|
1108
|
+
Meine Version übernehmen
|
|
1109
|
+
</button>
|
|
1110
|
+
<button
|
|
1111
|
+
type="button"
|
|
1112
|
+
className="sk-button"
|
|
1113
|
+
onClick={resolveConflictRemote}
|
|
1114
|
+
>
|
|
1115
|
+
Remote übernehmen
|
|
1116
|
+
</button>
|
|
1117
|
+
</div>
|
|
1118
|
+
</div>
|
|
1119
|
+
)}
|
|
1120
|
+
{loading ? (
|
|
1121
|
+
<p className="sk-pb__slide-over-loading">Lade...</p>
|
|
1122
|
+
) : (
|
|
1123
|
+
<EntryForm
|
|
1124
|
+
schema={section.fields}
|
|
1125
|
+
initialValues={initialValues}
|
|
1126
|
+
draftValues={cachedValues}
|
|
1127
|
+
onSave={handleSave}
|
|
1128
|
+
storeRef={storeRef}
|
|
1129
|
+
/>
|
|
1130
|
+
)}
|
|
1131
|
+
</div>
|
|
1132
|
+
</div>
|
|
1133
|
+
)
|
|
1134
|
+
}
|