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