@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.
- package/cli.mjs +75 -0
- package/integration.mjs +286 -0
- package/package.json +89 -0
- package/src/actions/index.ts +502 -0
- package/src/components/DesignManager.tsx +542 -0
- package/src/components/LinksManager.tsx +694 -0
- package/src/components/Modal.astro +65 -0
- package/src/components/NavManager.tsx +168 -0
- package/src/components/WidgetRenderer.astro +84 -0
- package/src/components/builder/PageBuilder.tsx +575 -0
- package/src/components/editor/ImagePicker.tsx +215 -0
- package/src/components/editor/PostEditor.tsx +1011 -0
- package/src/components/editor/PreviewOverlay.tsx +77 -0
- package/src/components/editor/extensions/calendar.tsx +230 -0
- package/src/components/editor/extensions/cards.tsx +488 -0
- package/src/components/editor/extensions/codeBlock.tsx +70 -0
- package/src/components/editor/extensions/infoCards.tsx +270 -0
- package/src/components/editor/extensions/mediaCards.tsx +376 -0
- package/src/components/editor/extensions/postList.tsx +130 -0
- package/src/components/editor/extensions/slash.tsx +250 -0
- package/src/components/editor/extensions/tableTyping.ts +63 -0
- package/src/components/editor/imaging.ts +146 -0
- package/src/components/media/CropModal.tsx +229 -0
- package/src/components/media/MediaLibrary.tsx +295 -0
- package/src/layouts/Admin.astro +119 -0
- package/src/layouts/Site.astro +218 -0
- package/src/lib/auth.ts +94 -0
- package/src/lib/backup/unzip.ts +38 -0
- package/src/lib/backup/zip.ts +105 -0
- package/src/lib/blocksToHtml.ts +68 -0
- package/src/lib/brand.ts +26 -0
- package/src/lib/calendar/ics.ts +402 -0
- package/src/lib/calendar/render.ts +275 -0
- package/src/lib/calendar/src.ts +68 -0
- package/src/lib/content.ts +53 -0
- package/src/lib/dates.ts +34 -0
- package/src/lib/dynamicBlocks.ts +115 -0
- package/src/lib/env.ts +6 -0
- package/src/lib/highlightCode.ts +44 -0
- package/src/lib/i18n.ts +40 -0
- package/src/lib/markdown.ts +7 -0
- package/src/lib/media/store.ts +95 -0
- package/src/lib/net.ts +51 -0
- package/src/lib/preview.ts +28 -0
- package/src/lib/repo/d1.ts +44 -0
- package/src/lib/repo/index.ts +23 -0
- package/src/lib/repo/migrations.ts +231 -0
- package/src/lib/repo/search.ts +14 -0
- package/src/lib/repo/sql.ts +721 -0
- package/src/lib/repo/sqlite.ts +43 -0
- package/src/lib/repo/types.ts +132 -0
- package/src/lib/schema.ts +286 -0
- package/src/lib/seo.ts +37 -0
- package/src/lib/theme.ts +206 -0
- package/src/lib/widgets.ts +108 -0
- package/src/middleware.ts +109 -0
- package/src/pages/[...slug].astro +43 -0
- package/src/pages/admin/c/[collection]/[id].astro +56 -0
- package/src/pages/admin/c/[collection]/index.astro +163 -0
- package/src/pages/admin/design.astro +42 -0
- package/src/pages/admin/index.astro +145 -0
- package/src/pages/admin/links.astro +21 -0
- package/src/pages/admin/login.astro +83 -0
- package/src/pages/admin/logout.ts +7 -0
- package/src/pages/admin/media.astro +13 -0
- package/src/pages/admin/navigation.astro +17 -0
- package/src/pages/admin/pages/[id].astro +49 -0
- package/src/pages/admin/pages/index.astro +100 -0
- package/src/pages/admin/posts/[id].astro +6 -0
- package/src/pages/admin/posts/index.astro +7 -0
- package/src/pages/admin/settings.astro +512 -0
- package/src/pages/admin/tags.astro +101 -0
- package/src/pages/admin/users.astro +149 -0
- package/src/pages/api/backup.ts +63 -0
- package/src/pages/api/drafts.ts +106 -0
- package/src/pages/api/media/upload.ts +89 -0
- package/src/pages/api/restore.ts +102 -0
- package/src/pages/author/[slug].astro +66 -0
- package/src/pages/blog/[slug].astro +240 -0
- package/src/pages/blog/index.astro +51 -0
- package/src/pages/blog/tag/[slug].astro +83 -0
- package/src/pages/embed/[pageId]/[blockId].ts +45 -0
- package/src/pages/go/[id].ts +10 -0
- package/src/pages/index.astro +52 -0
- package/src/pages/links.astro +110 -0
- package/src/pages/llms.txt.ts +47 -0
- package/src/pages/media/[...key].ts +18 -0
- package/src/pages/preview/[type]/[id].astro +52 -0
- package/src/pages/robots.txt.ts +28 -0
- package/src/pages/rss.xml.ts +58 -0
- package/src/pages/search.astro +83 -0
- package/src/pages/sitemap.xml.ts +55 -0
- package/src/styles/admin.css +652 -0
- package/src/styles/builder.css +121 -0
- package/src/styles/cards.css +176 -0
- package/src/styles/editor.css +596 -0
- package/src/styles/site.css +427 -0
- package/src/themes/editorial/templates/home.astro +127 -0
- package/src/themes/editorial/templates/post-minimal.astro +50 -0
- package/src/themes/editorial/theme.css +108 -0
- package/src/themes/editorial/theme.json +20 -0
- package/src/themes/field/theme.json +17 -0
- package/src/themes/ink/theme.json +17 -0
- package/src/themes/night/theme.css +7 -0
- package/src/themes/night/theme.json +17 -0
- package/src/virtual.d.ts +25 -0
- 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
|
+
}
|