@acms-subir/core 0.0.1

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.
Files changed (107) hide show
  1. package/cli.mjs +75 -0
  2. package/integration.mjs +286 -0
  3. package/package.json +89 -0
  4. package/src/actions/index.ts +502 -0
  5. package/src/components/DesignManager.tsx +542 -0
  6. package/src/components/LinksManager.tsx +694 -0
  7. package/src/components/Modal.astro +65 -0
  8. package/src/components/NavManager.tsx +168 -0
  9. package/src/components/WidgetRenderer.astro +84 -0
  10. package/src/components/builder/PageBuilder.tsx +575 -0
  11. package/src/components/editor/ImagePicker.tsx +215 -0
  12. package/src/components/editor/PostEditor.tsx +1011 -0
  13. package/src/components/editor/PreviewOverlay.tsx +77 -0
  14. package/src/components/editor/extensions/calendar.tsx +230 -0
  15. package/src/components/editor/extensions/cards.tsx +488 -0
  16. package/src/components/editor/extensions/codeBlock.tsx +70 -0
  17. package/src/components/editor/extensions/infoCards.tsx +270 -0
  18. package/src/components/editor/extensions/mediaCards.tsx +376 -0
  19. package/src/components/editor/extensions/postList.tsx +130 -0
  20. package/src/components/editor/extensions/slash.tsx +250 -0
  21. package/src/components/editor/extensions/tableTyping.ts +63 -0
  22. package/src/components/editor/imaging.ts +146 -0
  23. package/src/components/media/CropModal.tsx +229 -0
  24. package/src/components/media/MediaLibrary.tsx +295 -0
  25. package/src/layouts/Admin.astro +119 -0
  26. package/src/layouts/Site.astro +218 -0
  27. package/src/lib/auth.ts +94 -0
  28. package/src/lib/backup/unzip.ts +38 -0
  29. package/src/lib/backup/zip.ts +105 -0
  30. package/src/lib/blocksToHtml.ts +68 -0
  31. package/src/lib/brand.ts +26 -0
  32. package/src/lib/calendar/ics.ts +402 -0
  33. package/src/lib/calendar/render.ts +275 -0
  34. package/src/lib/calendar/src.ts +68 -0
  35. package/src/lib/content.ts +53 -0
  36. package/src/lib/dates.ts +34 -0
  37. package/src/lib/dynamicBlocks.ts +115 -0
  38. package/src/lib/env.ts +6 -0
  39. package/src/lib/highlightCode.ts +44 -0
  40. package/src/lib/i18n.ts +40 -0
  41. package/src/lib/markdown.ts +7 -0
  42. package/src/lib/media/store.ts +95 -0
  43. package/src/lib/net.ts +51 -0
  44. package/src/lib/preview.ts +28 -0
  45. package/src/lib/repo/d1.ts +44 -0
  46. package/src/lib/repo/index.ts +23 -0
  47. package/src/lib/repo/migrations.ts +231 -0
  48. package/src/lib/repo/search.ts +14 -0
  49. package/src/lib/repo/sql.ts +721 -0
  50. package/src/lib/repo/sqlite.ts +43 -0
  51. package/src/lib/repo/types.ts +132 -0
  52. package/src/lib/schema.ts +286 -0
  53. package/src/lib/seo.ts +37 -0
  54. package/src/lib/theme.ts +206 -0
  55. package/src/lib/widgets.ts +108 -0
  56. package/src/middleware.ts +109 -0
  57. package/src/pages/[...slug].astro +43 -0
  58. package/src/pages/admin/c/[collection]/[id].astro +56 -0
  59. package/src/pages/admin/c/[collection]/index.astro +163 -0
  60. package/src/pages/admin/design.astro +42 -0
  61. package/src/pages/admin/index.astro +145 -0
  62. package/src/pages/admin/links.astro +21 -0
  63. package/src/pages/admin/login.astro +83 -0
  64. package/src/pages/admin/logout.ts +7 -0
  65. package/src/pages/admin/media.astro +13 -0
  66. package/src/pages/admin/navigation.astro +17 -0
  67. package/src/pages/admin/pages/[id].astro +49 -0
  68. package/src/pages/admin/pages/index.astro +100 -0
  69. package/src/pages/admin/posts/[id].astro +6 -0
  70. package/src/pages/admin/posts/index.astro +7 -0
  71. package/src/pages/admin/settings.astro +512 -0
  72. package/src/pages/admin/tags.astro +101 -0
  73. package/src/pages/admin/users.astro +149 -0
  74. package/src/pages/api/backup.ts +63 -0
  75. package/src/pages/api/drafts.ts +106 -0
  76. package/src/pages/api/media/upload.ts +89 -0
  77. package/src/pages/api/restore.ts +102 -0
  78. package/src/pages/author/[slug].astro +66 -0
  79. package/src/pages/blog/[slug].astro +240 -0
  80. package/src/pages/blog/index.astro +51 -0
  81. package/src/pages/blog/tag/[slug].astro +83 -0
  82. package/src/pages/embed/[pageId]/[blockId].ts +45 -0
  83. package/src/pages/go/[id].ts +10 -0
  84. package/src/pages/index.astro +52 -0
  85. package/src/pages/links.astro +110 -0
  86. package/src/pages/llms.txt.ts +47 -0
  87. package/src/pages/media/[...key].ts +18 -0
  88. package/src/pages/preview/[type]/[id].astro +52 -0
  89. package/src/pages/robots.txt.ts +28 -0
  90. package/src/pages/rss.xml.ts +58 -0
  91. package/src/pages/search.astro +83 -0
  92. package/src/pages/sitemap.xml.ts +55 -0
  93. package/src/styles/admin.css +652 -0
  94. package/src/styles/builder.css +121 -0
  95. package/src/styles/cards.css +176 -0
  96. package/src/styles/editor.css +596 -0
  97. package/src/styles/site.css +427 -0
  98. package/src/themes/editorial/templates/home.astro +127 -0
  99. package/src/themes/editorial/templates/post-minimal.astro +50 -0
  100. package/src/themes/editorial/theme.css +108 -0
  101. package/src/themes/editorial/theme.json +20 -0
  102. package/src/themes/field/theme.json +17 -0
  103. package/src/themes/ink/theme.json +17 -0
  104. package/src/themes/night/theme.css +7 -0
  105. package/src/themes/night/theme.json +17 -0
  106. package/src/virtual.d.ts +25 -0
  107. package/src/worker.ts +28 -0
@@ -0,0 +1,575 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ DndContext, DragOverlay, PointerSensor, KeyboardSensor, useSensor, useSensors,
4
+ useDraggable, useDroppable, closestCenter, type DragStartEvent, type DragEndEvent,
5
+ } from '@dnd-kit/core';
6
+ import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
7
+ import { CSS } from '@dnd-kit/utilities';
8
+ import { marked } from 'marked';
9
+ import { actions } from 'astro:actions';
10
+ import type { Page, Status, WidgetInstance, WidgetType } from '../../lib/schema';
11
+ import { parseConfig, widgetTypes } from '../../lib/schema';
12
+ import { widgetMeta, fieldSpecs, type FieldSpec } from '../../lib/widgets';
13
+ import { ImagePicker, type PickedImage } from '../editor/ImagePicker';
14
+ import { PreviewOverlay } from '../editor/PreviewOverlay';
15
+
16
+ // The three-pane widget builder: palette → canvas → inspector.
17
+ // One document model, drag to add/reorder, autosaved as a whole.
18
+
19
+ type SaveState = 'saved' | 'dirty' | 'saving' | 'error';
20
+
21
+ interface Props {
22
+ page: Page;
23
+ previewPath: string;
24
+ sharePath: string;
25
+ unsplashEnabled: boolean;
26
+ }
27
+
28
+ interface Meta {
29
+ title: string;
30
+ slug: string;
31
+ status: Status;
32
+ isHome: boolean;
33
+ seoTitle: string;
34
+ seoDesc: string;
35
+ }
36
+
37
+ const DEVICES = [
38
+ { key: 'desktop', label: 'Desktop', width: '100%' },
39
+ { key: 'tablet', label: 'Tablet', width: '768px' },
40
+ { key: 'mobile', label: 'Mobile', width: '390px' },
41
+ ] as const;
42
+
43
+ const uuid = () => crypto.randomUUID();
44
+
45
+ export default function PageBuilder({ page, previewPath, sharePath, unsplashEnabled }: Props) {
46
+ const [blocks, setBlocks] = useState<WidgetInstance[]>(
47
+ page.blocks.map((b) => ({ ...b, config: parseConfig(b.type as WidgetType, b.config) as Record<string, unknown> }))
48
+ );
49
+ const [meta, setMeta] = useState<Meta>({
50
+ title: page.title, slug: page.slug, status: page.status,
51
+ isHome: page.isHome, seoTitle: page.seoTitle, seoDesc: page.seoDesc,
52
+ });
53
+ const [selectedId, setSelectedId] = useState<string | null>(null);
54
+ const [saveState, setSaveState] = useState<SaveState>('saved');
55
+ const [device, setDevice] = useState<(typeof DEVICES)[number]>(DEVICES[0]);
56
+ const [dragging, setDragging] = useState<{ type?: WidgetType; block?: WidgetInstance } | null>(null);
57
+ const [previewOpen, setPreviewOpen] = useState(false);
58
+ const [pickerFor, setPickerFor] = useState<{ blockId: string; key: string } | null>(null);
59
+
60
+ const blocksRef = useRef(blocks);
61
+ const metaRef = useRef(meta);
62
+ const saveStateRef = useRef<SaveState>('saved');
63
+ const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
64
+ const undoStack = useRef<WidgetInstance[][]>([]);
65
+ const redoStack = useRef<WidgetInstance[][]>([]);
66
+ const lastEdit = useRef<{ token: string; at: number }>({ token: '', at: 0 });
67
+
68
+ const setSave = (s: SaveState) => { saveStateRef.current = s; setSaveState(s); };
69
+
70
+ const save = useCallback(async () => {
71
+ if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; }
72
+ setSave('saving');
73
+ const m = metaRef.current;
74
+ const r = await actions.pages.save({
75
+ id: page.id,
76
+ title: m.title, slug: m.slug, status: m.status, isHome: m.isHome,
77
+ seoTitle: m.seoTitle, seoDesc: m.seoDesc,
78
+ blocks: blocksRef.current,
79
+ });
80
+ if (r.error) {
81
+ setSave('error');
82
+ } else {
83
+ metaRef.current = { ...metaRef.current, slug: r.data.page.slug };
84
+ setMeta((prev) => ({ ...prev, slug: r.data.page.slug }));
85
+ setSave(saveStateRef.current === 'saving' ? 'saved' : saveStateRef.current);
86
+ }
87
+ }, [page.id]);
88
+
89
+ const queueSave = useCallback(() => {
90
+ setSave('dirty');
91
+ if (saveTimer.current) clearTimeout(saveTimer.current);
92
+ saveTimer.current = setTimeout(() => save(), 1200);
93
+ }, [save]);
94
+
95
+ // history: coalesce rapid edits to the same field into one undo step
96
+ const commitBlocks = useCallback((next: WidgetInstance[], editToken = '') => {
97
+ const now = Date.now();
98
+ const coalesce = editToken && editToken === lastEdit.current.token && now - lastEdit.current.at < 900;
99
+ if (!coalesce) undoStack.current.push(blocksRef.current);
100
+ if (undoStack.current.length > 100) undoStack.current.shift();
101
+ redoStack.current = [];
102
+ lastEdit.current = { token: editToken, at: now };
103
+ blocksRef.current = next;
104
+ setBlocks(next);
105
+ queueSave();
106
+ }, [queueSave]);
107
+
108
+ const updateMeta = useCallback((patch: Partial<Meta>) => {
109
+ setMeta((prev) => {
110
+ const next = { ...prev, ...patch };
111
+ metaRef.current = next;
112
+ return next;
113
+ });
114
+ queueSave();
115
+ }, [queueSave]);
116
+
117
+ useEffect(() => {
118
+ const onKey = (e: KeyboardEvent) => {
119
+ const target = e.target as HTMLElement;
120
+ const typing = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT';
121
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); save(); return; }
122
+ if (typing) return;
123
+ if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
124
+ e.preventDefault();
125
+ const source = e.shiftKey ? redoStack.current : undoStack.current;
126
+ const target2 = e.shiftKey ? undoStack.current : redoStack.current;
127
+ const prev = source.pop();
128
+ if (prev) {
129
+ target2.push(blocksRef.current);
130
+ blocksRef.current = prev;
131
+ setBlocks(prev);
132
+ queueSave();
133
+ }
134
+ return;
135
+ }
136
+ if ((e.key === 'Backspace' || e.key === 'Delete') && selectedId) {
137
+ e.preventDefault();
138
+ commitBlocks(blocksRef.current.filter((b) => b.id !== selectedId));
139
+ setSelectedId(null);
140
+ }
141
+ };
142
+ const onLeave = (e: BeforeUnloadEvent) => {
143
+ if (saveStateRef.current === 'dirty' || saveStateRef.current === 'saving') e.preventDefault();
144
+ };
145
+ window.addEventListener('keydown', onKey);
146
+ window.addEventListener('beforeunload', onLeave);
147
+ return () => { window.removeEventListener('keydown', onKey); window.removeEventListener('beforeunload', onLeave); };
148
+ }, [save, queueSave, commitBlocks, selectedId]);
149
+
150
+ const sensors = useSensors(
151
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
152
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
153
+ );
154
+
155
+ function newBlock(type: WidgetType): WidgetInstance {
156
+ return { id: uuid(), type, config: parseConfig(type, {}) as Record<string, unknown> };
157
+ }
158
+
159
+ function addBlock(type: WidgetType, index?: number) {
160
+ const b = newBlock(type);
161
+ const next = [...blocksRef.current];
162
+ next.splice(index ?? next.length, 0, b);
163
+ commitBlocks(next);
164
+ setSelectedId(b.id);
165
+ }
166
+
167
+ function onDragStart(e: DragStartEvent) {
168
+ const id = String(e.active.id);
169
+ if (id.startsWith('palette:')) {
170
+ setDragging({ type: id.slice(8) as WidgetType });
171
+ } else {
172
+ setDragging({ block: blocksRef.current.find((b) => b.id === id) });
173
+ }
174
+ }
175
+
176
+ function onDragEnd(e: DragEndEvent) {
177
+ setDragging(null);
178
+ const { active, over } = e;
179
+ if (!over) return;
180
+ const activeId = String(active.id);
181
+ const overId = String(over.id);
182
+ if (activeId.startsWith('palette:')) {
183
+ const type = activeId.slice(8) as WidgetType;
184
+ const overIndex = blocksRef.current.findIndex((b) => b.id === overId);
185
+ addBlock(type, overIndex === -1 ? undefined : overIndex);
186
+ return;
187
+ }
188
+ if (activeId !== overId) {
189
+ const from = blocksRef.current.findIndex((b) => b.id === activeId);
190
+ const to = blocksRef.current.findIndex((b) => b.id === overId);
191
+ if (from !== -1 && to !== -1) commitBlocks(arrayMove(blocksRef.current, from, to));
192
+ }
193
+ }
194
+
195
+ function updateBlockConfig(blockId: string, key: string, value: unknown) {
196
+ commitBlocks(
197
+ blocksRef.current.map((b) => (b.id === blockId ? { ...b, config: { ...b.config, [key]: value } } : b)),
198
+ `${blockId}:${key}`
199
+ );
200
+ }
201
+
202
+ function duplicateBlock(id: string) {
203
+ const i = blocksRef.current.findIndex((b) => b.id === id);
204
+ if (i === -1) return;
205
+ const copy = { ...blocksRef.current[i], id: uuid(), config: { ...blocksRef.current[i].config } };
206
+ const next = [...blocksRef.current];
207
+ next.splice(i + 1, 0, copy);
208
+ commitBlocks(next);
209
+ setSelectedId(copy.id);
210
+ }
211
+
212
+ const selected = blocks.find((b) => b.id === selectedId) ?? null;
213
+ const saveLabel =
214
+ saveState === 'saved' ? 'Saved' :
215
+ saveState === 'saving' ? 'Saving…' :
216
+ saveState === 'dirty' ? 'Unsaved changes' : 'Save failed';
217
+
218
+ return (
219
+ <div className="pb-shell">
220
+ <header className="pb-topbar">
221
+ <div className="pb-topbar-side">
222
+ <a className="pe-btn ghost sm" href="/admin/pages">‹ Pages</a>
223
+ <div>
224
+ <div className="pb-title">{meta.title}{meta.isHome ? ' · home' : ''}</div>
225
+ <div className={`pb-savestate ${saveState}`}>
226
+ {blocks.length} block{blocks.length === 1 ? '' : 's'} · {saveLabel}
227
+ </div>
228
+ </div>
229
+ </div>
230
+ <div className="pe-devices">
231
+ {DEVICES.map((d) => (
232
+ <button key={d.key} type="button" className={device.key === d.key ? 'is-active' : ''} onClick={() => setDevice(d)}>
233
+ {d.label}
234
+ </button>
235
+ ))}
236
+ </div>
237
+ <div className="pb-topbar-side right">
238
+ <button type="button" className="pe-btn ghost sm" onClick={() => setPreviewOpen(true)}>Preview</button>
239
+ <button
240
+ type="button"
241
+ className={`pe-btn sm ${meta.status === 'published' ? '' : 'primary'}`}
242
+ onClick={() => updateMeta({ status: meta.status === 'published' ? 'draft' : 'published' })}
243
+ >
244
+ {meta.status === 'published' ? 'Unpublish' : 'Publish'}
245
+ </button>
246
+ <button
247
+ type="button"
248
+ className={`pe-btn ghost sm ${selectedId === null ? 'is-active' : ''}`}
249
+ title="Page settings"
250
+ onClick={() => setSelectedId(null)}
251
+ >
252
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
253
+ <rect x="3" y="4" width="18" height="16" rx="2" /><path d="M15 4v16" />
254
+ </svg>
255
+ </button>
256
+ </div>
257
+ </header>
258
+
259
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={onDragStart} onDragEnd={onDragEnd}>
260
+ <div className="pb-body">
261
+ <aside className="pb-palette">
262
+ {(['Layout', 'Content', 'Advanced'] as const).map((group) => (
263
+ <div key={group}>
264
+ <div className="pb-palette-group">{group}</div>
265
+ {widgetTypes.filter((t) => widgetMeta[t].group === group).map((t) => (
266
+ <PaletteTile key={t} type={t} onAdd={() => addBlock(t)} />
267
+ ))}
268
+ </div>
269
+ ))}
270
+ <p className="pb-palette-hint">Drag onto the canvas, or click to append. ⌫ removes the selected block, ⌘Z undoes.</p>
271
+ </aside>
272
+
273
+ <main className="pb-canvas-wrap" onClick={() => setSelectedId(null)}>
274
+ <div className="pb-canvas" style={{ width: device.width, maxWidth: '100%' }}>
275
+ <SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
276
+ {blocks.map((b) => (
277
+ <CanvasBlock
278
+ key={b.id}
279
+ block={b}
280
+ selected={b.id === selectedId}
281
+ onSelect={() => setSelectedId(b.id)}
282
+ onDuplicate={() => duplicateBlock(b.id)}
283
+ onDelete={() => {
284
+ commitBlocks(blocksRef.current.filter((x) => x.id !== b.id));
285
+ if (selectedId === b.id) setSelectedId(null);
286
+ }}
287
+ />
288
+ ))}
289
+ </SortableContext>
290
+ <EmptyDrop visible={blocks.length === 0} />
291
+ </div>
292
+ </main>
293
+
294
+ <aside className="pb-inspector" onClick={(e) => e.stopPropagation()}>
295
+ {selected ? (
296
+ <BlockInspector
297
+ key={selected.id}
298
+ block={selected}
299
+ onChange={(k, v) => updateBlockConfig(selected.id, k, v)}
300
+ onPickImage={(key) => setPickerFor({ blockId: selected.id, key })}
301
+ />
302
+ ) : (
303
+ <PageSettings meta={meta} onChange={updateMeta} pageId={page.id} />
304
+ )}
305
+ </aside>
306
+ </div>
307
+
308
+ <DragOverlay dropAnimation={null}>
309
+ {dragging?.type && <div className="pb-tile is-ghost"><TileInner type={dragging.type} /></div>}
310
+ {dragging?.block && (
311
+ <div className="pb-block is-ghost"><BlockPreview block={dragging.block} /></div>
312
+ )}
313
+ </DragOverlay>
314
+ </DndContext>
315
+
316
+ <ImagePicker
317
+ open={pickerFor !== null}
318
+ unsplashEnabled={unsplashEnabled}
319
+ onClose={() => setPickerFor(null)}
320
+ onSelect={(img: PickedImage) => {
321
+ if (pickerFor) {
322
+ const b = blocksRef.current.find((x) => x.id === pickerFor.blockId);
323
+ if (b) {
324
+ commitBlocks(
325
+ blocksRef.current.map((x) =>
326
+ x.id === pickerFor.blockId
327
+ ? { ...x, config: { ...x.config, [pickerFor.key]: img.src, ...(x.type === 'image' && !x.config.alt ? { alt: img.alt } : {}) } }
328
+ : x
329
+ )
330
+ );
331
+ }
332
+ }
333
+ setPickerFor(null);
334
+ }}
335
+ />
336
+ <PreviewOverlay open={previewOpen} url={previewPath} shareUrl={sharePath} onClose={() => setPreviewOpen(false)} />
337
+ </div>
338
+ );
339
+ }
340
+
341
+ // ---- palette --------------------------------------------------------------
342
+
343
+ function TileInner({ type }: { type: WidgetType }) {
344
+ const m = widgetMeta[type];
345
+ return (
346
+ <>
347
+ <span className="pb-tile-icon">
348
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
349
+ <path d={m.icon} />
350
+ </svg>
351
+ </span>
352
+ <span>
353
+ <span className="pb-tile-label">{m.label}</span>
354
+ <span className="pb-tile-desc">{m.description}</span>
355
+ </span>
356
+ <span className="pb-tile-grip">⠿</span>
357
+ </>
358
+ );
359
+ }
360
+
361
+ function PaletteTile({ type, onAdd }: { type: WidgetType; onAdd: () => void }) {
362
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: `palette:${type}` });
363
+ return (
364
+ <button
365
+ ref={setNodeRef}
366
+ type="button"
367
+ className={`pb-tile ${isDragging ? 'is-dragging' : ''}`}
368
+ onClick={onAdd}
369
+ {...listeners}
370
+ {...attributes}
371
+ >
372
+ <TileInner type={type} />
373
+ </button>
374
+ );
375
+ }
376
+
377
+ // ---- canvas ---------------------------------------------------------------
378
+
379
+ function EmptyDrop({ visible }: { visible: boolean }) {
380
+ const { setNodeRef, isOver } = useDroppable({ id: 'canvas-empty' });
381
+ if (!visible) return null;
382
+ return (
383
+ <div ref={setNodeRef} className={`pb-empty ${isOver ? 'is-over' : ''}`}>
384
+ Drag a widget here to start building
385
+ </div>
386
+ );
387
+ }
388
+
389
+ function CanvasBlock({
390
+ block, selected, onSelect, onDuplicate, onDelete,
391
+ }: {
392
+ block: WidgetInstance;
393
+ selected: boolean;
394
+ onSelect: () => void;
395
+ onDuplicate: () => void;
396
+ onDelete: () => void;
397
+ }) {
398
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: block.id });
399
+ return (
400
+ <div
401
+ ref={setNodeRef}
402
+ style={{ transform: CSS.Transform.toString(transform), transition }}
403
+ className={`pb-block ${selected ? 'is-selected' : ''} ${isDragging ? 'is-dragging' : ''}`}
404
+ onClick={(e) => { e.stopPropagation(); onSelect(); }}
405
+ >
406
+ <div className="pb-block-tools">
407
+ <button type="button" className="pb-grip" title="Drag to reorder" {...listeners} {...attributes}>⠿</button>
408
+ <span className="pb-block-type">{widgetMeta[block.type as WidgetType].label}</span>
409
+ <button type="button" title="Duplicate" onClick={(e) => { e.stopPropagation(); onDuplicate(); }}>⧉</button>
410
+ <button type="button" title="Delete" onClick={(e) => { e.stopPropagation(); onDelete(); }}>✕</button>
411
+ </div>
412
+ <BlockPreview block={block} />
413
+ </div>
414
+ );
415
+ }
416
+
417
+ function BlockPreview({ block }: { block: WidgetInstance }) {
418
+ const c = block.config as Record<string, any>;
419
+ switch (block.type as WidgetType) {
420
+ case 'hero':
421
+ return (
422
+ <div className="bp-hero" style={{ textAlign: c.align === 'left' ? 'left' : 'center' }}>
423
+ <div className="bp-hero-heading">{c.heading || 'Hero heading'}</div>
424
+ {c.sub ? <div className="bp-hero-sub">{c.sub}</div> : null}
425
+ {c.ctaLabel ? <span className="bp-btn">{c.ctaLabel}</span> : null}
426
+ </div>
427
+ );
428
+ case 'text':
429
+ return c.markdown ? (
430
+ <div className="bp-text" dangerouslySetInnerHTML={{ __html: marked.parse(c.markdown, { async: false }) as string }} />
431
+ ) : (
432
+ <div className="bp-placeholder">Empty text block — write some markdown in the inspector</div>
433
+ );
434
+ case 'image':
435
+ return c.src ? (
436
+ <figure className="bp-image">
437
+ <img src={c.src} alt={c.alt || ''} />
438
+ {c.caption ? <figcaption>{c.caption}</figcaption> : null}
439
+ </figure>
440
+ ) : (
441
+ <div className="bp-placeholder">No image yet — choose one in the inspector</div>
442
+ );
443
+ case 'button':
444
+ return (
445
+ <div style={{ textAlign: 'center', padding: '6px 0' }}>
446
+ <span className={`bp-btn ${c.variant === 'outline' ? 'outline' : ''}`}>{c.label || 'Button'}</span>
447
+ </div>
448
+ );
449
+ case 'spacer': {
450
+ const h = c.size === 's' ? 16 : c.size === 'l' ? 64 : 32;
451
+ return <div className="bp-spacer" style={{ height: h }}><span>spacer · {c.size}</span></div>;
452
+ }
453
+ case 'embed':
454
+ return c.url ? (
455
+ <iframe className="bp-embed" src={c.url} style={{ height: Math.min(Number(c.height) || 400, 420) }} loading="lazy" title="Embed preview" />
456
+ ) : (
457
+ <div className="bp-placeholder">No URL yet — set one in the inspector</div>
458
+ );
459
+ case 'customHtml':
460
+ return c.html || c.js ? (
461
+ <iframe
462
+ className="bp-embed"
463
+ sandbox="allow-scripts"
464
+ style={{ height: Math.min(Number(c.height) || 300, 420) }}
465
+ srcDoc={`<style>html,body{margin:0;font-family:system-ui}${c.css ?? ''}</style>${c.html ?? ''}<script>${c.js ?? ''}<\/script>`}
466
+ title="Custom HTML preview"
467
+ />
468
+ ) : (
469
+ <div className="bp-placeholder">Empty custom HTML block</div>
470
+ );
471
+ }
472
+ }
473
+
474
+ // ---- inspector ------------------------------------------------------------
475
+
476
+ function BlockInspector({
477
+ block, onChange, onPickImage,
478
+ }: {
479
+ block: WidgetInstance;
480
+ onChange: (key: string, value: unknown) => void;
481
+ onPickImage: (key: string) => void;
482
+ }) {
483
+ const specs = fieldSpecs(block.type as WidgetType);
484
+ const c = block.config as Record<string, any>;
485
+ return (
486
+ <>
487
+ <div className="pb-inspector-head">
488
+ <span className="pb-inspector-type">{widgetMeta[block.type as WidgetType].label}</span>
489
+ <span className="pb-inspector-sub">{widgetMeta[block.type as WidgetType].description}</span>
490
+ </div>
491
+ {specs.map((f: FieldSpec) => (
492
+ <label key={f.key} className="pe-field">
493
+ <span>{f.label}</span>
494
+ {f.kind === 'select' && (
495
+ <select value={String(c[f.key] ?? '')} onChange={(e) => onChange(f.key, e.target.value)}>
496
+ {f.options!.map((o) => <option key={o} value={o}>{o}</option>)}
497
+ </select>
498
+ )}
499
+ {f.kind === 'number' && (
500
+ <input type="number" value={Number(c[f.key] ?? 0)} onChange={(e) => onChange(f.key, Number(e.target.value))} />
501
+ )}
502
+ {(f.kind === 'textarea' || f.kind === 'code') && (
503
+ <textarea
504
+ rows={f.kind === 'code' ? 6 : 5}
505
+ className={f.kind === 'code' ? 'pb-code' : undefined}
506
+ value={String(c[f.key] ?? '')}
507
+ onChange={(e) => onChange(f.key, e.target.value)}
508
+ />
509
+ )}
510
+ {f.kind === 'image' && (
511
+ <div className="pb-image-field">
512
+ <input type="text" placeholder="https://… or pick →" value={String(c[f.key] ?? '')} onChange={(e) => onChange(f.key, e.target.value)} />
513
+ <button type="button" className="pe-btn sm" onClick={() => onPickImage(f.key)}>Choose</button>
514
+ </div>
515
+ )}
516
+ {f.kind === 'text' && (
517
+ <input type="text" value={String(c[f.key] ?? '')} onChange={(e) => onChange(f.key, e.target.value)} />
518
+ )}
519
+ </label>
520
+ ))}
521
+ </>
522
+ );
523
+ }
524
+
525
+ function PageSettings({ meta, onChange, pageId }: { meta: Meta; onChange: (p: Partial<Meta>) => void; pageId: string }) {
526
+ return (
527
+ <>
528
+ <div className="pb-inspector-head">
529
+ <span className="pb-inspector-type">Page settings</span>
530
+ <span className="pb-inspector-sub">Select a block to edit it instead</span>
531
+ </div>
532
+ <label className="pe-field"><span>Title</span>
533
+ <input type="text" value={meta.title} onChange={(e) => onChange({ title: e.target.value })} />
534
+ </label>
535
+ <label className="pe-field"><span>Slug</span>
536
+ <div className="pe-slug"><span className="pe-slug-prefix">/</span>
537
+ <input type="text" value={meta.slug} onChange={(e) => onChange({ slug: e.target.value })} />
538
+ </div>
539
+ </label>
540
+ <label className="pe-field"><span>Status</span>
541
+ <select value={meta.status} onChange={(e) => onChange({ status: e.target.value as Status })}>
542
+ <option value="draft">Draft</option>
543
+ <option value="published">Published</option>
544
+ <option value="archived">Archived</option>
545
+ </select>
546
+ </label>
547
+ <label className="pb-check">
548
+ <input type="checkbox" checked={meta.isHome} onChange={(e) => onChange({ isHome: e.target.checked })} />
549
+ Serve this page at <code>/</code>
550
+ </label>
551
+ <details className="pe-seo" open>
552
+ <summary>Search engine optimization</summary>
553
+ <label className="pe-field"><span>SEO title</span>
554
+ <input type="text" value={meta.seoTitle} onChange={(e) => onChange({ seoTitle: e.target.value })} />
555
+ </label>
556
+ <label className="pe-field"><span>SEO description</span>
557
+ <textarea rows={3} value={meta.seoDesc} onChange={(e) => onChange({ seoDesc: e.target.value })} />
558
+ </label>
559
+ </details>
560
+ <div className="pe-danger">
561
+ <button
562
+ type="button"
563
+ className="pe-btn danger sm"
564
+ onClick={async () => {
565
+ if (!window.confirm('Delete this page permanently? This cannot be undone.')) return;
566
+ await actions.pages.delete({ id: pageId });
567
+ location.href = '/admin/pages';
568
+ }}
569
+ >
570
+ Delete page
571
+ </button>
572
+ </div>
573
+ </>
574
+ );
575
+ }