@marktiderman/genesis-sites 0.2.0 → 0.3.0
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/README.md +89 -5
- package/dist/assets/sites-forms.732cc7e0c1.css +10 -0
- package/dist/assets/sites-forms.css +1 -1
- package/dist/assets/sites.716b4ac1a5.css +2 -0
- package/dist/assets/sites.css +1 -1
- package/dist/assets.generated-QSB7EJWX.js +3 -0
- package/dist/assets.generated-QSB7EJWX.js.map +1 -0
- package/dist/chunk-5IXDFFEG.js +465 -0
- package/dist/chunk-5IXDFFEG.js.map +1 -0
- package/dist/{chunk-IXMY4B56.js → chunk-FBQF2L7O.js} +90 -89
- package/dist/chunk-FBQF2L7O.js.map +1 -0
- package/dist/chunk-H6ED3K4R.js +18 -0
- package/dist/chunk-H6ED3K4R.js.map +1 -0
- package/dist/chunk-KPOX7D4S.js +250 -0
- package/dist/chunk-KPOX7D4S.js.map +1 -0
- package/dist/editor.d.ts +526 -0
- package/dist/editor.js +2480 -0
- package/dist/editor.js.map +1 -0
- package/dist/handler-QHH453YG.js +9 -0
- package/dist/handler-QHH453YG.js.map +1 -0
- package/dist/import.d.ts +2 -2
- package/dist/index.d.ts +11 -5
- package/dist/index.js +2 -249
- package/dist/index.js.map +1 -1
- package/dist/{registry-D_DCWRTc.d.ts → registry-HBn1X60x.d.ts} +1 -1
- package/dist/sections.d.ts +1 -1
- package/dist/{store-DkSycDhq.d.ts → store-BXiZeZzH.d.ts} +2 -2
- package/dist/supabase.d.ts +3 -3
- package/dist/supabase.js +2 -2
- package/dist/web.d.ts +4 -4
- package/dist/web.js +6 -476
- package/dist/web.js.map +1 -1
- package/package.json +13 -3
- package/dist/assets/sites-forms.4e6aa42497.css +0 -10
- package/dist/assets/sites.e1ffd2708b.css +0 -2
- package/dist/chunk-IXMY4B56.js.map +0 -1
package/dist/editor.js
ADDED
|
@@ -0,0 +1,2480 @@
|
|
|
1
|
+
import { starterSite, exampleBrands, landingPage } from './chunk-KPOX7D4S.js';
|
|
2
|
+
import './chunk-C375ZPU2.js';
|
|
3
|
+
import './chunk-VZUDRPFG.js';
|
|
4
|
+
import { stableHash, isConflict, prepareRelease, PublishBlockedError, siteTheme, brandDisplayName, themeCss, migrateNode, migratePage, migrateSite } from './chunk-FBQF2L7O.js';
|
|
5
|
+
import { getSection, sectionJsonSchema, listSections, SECTION_CATEGORIES, PageDoc, SiteDoc, newSectionId } from './chunk-ZGWGQGT2.js';
|
|
6
|
+
import { isSafeImageSrc, DEFAULT_RESERVED_PATHS, pagePathProblems, isSafeHref } from './chunk-3VKTBE7S.js';
|
|
7
|
+
import * as React7 from 'react';
|
|
8
|
+
import { createContext, useContext } from 'react';
|
|
9
|
+
import { createUsePuck, Puck } from '@puckeditor/core';
|
|
10
|
+
export { createUsePuck, usePuck } from '@puckeditor/core';
|
|
11
|
+
import { Undo2, Redo2, Monitor, Tablet, Smartphone, CheckCircle2, Eye, ExternalLink, RotateCcw, Plus, Home, FileText, ArrowUp, ArrowDown, Trash2, AlertCircle, AlertTriangle } from 'lucide-react';
|
|
12
|
+
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from '@marktiderman/genesis-ui/alert-dialog';
|
|
13
|
+
import { Alert, AlertTitle, AlertDescription } from '@marktiderman/genesis-ui/alert';
|
|
14
|
+
import { Button } from '@marktiderman/genesis-ui/button';
|
|
15
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@marktiderman/genesis-ui/dialog';
|
|
16
|
+
import { Spinner } from '@marktiderman/genesis-ui/spinner';
|
|
17
|
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@marktiderman/genesis-ui/tabs';
|
|
18
|
+
import { cn } from '@marktiderman/genesis-ui/utils';
|
|
19
|
+
import { genesisBrand } from '@marktiderman/genesis-design-system';
|
|
20
|
+
import { Input } from '@marktiderman/genesis-ui/input';
|
|
21
|
+
import { Label } from '@marktiderman/genesis-ui/label';
|
|
22
|
+
import { Switch } from '@marktiderman/genesis-ui/switch';
|
|
23
|
+
import { Textarea } from '@marktiderman/genesis-ui/textarea';
|
|
24
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
25
|
+
import { RichTextEditor } from '@marktiderman/genesis-ui/rich-text-editor';
|
|
26
|
+
import { Badge } from '@marktiderman/genesis-ui/badge';
|
|
27
|
+
import { RadioGroup, RadioGroupItem } from '@marktiderman/genesis-ui/radio-group';
|
|
28
|
+
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@marktiderman/genesis-ui/select';
|
|
29
|
+
import { Separator } from '@marktiderman/genesis-ui/separator';
|
|
30
|
+
|
|
31
|
+
var IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/avif", "image/gif"];
|
|
32
|
+
async function dimensions(file) {
|
|
33
|
+
try {
|
|
34
|
+
const bitmap = await createImageBitmap(file);
|
|
35
|
+
const out = { width: bitmap.width, height: bitmap.height };
|
|
36
|
+
bitmap.close();
|
|
37
|
+
return out;
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function uploadProblem(file, maxBytes = 10 * 1024 * 1024) {
|
|
43
|
+
if (!file) return "Choose a file.";
|
|
44
|
+
if (!IMAGE_TYPES.includes(file.type)) return "Use a PNG, JPEG, WebP, AVIF or GIF image (SVG isn't allowed).";
|
|
45
|
+
if (file.size > maxBytes) return `That file is over ${Math.round(maxBytes / 1024 / 1024)} MB.`;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
function AssetPicker({ open, siteId, store, assets, current, altElsewhere = false, onDone }) {
|
|
49
|
+
const canUpload = Boolean(assets?.upload || store.putAsset);
|
|
50
|
+
const [tab, setTab] = React7.useState(canUpload ? "upload" : "link");
|
|
51
|
+
const [file, setFile] = React7.useState(null);
|
|
52
|
+
const [url, setUrl] = React7.useState(current?.src ?? "");
|
|
53
|
+
const [chosen, setChosen] = React7.useState(null);
|
|
54
|
+
const [alt, setAlt] = React7.useState(current?.alt ?? "");
|
|
55
|
+
const [decorative, setDecorative] = React7.useState(current?.decorative === true);
|
|
56
|
+
const [library, setLibrary] = React7.useState(null);
|
|
57
|
+
const [uploaded, setUploaded] = React7.useState([]);
|
|
58
|
+
const [busy, setBusy] = React7.useState(false);
|
|
59
|
+
const [error, setError] = React7.useState(null);
|
|
60
|
+
const id = React7.useId();
|
|
61
|
+
React7.useEffect(() => {
|
|
62
|
+
if (!open) return;
|
|
63
|
+
setFile(null);
|
|
64
|
+
setChosen(null);
|
|
65
|
+
setError(null);
|
|
66
|
+
setUrl(current?.src ?? "");
|
|
67
|
+
setAlt(current?.alt ?? "");
|
|
68
|
+
setDecorative(current?.decorative === true);
|
|
69
|
+
if (assets?.list) {
|
|
70
|
+
assets.list(siteId).then(setLibrary, (err) => {
|
|
71
|
+
console.error("[genesis-sites/editor] listing assets failed.", err);
|
|
72
|
+
setLibrary([]);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}, [open, siteId, assets, current]);
|
|
76
|
+
const altOk = altElsewhere || decorative || alt.trim().length > 0;
|
|
77
|
+
const altInvalid = !altOk && (Boolean(file) || Boolean(chosen) || tab === "link" && url.trim().length > 0);
|
|
78
|
+
const all = [...uploaded, ...(library ?? []).filter((a) => !uploaded.some((u) => u.id === a.id))].filter((a) => a.mime.startsWith("image/") && a.mime !== "image/svg+xml");
|
|
79
|
+
async function use() {
|
|
80
|
+
setError(null);
|
|
81
|
+
if (!altOk) return setError("Describe the image, or mark it decorative.");
|
|
82
|
+
const base = { alt: decorative ? "" : alt.trim(), ...decorative ? { decorative: true } : {} };
|
|
83
|
+
if (tab === "upload") {
|
|
84
|
+
const problem = uploadProblem(file, assets?.maxBytes);
|
|
85
|
+
if (problem || !file) return setError(problem);
|
|
86
|
+
setBusy(true);
|
|
87
|
+
try {
|
|
88
|
+
const size = await dimensions(file);
|
|
89
|
+
const meta = { alt: base.alt, name: file.name, ...size };
|
|
90
|
+
const asset = assets?.upload ? await assets.upload(siteId, file, meta) : await store.putAsset(siteId, file, meta);
|
|
91
|
+
setUploaded((u) => [asset, ...u]);
|
|
92
|
+
if (!isSafeImageSrc(asset.url)) throw new Error(`The upload's address (${asset.url}) isn't a site path or https URL.`);
|
|
93
|
+
onDone({ src: asset.url, ...base, ...size });
|
|
94
|
+
} catch (err) {
|
|
95
|
+
setError(`The upload failed: ${err.message}`);
|
|
96
|
+
} finally {
|
|
97
|
+
setBusy(false);
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (tab === "library") {
|
|
102
|
+
if (!chosen) return setError("Choose an image.");
|
|
103
|
+
if (!isSafeImageSrc(chosen.url)) return setError(`That image's address (${chosen.url}) isn't a site path or https URL.`);
|
|
104
|
+
return onDone({ src: chosen.url, ...base, ...chosen.width ? { width: chosen.width } : {}, ...chosen.height ? { height: chosen.height } : {} });
|
|
105
|
+
}
|
|
106
|
+
const src = url.trim();
|
|
107
|
+
if (!isSafeImageSrc(src)) return setError("Use a site path (/images/photo.jpg) or an https link.");
|
|
108
|
+
onDone({ src, ...base });
|
|
109
|
+
}
|
|
110
|
+
return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: (o) => o ? null : onDone(null), children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-lg", children: [
|
|
111
|
+
/* @__PURE__ */ jsxs(DialogHeader, { children: [
|
|
112
|
+
/* @__PURE__ */ jsx(DialogTitle, { children: "Choose an image" }),
|
|
113
|
+
/* @__PURE__ */ jsx(DialogDescription, { children: "Upload one, pick one the site has, or link one. Every image needs alt text unless it's decorative." })
|
|
114
|
+
] }),
|
|
115
|
+
/* @__PURE__ */ jsxs(Tabs, { defaultValue: "upload", value: tab, onValueChange: setTab, children: [
|
|
116
|
+
/* @__PURE__ */ jsxs(TabsList, { className: "grid w-full grid-cols-3", children: [
|
|
117
|
+
/* @__PURE__ */ jsx(TabsTrigger, { value: "upload", disabled: !canUpload, children: "Upload" }),
|
|
118
|
+
/* @__PURE__ */ jsx(TabsTrigger, { value: "library", children: "Library" }),
|
|
119
|
+
/* @__PURE__ */ jsx(TabsTrigger, { value: "link", children: "Link" })
|
|
120
|
+
] }),
|
|
121
|
+
/* @__PURE__ */ jsxs(TabsContent, { value: "upload", className: "flex flex-col gap-2 pt-2", children: [
|
|
122
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-file`, children: "Image file" }),
|
|
123
|
+
/* @__PURE__ */ jsx(Input, { id: `${id}-file`, type: "file", accept: IMAGE_TYPES.join(","), onChange: (e) => setFile(e.currentTarget.files?.[0] ?? null) }),
|
|
124
|
+
!canUpload ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "This site's store doesn't take uploads; link an image instead." }) : null
|
|
125
|
+
] }),
|
|
126
|
+
/* @__PURE__ */ jsx(TabsContent, { value: "library", className: "pt-2", children: all.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: library === null && assets?.list ? "Loading\u2026" : "No images yet. Upload one first." }) : /* @__PURE__ */ jsx("div", { role: "group", "aria-label": "Site images", className: "grid max-h-64 grid-cols-3 gap-2 overflow-y-auto", children: all.map((a) => /* @__PURE__ */ jsx(
|
|
127
|
+
"button",
|
|
128
|
+
{
|
|
129
|
+
type: "button",
|
|
130
|
+
"aria-pressed": chosen?.id === a.id,
|
|
131
|
+
"aria-label": a.alt?.trim() || a.path.split("/").pop() || "Image",
|
|
132
|
+
onClick: () => {
|
|
133
|
+
setChosen(a);
|
|
134
|
+
if (a.alt && !alt) setAlt(a.alt);
|
|
135
|
+
},
|
|
136
|
+
className: cn("overflow-hidden rounded-md border-2 focus-visible:outline-2 focus-visible:outline-ring", chosen?.id === a.id ? "border-primary" : "border-transparent"),
|
|
137
|
+
children: isSafeImageSrc(a.url) ? /* @__PURE__ */ jsx("img", { src: a.url, alt: a.alt ?? "", className: "h-20 w-full object-cover" }) : /* @__PURE__ */ jsx("span", { className: "p-2 text-xs", children: a.path })
|
|
138
|
+
},
|
|
139
|
+
a.id
|
|
140
|
+
)) }) }),
|
|
141
|
+
/* @__PURE__ */ jsxs(TabsContent, { value: "link", className: "flex flex-col gap-2 pt-2", children: [
|
|
142
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-url`, children: "Image address" }),
|
|
143
|
+
/* @__PURE__ */ jsx(Input, { id: `${id}-url`, value: url, placeholder: "https://\u2026 or /images/photo.jpg", onChange: (e) => setUrl(e.currentTarget.value) })
|
|
144
|
+
] })
|
|
145
|
+
] }),
|
|
146
|
+
altElsewhere ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Describe the image in the item's Alt text field, after you choose it." }) : /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
|
|
147
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-alt`, children: "Alt text" }),
|
|
148
|
+
/* @__PURE__ */ jsx(Textarea, { id: `${id}-alt`, rows: 2, maxLength: 300, value: alt, disabled: decorative, "aria-invalid": altInvalid || void 0, "aria-describedby": `${id}-alt-hint`, onChange: (e) => setAlt(e.currentTarget.value), placeholder: "Two people stretching on mats by a window" }),
|
|
149
|
+
/* @__PURE__ */ jsx("p", { id: `${id}-alt-hint`, className: altInvalid ? "text-sm text-destructive" : "text-sm text-muted-foreground", children: altInvalid ? "Describe the image, or mark it decorative." : "What someone who can't see the image needs to know." }),
|
|
150
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
151
|
+
/* @__PURE__ */ jsx(Switch, { id: `${id}-decorative`, checked: decorative, onCheckedChange: setDecorative }),
|
|
152
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-decorative`, children: "Decorative (adds nothing a reader needs)" })
|
|
153
|
+
] })
|
|
154
|
+
] }),
|
|
155
|
+
error ? /* @__PURE__ */ jsx("p", { role: "alert", className: "text-sm text-destructive", children: error }) : null,
|
|
156
|
+
/* @__PURE__ */ jsxs(DialogFooter, { children: [
|
|
157
|
+
/* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: () => onDone(null), children: "Cancel" }),
|
|
158
|
+
/* @__PURE__ */ jsx(Button, { type: "button", onClick: use, loading: busy, disabled: !altOk, children: "Use image" })
|
|
159
|
+
] })
|
|
160
|
+
] }) });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/editor/canvas-dom.ts
|
|
164
|
+
function scrollCanvasTo(sectionId, from = document) {
|
|
165
|
+
requestAnimationFrame(
|
|
166
|
+
() => requestAnimationFrame(() => {
|
|
167
|
+
const frame = from.querySelector("[data-gs-canvas] iframe");
|
|
168
|
+
frame?.contentDocument?.querySelector(`[data-puck-component="${CSS.escape(sectionId)}"]`)?.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/editor/autosave.ts
|
|
174
|
+
var AutosaveBlockedError = class extends Error {
|
|
175
|
+
constructor(status, message) {
|
|
176
|
+
super(message);
|
|
177
|
+
this.status = status;
|
|
178
|
+
this.name = "AutosaveBlockedError";
|
|
179
|
+
}
|
|
180
|
+
status;
|
|
181
|
+
};
|
|
182
|
+
var Autosaver = class {
|
|
183
|
+
state;
|
|
184
|
+
/** The newest document not yet saved, or null. */
|
|
185
|
+
pending = null;
|
|
186
|
+
/** The last document the store accepted (or loaded). */
|
|
187
|
+
saved = null;
|
|
188
|
+
timer = null;
|
|
189
|
+
inFlight = null;
|
|
190
|
+
retries = 0;
|
|
191
|
+
waiters = [];
|
|
192
|
+
disposed = false;
|
|
193
|
+
paused = false;
|
|
194
|
+
opts;
|
|
195
|
+
constructor(options2, initial) {
|
|
196
|
+
this.opts = {
|
|
197
|
+
delay: 800,
|
|
198
|
+
retryDelay: 2e3,
|
|
199
|
+
equals: (a, b) => stableHash(a) === stableHash(b),
|
|
200
|
+
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
201
|
+
clearTimer: (h) => clearTimeout(h),
|
|
202
|
+
now: () => /* @__PURE__ */ new Date(),
|
|
203
|
+
...options2
|
|
204
|
+
};
|
|
205
|
+
this.saved = initial ?? null;
|
|
206
|
+
this.state = { status: "saved", rev: options2.rev, conflict: null, error: null, savedAt: null };
|
|
207
|
+
}
|
|
208
|
+
get current() {
|
|
209
|
+
return this.state;
|
|
210
|
+
}
|
|
211
|
+
/** Is anything not yet in the store (pending, saving, a conflict or a failed save)? */
|
|
212
|
+
get dirty() {
|
|
213
|
+
return this.pending !== null || this.inFlight !== null || this.state.status === "conflict" || this.state.status === "error";
|
|
214
|
+
}
|
|
215
|
+
/** The document as the person has it: unsaved changes, or the last save. */
|
|
216
|
+
get latest() {
|
|
217
|
+
return this.pending ?? this.saved;
|
|
218
|
+
}
|
|
219
|
+
set(patch) {
|
|
220
|
+
this.state = { ...this.state, ...patch };
|
|
221
|
+
this.opts.onState?.(this.state);
|
|
222
|
+
}
|
|
223
|
+
/** A change: saved after the delay, unless another change comes first. */
|
|
224
|
+
update(doc) {
|
|
225
|
+
if (this.disposed) return;
|
|
226
|
+
if (this.pending === null && this.saved !== null && this.opts.equals(doc, this.saved) && !this.inFlight) return;
|
|
227
|
+
this.pending = doc;
|
|
228
|
+
if (this.state.status === "conflict") return;
|
|
229
|
+
if (this.state.status !== "saving") this.set({ status: "pending" });
|
|
230
|
+
if (this.paused) return;
|
|
231
|
+
this.schedule(this.opts.delay);
|
|
232
|
+
}
|
|
233
|
+
schedule(ms) {
|
|
234
|
+
if (this.timer !== null) this.opts.clearTimer(this.timer);
|
|
235
|
+
this.timer = this.opts.setTimer(() => {
|
|
236
|
+
this.timer = null;
|
|
237
|
+
void this.run();
|
|
238
|
+
}, ms);
|
|
239
|
+
}
|
|
240
|
+
async run() {
|
|
241
|
+
if (this.inFlight) return this.inFlight;
|
|
242
|
+
if (this.paused) return;
|
|
243
|
+
if (this.pending === null || this.state.status === "conflict" || this.disposed) {
|
|
244
|
+
this.settle();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const doc = this.pending;
|
|
248
|
+
const rev = this.state.rev;
|
|
249
|
+
this.pending = null;
|
|
250
|
+
this.set({ status: "saving", error: null });
|
|
251
|
+
this.inFlight = (async () => {
|
|
252
|
+
try {
|
|
253
|
+
const result = await this.opts.save(rev, doc);
|
|
254
|
+
if (isConflict(result)) {
|
|
255
|
+
this.pending = this.pending ?? doc;
|
|
256
|
+
this.set({ status: "conflict", conflict: { doc: result.conflict, rev: result.rev } });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.saved = doc;
|
|
260
|
+
this.retries = 0;
|
|
261
|
+
this.set({ rev: result.rev, savedAt: this.opts.now(), status: this.pending !== null ? "pending" : "saved" });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
this.pending = this.pending ?? doc;
|
|
264
|
+
const delay = Math.min(6e4, this.opts.retryDelay * 2 ** this.retries++);
|
|
265
|
+
this.set({ status: "error", error: err instanceof Error ? err : new Error(String(err)) });
|
|
266
|
+
if (!this.disposed) this.schedule(delay);
|
|
267
|
+
} finally {
|
|
268
|
+
this.inFlight = null;
|
|
269
|
+
}
|
|
270
|
+
})();
|
|
271
|
+
await this.inFlight;
|
|
272
|
+
if (this.pending !== null && this.state.status === "pending") return this.run();
|
|
273
|
+
this.settle();
|
|
274
|
+
}
|
|
275
|
+
settle() {
|
|
276
|
+
if (this.dirty && this.state.status !== "conflict" && this.state.status !== "error") return;
|
|
277
|
+
const waiters = this.waiters;
|
|
278
|
+
this.waiters = [];
|
|
279
|
+
for (const w of waiters) {
|
|
280
|
+
if (this.state.status === "conflict") w.reject(new AutosaveBlockedError("conflict", "Someone else saved this first. Load their version or keep yours before going on."));
|
|
281
|
+
else if (this.state.status === "error") w.reject(new AutosaveBlockedError("error", `The last change couldn't be saved: ${this.state.error?.message ?? "unknown error"}`));
|
|
282
|
+
else w.resolve();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Save now; resolves when nothing is waiting, rejects on a conflict or a failed save. */
|
|
286
|
+
flush() {
|
|
287
|
+
if (this.timer !== null) {
|
|
288
|
+
this.opts.clearTimer(this.timer);
|
|
289
|
+
this.timer = null;
|
|
290
|
+
}
|
|
291
|
+
if (!this.dirty) return Promise.resolve();
|
|
292
|
+
if (this.state.status === "conflict") return Promise.reject(new AutosaveBlockedError("conflict", "Someone else saved this first. Load their version or keep yours before going on."));
|
|
293
|
+
const done = new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
|
|
294
|
+
if (this.state.status === "error") this.set({ status: "pending" });
|
|
295
|
+
void this.run();
|
|
296
|
+
return done;
|
|
297
|
+
}
|
|
298
|
+
/** Conflict: take the store's document. Returns it; unsaved changes are discarded. */
|
|
299
|
+
reload() {
|
|
300
|
+
const conflict = this.state.conflict;
|
|
301
|
+
if (!conflict) return null;
|
|
302
|
+
this.pending = null;
|
|
303
|
+
this.saved = conflict.doc;
|
|
304
|
+
this.set({ status: "saved", rev: conflict.rev, conflict: null, error: null });
|
|
305
|
+
this.settle();
|
|
306
|
+
return conflict.doc;
|
|
307
|
+
}
|
|
308
|
+
/** Conflict: save this person's document over the store's. */
|
|
309
|
+
overwrite() {
|
|
310
|
+
const conflict = this.state.conflict;
|
|
311
|
+
if (!conflict) return Promise.resolve();
|
|
312
|
+
this.pending = this.pending ?? this.saved;
|
|
313
|
+
this.set({ status: "pending", rev: conflict.rev, conflict: null });
|
|
314
|
+
return this.flush();
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The store moved on without a conflict (a page move bumped its revision,
|
|
318
|
+
* a publish migrated it): adopt its revision. `doc` replaces the saved
|
|
319
|
+
* copy when given; unsaved changes are kept and saved on the new revision.
|
|
320
|
+
*/
|
|
321
|
+
rebase(rev, doc) {
|
|
322
|
+
if (doc !== void 0) this.saved = doc;
|
|
323
|
+
this.set({ rev });
|
|
324
|
+
}
|
|
325
|
+
/** Is saving held (`pause`)? */
|
|
326
|
+
get isPaused() {
|
|
327
|
+
return this.paused;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Hold every save, for as long as the store must not change under the
|
|
331
|
+
* caller (a publish). A save already in flight lands; changes keep
|
|
332
|
+
* collecting and are saved on `resume`.
|
|
333
|
+
*/
|
|
334
|
+
pause() {
|
|
335
|
+
this.paused = true;
|
|
336
|
+
if (this.timer !== null) this.opts.clearTimer(this.timer);
|
|
337
|
+
this.timer = null;
|
|
338
|
+
}
|
|
339
|
+
/** Save again: what collected while paused is saved now, on the current revision. */
|
|
340
|
+
resume() {
|
|
341
|
+
if (!this.paused) return;
|
|
342
|
+
this.paused = false;
|
|
343
|
+
if (this.disposed) return;
|
|
344
|
+
if (this.pending !== null && this.state.status !== "conflict") this.schedule(0);
|
|
345
|
+
else this.settle();
|
|
346
|
+
}
|
|
347
|
+
dispose() {
|
|
348
|
+
this.disposed = true;
|
|
349
|
+
if (this.timer !== null) this.opts.clearTimer(this.timer);
|
|
350
|
+
this.timer = null;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// src/editor/data.ts
|
|
355
|
+
var NODE_KEYS = ["_variant", "_hidden", "_hideOn", "_v", "_placeholder"];
|
|
356
|
+
var presetComponentName = (type, index) => index === 0 ? type : `${type}__${index}`;
|
|
357
|
+
var sectionTypeOf = (componentName) => componentName.split("__")[0];
|
|
358
|
+
var noSlots = () => [];
|
|
359
|
+
var hideOnValue = (hideOn) => hideOn.includes("mobile") && hideOn.includes("desktop") ? "both" : hideOn[0] ?? "none";
|
|
360
|
+
function nodeToItem(node, slotKeys = noSlots) {
|
|
361
|
+
const props = { ...structuredClone(node.props) };
|
|
362
|
+
for (const key of slotKeys(node.type)) {
|
|
363
|
+
const nested = props[key];
|
|
364
|
+
if (Array.isArray(nested)) props[key] = nested.map((n) => nodeToItem(n, slotKeys));
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
type: node.type,
|
|
368
|
+
props: {
|
|
369
|
+
...props,
|
|
370
|
+
id: node.id,
|
|
371
|
+
_v: node.v,
|
|
372
|
+
...node.variant ? { _variant: node.variant } : {},
|
|
373
|
+
_hidden: node.hidden === true,
|
|
374
|
+
_hideOn: node.hideOn?.length ? hideOnValue(node.hideOn) : "none",
|
|
375
|
+
...node.placeholder ? { _placeholder: true } : {}
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function pruneEmpty(value) {
|
|
380
|
+
if (typeof value === "string") return value === "" ? void 0 : value;
|
|
381
|
+
if (Array.isArray(value)) return value.map((v) => typeof v === "string" ? v : pruneEmpty(v) ?? {});
|
|
382
|
+
if (value && typeof value === "object") {
|
|
383
|
+
const out = {};
|
|
384
|
+
for (const [k, v] of Object.entries(value)) {
|
|
385
|
+
const pruned = pruneEmpty(v);
|
|
386
|
+
if (pruned !== void 0) out[k] = pruned;
|
|
387
|
+
}
|
|
388
|
+
return Object.keys(out).length ? out : void 0;
|
|
389
|
+
}
|
|
390
|
+
return value;
|
|
391
|
+
}
|
|
392
|
+
var ARRAY_BOOKKEEPING = /* @__PURE__ */ new Set(["_arrayId", "_originalIndex", "_currentIndex"]);
|
|
393
|
+
function stripArrayIds(value) {
|
|
394
|
+
if (Array.isArray(value)) return value.map(stripArrayIds);
|
|
395
|
+
if (value && typeof value === "object") {
|
|
396
|
+
const out = {};
|
|
397
|
+
for (const [k, v] of Object.entries(value)) if (!ARRAY_BOOKKEEPING.has(k)) out[k] = stripArrayIds(v);
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
return value;
|
|
401
|
+
}
|
|
402
|
+
function safeSectionId(id) {
|
|
403
|
+
if (/^[A-Za-z0-9_-]{1,64}$/.test(id)) return id;
|
|
404
|
+
const cleaned = id.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+/, "");
|
|
405
|
+
if (cleaned.length <= 64 && cleaned.length > 0) return cleaned;
|
|
406
|
+
let h = 2166136261;
|
|
407
|
+
for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 16777619) >>> 0;
|
|
408
|
+
return `${cleaned.slice(0, 55)}-${h.toString(36)}`.slice(0, 64) || `s${h.toString(36)}`;
|
|
409
|
+
}
|
|
410
|
+
function itemToNode(item, options2 = {}) {
|
|
411
|
+
const lookup = options2.lookup ?? getSection;
|
|
412
|
+
const type = sectionTypeOf(item.type);
|
|
413
|
+
const def = lookup(type);
|
|
414
|
+
const { id, _variant, _hidden, _hideOn, _v, _placeholder, ...rest } = item.props;
|
|
415
|
+
const slotKeys = options2.slotKeys ?? noSlots;
|
|
416
|
+
const slots = new Set(slotKeys(type));
|
|
417
|
+
const plain = Object.fromEntries(Object.entries(rest).filter(([k]) => !slots.has(k)));
|
|
418
|
+
const props = stripArrayIds(pruneEmpty(plain)) ?? {};
|
|
419
|
+
for (const key of slots) {
|
|
420
|
+
const nested = rest[key];
|
|
421
|
+
props[key] = Array.isArray(nested) ? nested.map((n) => itemToNode(n, options2)) : [];
|
|
422
|
+
}
|
|
423
|
+
const hideOn = _hideOn === "both" ? ["mobile", "desktop"] : _hideOn === "mobile" || _hideOn === "desktop" ? [_hideOn] : void 0;
|
|
424
|
+
const original = options2.placeholders?.get(String(id));
|
|
425
|
+
const placeholder = _placeholder === true && original !== void 0 && original === placeholderHash(props);
|
|
426
|
+
return {
|
|
427
|
+
id: safeSectionId(String(id)),
|
|
428
|
+
type,
|
|
429
|
+
v: typeof _v === "number" ? _v : def?.version ?? 1,
|
|
430
|
+
...typeof _variant === "string" ? { variant: _variant } : def ? { variant: def.defaultVariant } : {},
|
|
431
|
+
..._hidden === true ? { hidden: true } : {},
|
|
432
|
+
...hideOn ? { hideOn } : {},
|
|
433
|
+
...placeholder ? { placeholder: true } : {},
|
|
434
|
+
props
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
var placeholderHash = (props) => stableHash(stripArrayIds(pruneEmpty(props)) ?? {});
|
|
438
|
+
function placeholderSnapshot(nodes, into = /* @__PURE__ */ new Map()) {
|
|
439
|
+
for (const n of nodes) if (n.placeholder) into.set(n.id, placeholderHash(n.props));
|
|
440
|
+
return into;
|
|
441
|
+
}
|
|
442
|
+
function toEditorData(page, site, slotKeys = noSlots) {
|
|
443
|
+
const { title, hideSiteChrome, ...restRoot } = page.root.props;
|
|
444
|
+
return {
|
|
445
|
+
root: {
|
|
446
|
+
props: {
|
|
447
|
+
...restRoot,
|
|
448
|
+
title,
|
|
449
|
+
...hideSiteChrome ? { hideSiteChrome: true } : {},
|
|
450
|
+
...page.seo ? { seo: structuredClone(page.seo) } : {},
|
|
451
|
+
header: site.header.map((n) => nodeToItem(n, slotKeys)),
|
|
452
|
+
footer: site.footer.map((n) => nodeToItem(n, slotKeys))
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
content: page.content.map((n) => nodeToItem(n, slotKeys))
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
function fromEditorData(data, options2 = {}) {
|
|
459
|
+
const rootProps = data.root?.props ?? {};
|
|
460
|
+
const { header, footer, seo, title, hideSiteChrome, ...restRoot } = rootProps;
|
|
461
|
+
const items = (list) => Array.isArray(list) ? list.map((i) => itemToNode(i, options2)) : [];
|
|
462
|
+
const cleanSeo = stripArrayIds(pruneEmpty(seo));
|
|
463
|
+
const extra = pruneEmpty(restRoot) ?? {};
|
|
464
|
+
const page = {
|
|
465
|
+
schemaVersion: 1,
|
|
466
|
+
root: { props: { ...extra, title: typeof title === "string" ? title : "", ...hideSiteChrome === true ? { hideSiteChrome: true } : {} } },
|
|
467
|
+
content: items(data.content),
|
|
468
|
+
...cleanSeo ? { seo: cleanSeo } : {}
|
|
469
|
+
};
|
|
470
|
+
return { page, header: items(header), footer: items(footer) };
|
|
471
|
+
}
|
|
472
|
+
function h1SectionId(content, lookup = getSection) {
|
|
473
|
+
for (const item of content) {
|
|
474
|
+
if (item.props._hidden === true) continue;
|
|
475
|
+
if (lookup(sectionTypeOf(item.type))?.providesH1) return item.props.id;
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
function countTypes(data) {
|
|
480
|
+
const counts = /* @__PURE__ */ new Map();
|
|
481
|
+
const add = (items) => {
|
|
482
|
+
for (const item of items ?? []) {
|
|
483
|
+
const type = sectionTypeOf(item.type);
|
|
484
|
+
counts.set(type, (counts.get(type) ?? 0) + 1);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
add(data.content);
|
|
488
|
+
add(data.root?.props?.header);
|
|
489
|
+
add(data.root?.props?.footer);
|
|
490
|
+
return counts;
|
|
491
|
+
}
|
|
492
|
+
var EditorServicesContext = createContext(null);
|
|
493
|
+
function useEditorServices() {
|
|
494
|
+
const services = useContext(EditorServicesContext);
|
|
495
|
+
if (!services) throw new Error("genesis-sites/editor: a field was rendered outside <SiteEditor>.");
|
|
496
|
+
return services;
|
|
497
|
+
}
|
|
498
|
+
function Thumb({ src, alt }) {
|
|
499
|
+
if (!src) return /* @__PURE__ */ jsx("div", { className: "flex h-24 w-full items-center justify-center rounded-md border border-dashed border-border text-sm text-muted-foreground", children: "No image" });
|
|
500
|
+
if (!isSafeImageSrc(src)) return /* @__PURE__ */ jsx("div", { className: "rounded-md border border-destructive p-2 text-sm text-destructive", children: "This address can't be used for an image: use a site path or an https link." });
|
|
501
|
+
return /* @__PURE__ */ jsx("img", { src, alt, className: "h-24 w-full rounded-md border border-border object-cover" });
|
|
502
|
+
}
|
|
503
|
+
function ImageField({ field, id, value, onChange, readOnly }) {
|
|
504
|
+
const services = useEditorServices();
|
|
505
|
+
const image = value ?? {};
|
|
506
|
+
const missingAlt = Boolean(image.src) && !image.alt?.trim() && image.decorative !== true;
|
|
507
|
+
const choose = async () => {
|
|
508
|
+
const picked = await services.pickImage(image.src ? { current: image } : {});
|
|
509
|
+
if (!picked) return;
|
|
510
|
+
const { src: _s, alt: _a, decorative: _d, width: _w, height: _h, focal: _f, ...rest } = image;
|
|
511
|
+
onChange({ ...rest, ...picked });
|
|
512
|
+
};
|
|
513
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", role: "group", "aria-labelledby": `${id}-label`, children: [
|
|
514
|
+
/* @__PURE__ */ jsx("span", { id: `${id}-label`, className: "text-sm font-medium", children: field.label }),
|
|
515
|
+
/* @__PURE__ */ jsx(Thumb, { src: image.src, alt: image.alt ?? "" }),
|
|
516
|
+
/* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
|
|
517
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "outline", onClick: choose, disabled: readOnly, children: image.src ? "Replace image" : "Choose image" }),
|
|
518
|
+
image.src ? /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "ghost", onClick: () => onChange(void 0), disabled: readOnly, children: "Remove" }) : null
|
|
519
|
+
] }),
|
|
520
|
+
image.src ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
521
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-alt`, children: "Alt text" }),
|
|
522
|
+
/* @__PURE__ */ jsx(
|
|
523
|
+
Textarea,
|
|
524
|
+
{
|
|
525
|
+
id: `${id}-alt`,
|
|
526
|
+
value: image.alt ?? "",
|
|
527
|
+
rows: 2,
|
|
528
|
+
maxLength: 300,
|
|
529
|
+
disabled: readOnly || image.decorative === true,
|
|
530
|
+
"aria-invalid": missingAlt || void 0,
|
|
531
|
+
"aria-describedby": `${id}-alt-hint`,
|
|
532
|
+
onChange: (e) => onChange({ ...image, alt: e.currentTarget.value })
|
|
533
|
+
}
|
|
534
|
+
),
|
|
535
|
+
/* @__PURE__ */ jsx("p", { id: `${id}-alt-hint`, className: missingAlt ? "text-sm text-destructive" : "text-sm text-muted-foreground", children: missingAlt ? "Describe the image, or mark it decorative. A page can't be published without this." : "What someone who can't see the image needs to know." }),
|
|
536
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
537
|
+
/* @__PURE__ */ jsx(Switch, { id: `${id}-decorative`, checked: image.decorative === true, disabled: readOnly, onCheckedChange: (on) => onChange({ ...image, decorative: on || void 0, ...on ? { alt: "" } : {} }) }),
|
|
538
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-decorative`, children: "Decorative (adds nothing a reader needs)" })
|
|
539
|
+
] })
|
|
540
|
+
] }) : null
|
|
541
|
+
] });
|
|
542
|
+
}
|
|
543
|
+
function ImageSrcField({ field, id, value, onChange, readOnly }) {
|
|
544
|
+
const services = useEditorServices();
|
|
545
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", role: "group", "aria-labelledby": `${id}-label`, children: [
|
|
546
|
+
/* @__PURE__ */ jsx("span", { id: `${id}-label`, className: "text-sm font-medium", children: field.label }),
|
|
547
|
+
/* @__PURE__ */ jsx(Thumb, { src: value, alt: "" }),
|
|
548
|
+
/* @__PURE__ */ jsx(
|
|
549
|
+
Button,
|
|
550
|
+
{
|
|
551
|
+
type: "button",
|
|
552
|
+
size: "sm",
|
|
553
|
+
variant: "outline",
|
|
554
|
+
disabled: readOnly,
|
|
555
|
+
onClick: async () => {
|
|
556
|
+
const picked = await services.pickImage({ altElsewhere: true, ...value ? { current: { src: value, alt: "" } } : {} });
|
|
557
|
+
if (picked) onChange(picked.src);
|
|
558
|
+
},
|
|
559
|
+
children: value ? "Replace image" : "Choose image"
|
|
560
|
+
}
|
|
561
|
+
)
|
|
562
|
+
] });
|
|
563
|
+
}
|
|
564
|
+
function RichTextField({ field, id, value, onChange, readOnly }) {
|
|
565
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
|
|
566
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: id, children: field.label }),
|
|
567
|
+
/* @__PURE__ */ jsx(RichTextEditor, { id, value: value ?? { type: "doc", content: [] }, onChange: (doc) => onChange(doc), readOnly, headingLevels: [2, 3], contentClassName: "min-h-40" })
|
|
568
|
+
] });
|
|
569
|
+
}
|
|
570
|
+
function LinesField({ field, id, value, onChange, readOnly }) {
|
|
571
|
+
const max = field.metadata?.maxItems;
|
|
572
|
+
const [text, setText] = React7.useState(() => (value ?? []).join("\n"));
|
|
573
|
+
const lastSent = React7.useRef(value);
|
|
574
|
+
React7.useEffect(() => {
|
|
575
|
+
if (JSON.stringify(value ?? []) !== JSON.stringify(lastSent.current ?? [])) setText((value ?? []).join("\n"));
|
|
576
|
+
}, [value]);
|
|
577
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
|
|
578
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: id, children: field.label }),
|
|
579
|
+
/* @__PURE__ */ jsx(
|
|
580
|
+
Textarea,
|
|
581
|
+
{
|
|
582
|
+
id,
|
|
583
|
+
value: text,
|
|
584
|
+
rows: 4,
|
|
585
|
+
disabled: readOnly,
|
|
586
|
+
"aria-describedby": `${id}-hint`,
|
|
587
|
+
onChange: (e) => {
|
|
588
|
+
const next = e.currentTarget.value;
|
|
589
|
+
setText(next);
|
|
590
|
+
const lines = next.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
591
|
+
lastSent.current = lines;
|
|
592
|
+
onChange(lines);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
),
|
|
596
|
+
/* @__PURE__ */ jsxs("p", { id: `${id}-hint`, className: "text-sm text-muted-foreground", children: [
|
|
597
|
+
"One per line",
|
|
598
|
+
max ? `, up to ${max}` : "",
|
|
599
|
+
"."
|
|
600
|
+
] })
|
|
601
|
+
] });
|
|
602
|
+
}
|
|
603
|
+
function UnsupportedField({ field }) {
|
|
604
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
|
|
605
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-medium", children: field.label }),
|
|
606
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "This can't be edited here yet; it's kept as it is." })
|
|
607
|
+
] });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/editor/fields.ts
|
|
611
|
+
var GENERIC_TITLES = /* @__PURE__ */ new Set(["Link", "Image", "Rich text"]);
|
|
612
|
+
function humanize(key) {
|
|
613
|
+
const words = key.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim().toLowerCase().split(/\s+/).map((w) => w === "href" ? "link" : w === "cta" || w === "seo" || w === "faq" || w === "url" ? w.toUpperCase() : w);
|
|
614
|
+
const text = words.join(" ");
|
|
615
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
616
|
+
}
|
|
617
|
+
var labelFor = (key, schema) => schema.title && !GENERIC_TITLES.has(schema.title) ? schema.title : humanize(key);
|
|
618
|
+
var typeOf = (schema) => Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type;
|
|
619
|
+
var isImageSchema = (schema) => schema["x-genesis-kind"] === "image" || typeOf(schema) === "object" && Boolean(schema.properties?.src) && Boolean(schema.properties?.alt);
|
|
620
|
+
var SUMMARY_KEYS = ["title", "name", "label", "question", "heading", "value", "quote", "caption", "alt"];
|
|
621
|
+
function summaryOf(item, index) {
|
|
622
|
+
for (const key of SUMMARY_KEYS) {
|
|
623
|
+
const v = item?.[key];
|
|
624
|
+
if (typeof v === "string" && v.trim()) return v.length > 40 ? `${v.slice(0, 40)}\u2026` : v;
|
|
625
|
+
}
|
|
626
|
+
return `Item ${(index ?? 0) + 1}`;
|
|
627
|
+
}
|
|
628
|
+
function defaultValue(schema) {
|
|
629
|
+
if (schema.default !== void 0) return structuredClone(schema.default);
|
|
630
|
+
const t = typeOf(schema);
|
|
631
|
+
if (t === "object" && schema.properties && !isImageSchema(schema)) {
|
|
632
|
+
const out = {};
|
|
633
|
+
for (const key of schema.required ?? []) {
|
|
634
|
+
const v = defaultValue(schema.properties[key] ?? {});
|
|
635
|
+
if (v !== void 0) out[key] = v;
|
|
636
|
+
}
|
|
637
|
+
return out;
|
|
638
|
+
}
|
|
639
|
+
if (t === "string" && !schema.enum) return "";
|
|
640
|
+
if (t === "array") return [];
|
|
641
|
+
return void 0;
|
|
642
|
+
}
|
|
643
|
+
function options(values, required, fallback) {
|
|
644
|
+
const opts = values.map((v) => ({ label: typeof v === "string" ? humanize(v) : String(v), value: v }));
|
|
645
|
+
const empty = fallback === void 0 ? "None" : `Default (${typeof fallback === "string" ? humanize(fallback).toLowerCase() : String(fallback)})`;
|
|
646
|
+
return required ? opts : [{ label: empty, value: "" }, ...opts];
|
|
647
|
+
}
|
|
648
|
+
function fieldFor(key, schema, required, ctx) {
|
|
649
|
+
const label = labelFor(key, schema);
|
|
650
|
+
const path = ctx.path ? `${ctx.path}.${key}` : key;
|
|
651
|
+
const kind = schema["x-genesis-kind"];
|
|
652
|
+
if (kind === "slot") {
|
|
653
|
+
if (ctx.path !== "") {
|
|
654
|
+
ctx.unsupported.push(path);
|
|
655
|
+
return { type: "custom", label, render: UnsupportedField };
|
|
656
|
+
}
|
|
657
|
+
ctx.slots.push(key);
|
|
658
|
+
return { type: "slot", label, ...schema["x-genesis-allow"] ?? ctx.slotAllow ? { allow: schema["x-genesis-allow"] ?? ctx.slotAllow } : {} };
|
|
659
|
+
}
|
|
660
|
+
if (kind === "rich-text") return { type: "custom", label, render: RichTextField };
|
|
661
|
+
if (isImageSchema(schema)) return { type: "custom", label, render: ImageField, metadata: { required } };
|
|
662
|
+
const union = schema.anyOf ?? schema.oneOf;
|
|
663
|
+
if (union) {
|
|
664
|
+
const consts = union.filter((s) => "const" in s).map((s) => s.const);
|
|
665
|
+
if (consts.length === union.length && consts.length > 0) return { type: "select", label, options: options(consts, required, schema.default) };
|
|
666
|
+
const branch = union.find((s) => typeOf(s) !== "null");
|
|
667
|
+
if (branch) return fieldFor(key, { ...branch, title: schema.title ?? branch.title }, required, ctx);
|
|
668
|
+
}
|
|
669
|
+
if (schema.enum) return { type: "select", label, options: options(schema.enum, required, schema.default) };
|
|
670
|
+
switch (typeOf(schema)) {
|
|
671
|
+
case "boolean":
|
|
672
|
+
return {
|
|
673
|
+
type: "radio",
|
|
674
|
+
label,
|
|
675
|
+
// Shown when the prop is unset, so the radio says what the section does.
|
|
676
|
+
metadata: { default: schema.default === true },
|
|
677
|
+
options: [
|
|
678
|
+
{ label: "Yes", value: true },
|
|
679
|
+
{ label: "No", value: false }
|
|
680
|
+
]
|
|
681
|
+
};
|
|
682
|
+
case "number":
|
|
683
|
+
case "integer":
|
|
684
|
+
return {
|
|
685
|
+
type: "number",
|
|
686
|
+
label,
|
|
687
|
+
...typeof schema.minimum === "number" ? { min: schema.minimum } : typeof schema.exclusiveMinimum === "number" ? { min: schema.exclusiveMinimum + (typeOf(schema) === "integer" ? 1 : 0) } : {},
|
|
688
|
+
...typeof schema.maximum === "number" ? { max: schema.maximum } : {},
|
|
689
|
+
...typeOf(schema) === "integer" ? { step: 1 } : {}
|
|
690
|
+
};
|
|
691
|
+
case "string":
|
|
692
|
+
return {
|
|
693
|
+
type: schema.format !== "uri-reference" && (schema.maxLength ?? 0) > 160 ? "textarea" : "text",
|
|
694
|
+
label,
|
|
695
|
+
...schema.format === "uri-reference" ? { placeholder: "/about, #section or https://\u2026" } : {}
|
|
696
|
+
};
|
|
697
|
+
case "object": {
|
|
698
|
+
const keyed = enumKeyedProperties(schema);
|
|
699
|
+
if (keyed) return { type: "object", label, objectFields: objectFields({ ...schema, properties: keyed, required: [] }, { ...ctx, path }) };
|
|
700
|
+
if (!schema.properties) break;
|
|
701
|
+
return { type: "object", label, objectFields: objectFields(schema, { ...ctx, path }) };
|
|
702
|
+
}
|
|
703
|
+
case "array": {
|
|
704
|
+
const items = schema.items ?? {};
|
|
705
|
+
if (typeOf(items) === "string") return { type: "custom", label, render: LinesField, metadata: { maxItems: schema.maxItems } };
|
|
706
|
+
if (typeOf(items) === "object" && items.properties) {
|
|
707
|
+
const arrayFields = isImageSchema(items) ? imageItemFields(items, { ...ctx, path: `${path}[]` }) : objectFields(items, { ...ctx, path: `${path}[]` });
|
|
708
|
+
return {
|
|
709
|
+
type: "array",
|
|
710
|
+
label,
|
|
711
|
+
arrayFields,
|
|
712
|
+
defaultItemProps: defaultValue(items) ?? {},
|
|
713
|
+
getItemSummary: (item, index) => summaryOf(item, index),
|
|
714
|
+
...typeof schema.minItems === "number" ? { min: schema.minItems } : {},
|
|
715
|
+
...typeof schema.maxItems === "number" ? { max: schema.maxItems } : {}
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
ctx.unsupported.push(path);
|
|
722
|
+
return { type: "custom", label, render: UnsupportedField };
|
|
723
|
+
}
|
|
724
|
+
function enumKeyedProperties(schema) {
|
|
725
|
+
const keys = schema.propertyNames?.enum;
|
|
726
|
+
const value = schema.additionalProperties;
|
|
727
|
+
if (schema.properties || !keys?.length || !keys.every((k) => typeof k === "string") || !value || typeof value !== "object") return null;
|
|
728
|
+
return Object.fromEntries(keys.map((k) => [k, value]));
|
|
729
|
+
}
|
|
730
|
+
function objectFields(schema, ctx) {
|
|
731
|
+
const required = new Set(schema.required ?? []);
|
|
732
|
+
const out = {};
|
|
733
|
+
for (const [key, prop] of Object.entries(schema.properties ?? {})) out[key] = fieldFor(key, prop, required.has(key), ctx);
|
|
734
|
+
return out;
|
|
735
|
+
}
|
|
736
|
+
function imageItemFields(schema, ctx) {
|
|
737
|
+
const out = {
|
|
738
|
+
src: { type: "custom", label: "Image", render: ImageSrcField },
|
|
739
|
+
alt: { type: "textarea", label: "Alt text (what the image shows)" },
|
|
740
|
+
decorative: {
|
|
741
|
+
type: "radio",
|
|
742
|
+
label: "Decorative (no alt text needed)",
|
|
743
|
+
options: [
|
|
744
|
+
{ label: "Yes", value: true },
|
|
745
|
+
{ label: "No", value: false }
|
|
746
|
+
]
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
const required = new Set(schema.required ?? []);
|
|
750
|
+
for (const [key, prop] of Object.entries(schema.properties ?? {})) {
|
|
751
|
+
if (["src", "alt", "decorative", "width", "height", "focal"].includes(key)) continue;
|
|
752
|
+
out[key] = fieldFor(key, prop, required.has(key), ctx);
|
|
753
|
+
}
|
|
754
|
+
return out;
|
|
755
|
+
}
|
|
756
|
+
var warned = /* @__PURE__ */ new Set();
|
|
757
|
+
function sectionFields(def, options2 = {}) {
|
|
758
|
+
const schema = sectionJsonSchema(def);
|
|
759
|
+
const ctx = { path: "", unsupported: [], slots: [], ...options2.slotAllow ? { slotAllow: options2.slotAllow } : {} };
|
|
760
|
+
const fields = {};
|
|
761
|
+
if (def.variants.length > 1) {
|
|
762
|
+
fields._variant = { type: "select", label: "Layout", options: def.variants.map((v) => ({ label: v.label, value: v.id })) };
|
|
763
|
+
}
|
|
764
|
+
for (const reserved of NODE_KEYS) {
|
|
765
|
+
if (schema.properties?.[reserved]) throw new Error(`Section "${def.type}" has a prop named "${reserved}", which the editor reserves.`);
|
|
766
|
+
}
|
|
767
|
+
Object.assign(fields, objectFields(schema, ctx));
|
|
768
|
+
fields._hidden = {
|
|
769
|
+
type: "radio",
|
|
770
|
+
label: "Visibility",
|
|
771
|
+
options: [
|
|
772
|
+
{ label: "Shown", value: false },
|
|
773
|
+
{ label: "Hidden (kept, never published)", value: true }
|
|
774
|
+
]
|
|
775
|
+
};
|
|
776
|
+
fields._hideOn = {
|
|
777
|
+
type: "select",
|
|
778
|
+
label: "Show on",
|
|
779
|
+
options: [
|
|
780
|
+
{ label: "Every screen", value: "none" },
|
|
781
|
+
{ label: "Wider screens only", value: "mobile" },
|
|
782
|
+
{ label: "Phones only", value: "desktop" }
|
|
783
|
+
]
|
|
784
|
+
};
|
|
785
|
+
if (ctx.unsupported.length) {
|
|
786
|
+
const key = `${def.type}:${ctx.unsupported.join(",")}`;
|
|
787
|
+
if (!warned.has(key)) {
|
|
788
|
+
warned.add(key);
|
|
789
|
+
console.warn(`[genesis-sites/editor] section "${def.type}": no editor field for ${ctx.unsupported.join(", ")}; those props show as "can't be edited here".`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return { fields, slotKeys: ctx.slots, unsupported: ctx.unsupported };
|
|
793
|
+
}
|
|
794
|
+
var CATEGORY_TITLES = {
|
|
795
|
+
header: "Header",
|
|
796
|
+
intro: "Openings",
|
|
797
|
+
content: "Content",
|
|
798
|
+
"social-proof": "Social proof",
|
|
799
|
+
conversion: "Calls to action",
|
|
800
|
+
footer: "Footer"
|
|
801
|
+
};
|
|
802
|
+
var META = /* @__PURE__ */ new Set(["puck", "editMode", "id"]);
|
|
803
|
+
var SectionBoundary = class extends React7.Component {
|
|
804
|
+
state = { error: null };
|
|
805
|
+
static getDerivedStateFromError(error) {
|
|
806
|
+
return { error };
|
|
807
|
+
}
|
|
808
|
+
componentDidCatch(error) {
|
|
809
|
+
console.error(`[genesis-sites/editor] the ${this.props.label} section failed to draw.`, error);
|
|
810
|
+
}
|
|
811
|
+
render() {
|
|
812
|
+
if (this.state.error) return /* @__PURE__ */ jsx(Problem, { title: `The ${this.props.label} section couldn't be drawn`, lines: [this.state.error.message] });
|
|
813
|
+
return this.props.children;
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
function Problem({ title, lines }) {
|
|
817
|
+
return /* @__PURE__ */ jsxs("div", { role: "alert", style: { margin: "1rem", padding: "1rem 1.25rem", border: "2px dashed currentColor", borderRadius: "0.5rem", fontFamily: "system-ui, sans-serif", fontSize: "0.875rem", lineHeight: 1.5 }, children: [
|
|
818
|
+
/* @__PURE__ */ jsx("strong", { children: title }),
|
|
819
|
+
/* @__PURE__ */ jsx("ul", { style: { margin: "0.5rem 0 0", paddingLeft: "1.25rem" }, children: lines.map((l, i) => /* @__PURE__ */ jsx("li", { children: l }, i)) })
|
|
820
|
+
] });
|
|
821
|
+
}
|
|
822
|
+
function Tag({ children }) {
|
|
823
|
+
return /* @__PURE__ */ jsx("span", { style: { position: "absolute", top: "0.5rem", right: "0.5rem", zIndex: 5, padding: "0.125rem 0.5rem", borderRadius: "999px", background: "hsl(var(--foreground))", color: "hsl(var(--background))", fontFamily: "system-ui, sans-serif", fontSize: "0.75rem", fontWeight: 600 }, children });
|
|
824
|
+
}
|
|
825
|
+
function CanvasSection({ def, componentName, input, slotKeys }) {
|
|
826
|
+
const meta = input.puck?.metadata ?? {};
|
|
827
|
+
const id = String(input.id);
|
|
828
|
+
const props = { id };
|
|
829
|
+
for (const [k, v] of Object.entries(input)) if (!META.has(k) && !slotKeys.includes(k)) props[k] = v;
|
|
830
|
+
const node = itemToNode({ type: componentName, props }, { ...meta.placeholders ? { placeholders: new Map(Object.entries(meta.placeholders)) } : {} });
|
|
831
|
+
const migrated = migrateNode(node);
|
|
832
|
+
const slots = slotKeys.map((key) => {
|
|
833
|
+
const Slot = input[key];
|
|
834
|
+
return typeof Slot === "function" ? /* @__PURE__ */ jsx("div", { "aria-label": `${def.label}: ${key}`, style: { padding: "0.5rem" }, children: /* @__PURE__ */ jsx(Slot, { minEmptyHeight: 64 }) }, key) : null;
|
|
835
|
+
});
|
|
836
|
+
if (!migrated.node) {
|
|
837
|
+
return /* @__PURE__ */ jsx(Problem, { title: `Fix the ${def.label} section to show it`, lines: migrated.issues.map((i) => i.message.replace(/^Section \S+ \([^)]*\): /, "")) });
|
|
838
|
+
}
|
|
839
|
+
const ctx = {
|
|
840
|
+
headingLevel: meta.h1Id === id ? 1 : 2,
|
|
841
|
+
site: meta.site ?? { name: "", lang: "en", nav: [] },
|
|
842
|
+
pagePath: meta.pagePath ?? "/",
|
|
843
|
+
href: (href) => href,
|
|
844
|
+
forms: new Map(Object.entries(meta.forms ?? {})),
|
|
845
|
+
mode: "preview",
|
|
846
|
+
idPrefix: `gs-${id}`
|
|
847
|
+
};
|
|
848
|
+
const tags = [node.hidden ? "Hidden: not published" : null, node.hideOn?.includes("mobile") ? "Hidden on phones" : null, node.hideOn?.includes("desktop") ? "Phones only" : null, node.placeholder ? "Sample content: replace it" : null].filter(Boolean);
|
|
849
|
+
return /* @__PURE__ */ jsxs("div", { style: { position: "relative", ...node.hidden ? { opacity: 0.45 } : {} }, "data-gs-section": def.type, children: [
|
|
850
|
+
tags.length ? /* @__PURE__ */ jsx(Tag, { children: tags.join(" \xB7 ") }) : null,
|
|
851
|
+
/* @__PURE__ */ jsx(SectionBoundary, { label: def.label, children: def.render({ props: migrated.node.props, variant: migrated.node.variant, node: migrated.node, ctx }) }),
|
|
852
|
+
slots
|
|
853
|
+
] });
|
|
854
|
+
}
|
|
855
|
+
function createEditorConfig(options2 = {}) {
|
|
856
|
+
const sections = (options2.sections ?? listSections()).filter((def) => !options2.kind || !def.enabledOn || def.enabledOn.includes(options2.kind));
|
|
857
|
+
const components = {};
|
|
858
|
+
const info = /* @__PURE__ */ new Map();
|
|
859
|
+
const slots = /* @__PURE__ */ new Map();
|
|
860
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
861
|
+
const names = (placement) => [...info.entries()].filter(([, i]) => i.placement === placement).map(([n]) => n);
|
|
862
|
+
const bodyTypes = sections.filter((d) => (d.placement ?? "body") === "body").map((d) => d.type);
|
|
863
|
+
for (const def of sections) {
|
|
864
|
+
const { fields, slotKeys } = sectionFields(def, { slotAllow: bodyTypes });
|
|
865
|
+
slots.set(def.type, slotKeys);
|
|
866
|
+
const placement = def.placement ?? "body";
|
|
867
|
+
def.presets.forEach((preset, i) => {
|
|
868
|
+
const name = presetComponentName(def.type, i);
|
|
869
|
+
info.set(name, { type: def.type, label: preset.name, sectionLabel: def.label, category: def.category, placement, ...def.limit ? { limit: def.limit } : {} });
|
|
870
|
+
const list = byCategory.get(def.category) ?? [];
|
|
871
|
+
list.push(name);
|
|
872
|
+
byCategory.set(def.category, list);
|
|
873
|
+
const limit = def.limit;
|
|
874
|
+
components[name] = {
|
|
875
|
+
label: i === 0 ? def.label : `${def.label}: ${preset.name}`,
|
|
876
|
+
fields,
|
|
877
|
+
defaultProps: { ...structuredClone(preset.props), _v: def.version, _variant: preset.variant, _hidden: false, _hideOn: "none" },
|
|
878
|
+
// Read each time Puck asks (the Add picker), so a section at its limit can't be dragged in.
|
|
879
|
+
permissions: {
|
|
880
|
+
get insert() {
|
|
881
|
+
if (options2.readOnly?.()) return false;
|
|
882
|
+
return limit === void 0 || (options2.counts?.().get(def.type) ?? 0) < limit;
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
render: (input) => /* @__PURE__ */ jsx(CanvasSection, { def, componentName: name, input, slotKeys })
|
|
886
|
+
};
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
const categories = {};
|
|
890
|
+
for (const category of SECTION_CATEGORIES) {
|
|
891
|
+
const list = byCategory.get(category);
|
|
892
|
+
if (list?.length) categories[category] = { title: CATEGORY_TITLES[category], components: list };
|
|
893
|
+
}
|
|
894
|
+
const config = {
|
|
895
|
+
categories: { ...categories, other: { visible: false } },
|
|
896
|
+
components,
|
|
897
|
+
root: {
|
|
898
|
+
fields: {
|
|
899
|
+
title: { type: "text", label: "Page title" },
|
|
900
|
+
hideSiteChrome: {
|
|
901
|
+
type: "radio",
|
|
902
|
+
label: "Site header and footer",
|
|
903
|
+
options: [
|
|
904
|
+
{ label: "Show", value: false },
|
|
905
|
+
{ label: "Hide (a focused landing page)", value: true }
|
|
906
|
+
]
|
|
907
|
+
},
|
|
908
|
+
header: { type: "slot", label: "Site header", allow: names("header") },
|
|
909
|
+
footer: { type: "slot", label: "Site footer", allow: names("footer") }
|
|
910
|
+
},
|
|
911
|
+
render: ({ children, header: Header, footer: Footer, hideSiteChrome }) => /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
912
|
+
!hideSiteChrome && Header ? /* @__PURE__ */ jsx(Header, { minEmptyHeight: 48, style: { height: "auto" } }) : null,
|
|
913
|
+
/* @__PURE__ */ jsx("main", { id: "main", children }),
|
|
914
|
+
!hideSiteChrome && Footer ? /* @__PURE__ */ jsx(Footer, { minEmptyHeight: 48, style: { height: "auto" } }) : null
|
|
915
|
+
] })
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
return { config, components: info, slotKeys: (type) => slots.get(sectionTypeOf(type)) ?? [] };
|
|
919
|
+
}
|
|
920
|
+
var usePuck = createUsePuck();
|
|
921
|
+
var EditorLockContext = React7.createContext(false);
|
|
922
|
+
var fieldId = (p) => p.id ?? `gs-field-${p.name.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
|
923
|
+
function FieldShell({ id, label, children }) {
|
|
924
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5 py-1", children: [
|
|
925
|
+
label ? /* @__PURE__ */ jsx(Label, { htmlFor: id, children: label }) : null,
|
|
926
|
+
children
|
|
927
|
+
] });
|
|
928
|
+
}
|
|
929
|
+
function TextInput(p) {
|
|
930
|
+
const id = fieldId(p);
|
|
931
|
+
return /* @__PURE__ */ jsx(FieldShell, { id, label: p.field.label, children: /* @__PURE__ */ jsx(Input, { id, name: p.name, value: typeof p.value === "string" ? p.value : "", placeholder: p.field.placeholder, disabled: p.readOnly, onChange: (e) => p.onChange(e.currentTarget.value) }) });
|
|
932
|
+
}
|
|
933
|
+
function TextareaInput(p) {
|
|
934
|
+
const id = fieldId(p);
|
|
935
|
+
return /* @__PURE__ */ jsx(FieldShell, { id, label: p.field.label, children: /* @__PURE__ */ jsx(Textarea, { id, name: p.name, rows: 3, value: typeof p.value === "string" ? p.value : "", placeholder: p.field.placeholder, disabled: p.readOnly, onChange: (e) => p.onChange(e.currentTarget.value) }) });
|
|
936
|
+
}
|
|
937
|
+
function NumberInput(p) {
|
|
938
|
+
const id = fieldId(p);
|
|
939
|
+
return /* @__PURE__ */ jsx(FieldShell, { id, label: p.field.label, children: /* @__PURE__ */ jsx(
|
|
940
|
+
Input,
|
|
941
|
+
{
|
|
942
|
+
id,
|
|
943
|
+
name: p.name,
|
|
944
|
+
type: "number",
|
|
945
|
+
inputMode: "decimal",
|
|
946
|
+
value: typeof p.value === "number" ? String(p.value) : "",
|
|
947
|
+
min: p.field.min,
|
|
948
|
+
max: p.field.max,
|
|
949
|
+
step: p.field.step,
|
|
950
|
+
disabled: p.readOnly,
|
|
951
|
+
onChange: (e) => {
|
|
952
|
+
const raw = e.currentTarget.value;
|
|
953
|
+
p.onChange(raw === "" ? void 0 : Number(raw));
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
) });
|
|
957
|
+
}
|
|
958
|
+
var indexOf = (options2, value) => {
|
|
959
|
+
const i = options2.findIndex((o) => o.value === value || value === void 0 && (o.value === "" || o.value === false));
|
|
960
|
+
return i < 0 ? "" : String(i);
|
|
961
|
+
};
|
|
962
|
+
function SelectInput(p) {
|
|
963
|
+
const id = fieldId(p);
|
|
964
|
+
const options2 = p.field.options ?? [];
|
|
965
|
+
return /* @__PURE__ */ jsx(FieldShell, { id, label: p.field.label, children: /* @__PURE__ */ jsxs(Select, { value: indexOf(options2, p.value), disabled: p.readOnly, onValueChange: (i) => p.onChange(options2[Number(i)]?.value), children: [
|
|
966
|
+
/* @__PURE__ */ jsx(SelectTrigger, { id, "aria-label": p.field.label, children: /* @__PURE__ */ jsx(SelectValue, { placeholder: "Choose\u2026" }) }),
|
|
967
|
+
/* @__PURE__ */ jsx(SelectContent, { children: options2.map((o, i) => /* @__PURE__ */ jsx(SelectItem, { value: String(i), children: o.label }, i)) })
|
|
968
|
+
] }) });
|
|
969
|
+
}
|
|
970
|
+
function RadioInput(p) {
|
|
971
|
+
const id = fieldId(p);
|
|
972
|
+
const options2 = p.field.options ?? [];
|
|
973
|
+
const fallback = p.field.metadata?.default;
|
|
974
|
+
const value = p.value === void 0 && fallback !== void 0 ? fallback : p.value;
|
|
975
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5 py-1", role: "group", "aria-labelledby": `${id}-label`, children: [
|
|
976
|
+
/* @__PURE__ */ jsx("span", { id: `${id}-label`, className: "text-sm font-medium leading-none", children: p.field.label }),
|
|
977
|
+
/* @__PURE__ */ jsx(RadioGroup, { value: indexOf(options2, value), disabled: p.readOnly, onValueChange: (i) => p.onChange(options2[Number(i)]?.value), className: "flex flex-wrap gap-4", "aria-labelledby": `${id}-label`, children: options2.map((o, i) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
978
|
+
/* @__PURE__ */ jsx(RadioGroupItem, { id: `${id}-${i}`, value: String(i) }),
|
|
979
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-${i}`, className: "font-normal", children: o.label })
|
|
980
|
+
] }, i)) })
|
|
981
|
+
] });
|
|
982
|
+
}
|
|
983
|
+
function ComponentItem({ name, info, announce }) {
|
|
984
|
+
const data = usePuck((s) => s.appState.data);
|
|
985
|
+
const meta = info.get(name);
|
|
986
|
+
const type = sectionTypeOf(name);
|
|
987
|
+
const used = React7.useMemo(() => countTypes(data).get(type) ?? 0, [data, type]);
|
|
988
|
+
const full = meta?.limit !== void 0 && used >= meta.limit;
|
|
989
|
+
const dispatch = usePuck((s) => s.dispatch);
|
|
990
|
+
const locked = React7.useContext(EditorLockContext);
|
|
991
|
+
const label = meta?.label ?? name;
|
|
992
|
+
const add = () => {
|
|
993
|
+
if (locked) return;
|
|
994
|
+
const zone = meta?.placement === "header" ? "root:header" : meta?.placement === "footer" ? "root:footer" : "root:default-zone";
|
|
995
|
+
const list = zone === "root:header" ? data.root.props.header : zone === "root:footer" ? data.root.props.footer : data.content;
|
|
996
|
+
const index = list?.length ?? 0;
|
|
997
|
+
const id = newSectionId(type.replace(/-/g, "").slice(0, 6));
|
|
998
|
+
dispatch({ type: "insert", componentType: name, destinationIndex: index, destinationZone: zone, id });
|
|
999
|
+
dispatch({ type: "setUi", ui: { itemSelector: { index, zone } } });
|
|
1000
|
+
scrollCanvasTo(id, ref.current?.ownerDocument);
|
|
1001
|
+
announce?.(`${label} added at the end of the ${zone === "root:header" ? "header" : zone === "root:footer" ? "footer" : "page"}. Its fields are open; Up and Down move it.`);
|
|
1002
|
+
};
|
|
1003
|
+
const ref = React7.useRef(null);
|
|
1004
|
+
React7.useEffect(() => {
|
|
1005
|
+
const copy = ref.current?.closest('[class*="draggableBg"]');
|
|
1006
|
+
if (copy) {
|
|
1007
|
+
copy.setAttribute("aria-hidden", "true");
|
|
1008
|
+
copy.inert = true;
|
|
1009
|
+
}
|
|
1010
|
+
}, []);
|
|
1011
|
+
return /* @__PURE__ */ jsxs(
|
|
1012
|
+
"div",
|
|
1013
|
+
{
|
|
1014
|
+
ref,
|
|
1015
|
+
className: cn(
|
|
1016
|
+
"flex w-full items-center justify-between gap-2 rounded-md border border-border bg-background px-3 py-2 text-left text-sm text-foreground",
|
|
1017
|
+
full ? "cursor-not-allowed opacity-60" : "cursor-grab hover:border-primary"
|
|
1018
|
+
),
|
|
1019
|
+
title: full ? `A page has at most ${meta?.limit} of these` : `Drag onto the page, or press Add: ${label}`,
|
|
1020
|
+
children: [
|
|
1021
|
+
/* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-col", children: [
|
|
1022
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium", children: label }),
|
|
1023
|
+
meta && meta.label !== meta.sectionLabel ? /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: meta.sectionLabel }) : null
|
|
1024
|
+
] }),
|
|
1025
|
+
full ? /* @__PURE__ */ jsx(Badge, { variant: "secondary", className: "shrink-0", children: "Limit reached" }) : /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "outline", className: "h-7 shrink-0 px-2", "aria-label": `Add ${label}`, disabled: locked, onPointerDown: (e) => e.stopPropagation(), onClick: add, children: "Add" })
|
|
1026
|
+
]
|
|
1027
|
+
}
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
function SectionActions({ info, announce }) {
|
|
1031
|
+
const selected = usePuck((s) => s.selectedItem);
|
|
1032
|
+
const getSelectorForId = usePuck((s) => s.getSelectorForId);
|
|
1033
|
+
const getItemBySelector = usePuck((s) => s.getItemBySelector);
|
|
1034
|
+
const dispatch = usePuck((s) => s.dispatch);
|
|
1035
|
+
const locked = React7.useContext(EditorLockContext);
|
|
1036
|
+
const upRef = React7.useRef(null);
|
|
1037
|
+
const downRef = React7.useRef(null);
|
|
1038
|
+
if (!selected) return null;
|
|
1039
|
+
const id = String(selected.props.id);
|
|
1040
|
+
const selector = getSelectorForId(id);
|
|
1041
|
+
if (!selector) return null;
|
|
1042
|
+
const label = info.get(selected.type)?.sectionLabel ?? selected.type;
|
|
1043
|
+
const { index, zone } = selector;
|
|
1044
|
+
const canUp = index > 0;
|
|
1045
|
+
const canDown = Boolean(getItemBySelector({ index: index + 1, zone }));
|
|
1046
|
+
const move = (by) => {
|
|
1047
|
+
if (locked) return;
|
|
1048
|
+
const to = index + by;
|
|
1049
|
+
dispatch({ type: "reorder", sourceIndex: index, destinationIndex: to, destinationZone: zone });
|
|
1050
|
+
dispatch({ type: "setUi", ui: { itemSelector: { index: to, zone } } });
|
|
1051
|
+
scrollCanvasTo(id);
|
|
1052
|
+
announce?.(`${label} moved ${by < 0 ? "up" : "down"}, to position ${to + 1}.`);
|
|
1053
|
+
requestAnimationFrame(() => {
|
|
1054
|
+
const pressed = by < 0 ? upRef.current : downRef.current;
|
|
1055
|
+
if (pressed?.disabled) (by < 0 ? downRef.current : upRef.current)?.focus();
|
|
1056
|
+
});
|
|
1057
|
+
};
|
|
1058
|
+
return /* @__PURE__ */ jsxs("div", { className: "mb-2 flex flex-wrap items-center justify-between gap-2 border-b border-border px-1 pb-2", role: "group", "aria-label": `${label} section`, children: [
|
|
1059
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-semibold", children: label }),
|
|
1060
|
+
/* @__PURE__ */ jsxs("div", { className: "flex gap-1", children: [
|
|
1061
|
+
/* @__PURE__ */ jsx(Button, { ref: upRef, type: "button", size: "sm", variant: "outline", disabled: locked || !canUp, onClick: () => move(-1), "aria-label": `Move ${label} up`, children: "Up" }),
|
|
1062
|
+
/* @__PURE__ */ jsx(Button, { ref: downRef, type: "button", size: "sm", variant: "outline", disabled: locked || !canDown, onClick: () => move(1), "aria-label": `Move ${label} down`, children: "Down" }),
|
|
1063
|
+
/* @__PURE__ */ jsx(
|
|
1064
|
+
Button,
|
|
1065
|
+
{
|
|
1066
|
+
type: "button",
|
|
1067
|
+
size: "sm",
|
|
1068
|
+
variant: "outline",
|
|
1069
|
+
"aria-label": `Remove ${label}`,
|
|
1070
|
+
disabled: locked,
|
|
1071
|
+
onClick: () => {
|
|
1072
|
+
if (locked) return;
|
|
1073
|
+
dispatch({ type: "remove", index, zone });
|
|
1074
|
+
dispatch({ type: "setUi", ui: { itemSelector: null } });
|
|
1075
|
+
announce?.(`${label} removed. Undo brings it back.`);
|
|
1076
|
+
},
|
|
1077
|
+
children: "Remove"
|
|
1078
|
+
}
|
|
1079
|
+
)
|
|
1080
|
+
] })
|
|
1081
|
+
] });
|
|
1082
|
+
}
|
|
1083
|
+
function genesisOverrides(info, extra = {}, options2 = {}) {
|
|
1084
|
+
return {
|
|
1085
|
+
fields: ({ children }) => /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1086
|
+
/* @__PURE__ */ jsx(SectionActions, { info, announce: options2.announce }),
|
|
1087
|
+
children
|
|
1088
|
+
] }),
|
|
1089
|
+
fieldTypes: {
|
|
1090
|
+
text: TextInput,
|
|
1091
|
+
textarea: TextareaInput,
|
|
1092
|
+
number: NumberInput,
|
|
1093
|
+
select: SelectInput,
|
|
1094
|
+
radio: RadioInput
|
|
1095
|
+
},
|
|
1096
|
+
fieldLabel: ({ children, label, el, className }) => {
|
|
1097
|
+
const El = el === "div" ? "div" : "label";
|
|
1098
|
+
return /* @__PURE__ */ jsxs(El, { className: cn("flex flex-col gap-1.5 py-1 text-sm font-medium", className), children: [
|
|
1099
|
+
/* @__PURE__ */ jsx("span", { children: label }),
|
|
1100
|
+
children
|
|
1101
|
+
] });
|
|
1102
|
+
},
|
|
1103
|
+
drawerItem: ({ name }) => /* @__PURE__ */ jsx(ComponentItem, { name, info, announce: options2.announce }),
|
|
1104
|
+
...extra
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// src/editor/site-actions.ts
|
|
1109
|
+
async function checkSite(store, siteId, options2 = {}) {
|
|
1110
|
+
const draft = await store.getDraft(siteId);
|
|
1111
|
+
if (!draft) return { publishable: false, issues: [{ severity: "error", code: "no-site", message: `There's no site ${siteId}, or you can't edit it.` }] };
|
|
1112
|
+
const metas = await store.listPages(siteId);
|
|
1113
|
+
const drafts = await Promise.all(metas.map((meta) => store.getPageDraft(meta.id)));
|
|
1114
|
+
const pages = [];
|
|
1115
|
+
drafts.forEach((p, i) => {
|
|
1116
|
+
if (p) pages.push({ id: metas[i].id, path: p.path, kind: p.kind, doc: p.doc });
|
|
1117
|
+
});
|
|
1118
|
+
let issues;
|
|
1119
|
+
try {
|
|
1120
|
+
issues = prepareRelease(draft.site, pages, { reserved: options2.reserved ?? DEFAULT_RESERVED_PATHS, ...options2.brand ? { brand: options2.brand } : {} }).issues;
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
if (!(err instanceof PublishBlockedError)) {
|
|
1123
|
+
issues = [{ severity: "error", code: "invalid-draft", message: `The drafts can't be checked: ${err.message}` }];
|
|
1124
|
+
} else issues = err.issues;
|
|
1125
|
+
}
|
|
1126
|
+
const sorted = [...issues].sort((a, b) => a.severity === b.severity ? 0 : a.severity === "error" ? -1 : 1);
|
|
1127
|
+
return { issues: sorted, publishable: !sorted.some((i) => i.severity === "error") };
|
|
1128
|
+
}
|
|
1129
|
+
async function publishSite(store, siteId, options2) {
|
|
1130
|
+
const check = await checkSite(store, siteId, options2);
|
|
1131
|
+
if (!check.publishable) return { blocked: check.issues };
|
|
1132
|
+
try {
|
|
1133
|
+
const release = await store.publish(siteId, { brand: options2.brand, ...options2.note?.trim() ? { note: options2.note.trim() } : {}, ...options2.reserved ? { reserved: options2.reserved } : {} });
|
|
1134
|
+
return { release };
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
if (err instanceof PublishBlockedError) return { blocked: err.issues };
|
|
1137
|
+
throw err;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
function pathProblems(path, pages, options2 = {}) {
|
|
1141
|
+
const problems = pagePathProblems(path, options2.reserved ?? DEFAULT_RESERVED_PATHS);
|
|
1142
|
+
if (pages.some((p) => p.path === path && p.id !== options2.except)) problems.push(`A page is already at ${path}.`);
|
|
1143
|
+
return problems;
|
|
1144
|
+
}
|
|
1145
|
+
function slugify(title) {
|
|
1146
|
+
const slug = title.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "");
|
|
1147
|
+
return `/${slug || "page"}`;
|
|
1148
|
+
}
|
|
1149
|
+
async function renamePage(store, pageId, path, options2 = {}) {
|
|
1150
|
+
const current = await store.getPageDraft(pageId);
|
|
1151
|
+
if (!current) throw new Error("That page no longer exists.");
|
|
1152
|
+
if (current.path === "/") throw new Error("The home page stays at /.");
|
|
1153
|
+
const pages = await store.listPages(current.siteId);
|
|
1154
|
+
const problems = pathProblems(path, pages, { ...options2, except: pageId });
|
|
1155
|
+
if (problems.length) throw new Error(problems.join(" "));
|
|
1156
|
+
return store.movePage(pageId, path);
|
|
1157
|
+
}
|
|
1158
|
+
var blank = (title) => ({ schemaVersion: 1, root: { props: { title } }, content: [] });
|
|
1159
|
+
function pageTemplates() {
|
|
1160
|
+
const templates = [
|
|
1161
|
+
{ id: "blank", label: "Blank page", description: "Start empty and add sections.", kind: "page", doc: ({ title }) => blank(title) },
|
|
1162
|
+
{
|
|
1163
|
+
id: "landing",
|
|
1164
|
+
label: "Landing page from your brand",
|
|
1165
|
+
description: "A hero, what you offer, quotes and a call to action, in one click.",
|
|
1166
|
+
kind: "landing",
|
|
1167
|
+
doc: ({ title, brand }) => {
|
|
1168
|
+
const doc = landingPage({ brand, message: { headline: title } }).pages[0].doc;
|
|
1169
|
+
return { ...doc, root: { props: { ...doc.root.props, title } } };
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
];
|
|
1173
|
+
const starter = starterSite({ brand: exampleBrands.riverside, message: { headline: "Your headline" } });
|
|
1174
|
+
for (const page of starter.pages.slice(1)) {
|
|
1175
|
+
const name = page.doc.root.props.title;
|
|
1176
|
+
templates.push({
|
|
1177
|
+
id: `starter${page.path.replace(/\//g, "-")}`,
|
|
1178
|
+
label: `${name} page`,
|
|
1179
|
+
description: `The starter site's ${name.toLowerCase()} page.`,
|
|
1180
|
+
kind: page.kind,
|
|
1181
|
+
doc: ({ title, brand }) => {
|
|
1182
|
+
const fresh = starterSite({ brand, message: { headline: title } }).pages.find((p) => p.path === page.path).doc;
|
|
1183
|
+
return { ...fresh, root: { props: { ...fresh.root.props, title } } };
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
return templates;
|
|
1188
|
+
}
|
|
1189
|
+
async function movePageInList(store, siteId, pageId, by) {
|
|
1190
|
+
if (!store.reorderPages) throw new Error("This store keeps pages in its own order.");
|
|
1191
|
+
const pages = await store.listPages(siteId);
|
|
1192
|
+
const i = pages.findIndex((p) => p.id === pageId);
|
|
1193
|
+
const j = i + by;
|
|
1194
|
+
if (i < 0 || j < 0 || j >= pages.length) return pages;
|
|
1195
|
+
const ids = pages.map((p) => p.id);
|
|
1196
|
+
[ids[i], ids[j]] = [ids[j], ids[i]];
|
|
1197
|
+
await store.reorderPages(siteId, ids);
|
|
1198
|
+
return store.listPages(siteId);
|
|
1199
|
+
}
|
|
1200
|
+
function AddPageDialog({ open, onOpenChange, pages, reserved, onCreate }) {
|
|
1201
|
+
const templates = React7.useMemo(() => pageTemplates(), []);
|
|
1202
|
+
const [title, setTitle] = React7.useState("");
|
|
1203
|
+
const [path, setPath] = React7.useState("");
|
|
1204
|
+
const [pathEdited, setPathEdited] = React7.useState(false);
|
|
1205
|
+
const [templateId, setTemplateId] = React7.useState("blank");
|
|
1206
|
+
const [addToMenu, setAddToMenu] = React7.useState(true);
|
|
1207
|
+
const [error, setError] = React7.useState(null);
|
|
1208
|
+
const [busy, setBusy] = React7.useState(false);
|
|
1209
|
+
const id = React7.useId();
|
|
1210
|
+
React7.useEffect(() => {
|
|
1211
|
+
if (open) {
|
|
1212
|
+
setTitle("");
|
|
1213
|
+
setPath("");
|
|
1214
|
+
setPathEdited(false);
|
|
1215
|
+
setTemplateId("blank");
|
|
1216
|
+
setAddToMenu(true);
|
|
1217
|
+
setError(null);
|
|
1218
|
+
}
|
|
1219
|
+
}, [open]);
|
|
1220
|
+
const problems = path ? pathProblems(path, pages, { ...reserved ? { reserved } : {} }) : [];
|
|
1221
|
+
const submit = async (e) => {
|
|
1222
|
+
e.preventDefault();
|
|
1223
|
+
if (!title.trim()) return setError("Give the page a title.");
|
|
1224
|
+
if (problems.length) return setError(problems.join(" "));
|
|
1225
|
+
setBusy(true);
|
|
1226
|
+
try {
|
|
1227
|
+
await onCreate({ title: title.trim(), path, template: templates.find((t) => t.id === templateId), addToMenu });
|
|
1228
|
+
onOpenChange(false);
|
|
1229
|
+
} catch (err) {
|
|
1230
|
+
setError(err.message);
|
|
1231
|
+
} finally {
|
|
1232
|
+
setBusy(false);
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsx(DialogContent, { className: "sm:max-w-lg", children: /* @__PURE__ */ jsxs("form", { onSubmit: submit, className: "flex flex-col gap-4", children: [
|
|
1236
|
+
/* @__PURE__ */ jsxs(DialogHeader, { children: [
|
|
1237
|
+
/* @__PURE__ */ jsx(DialogTitle, { children: "Add a page" }),
|
|
1238
|
+
/* @__PURE__ */ jsx(DialogDescription, { children: "Start blank, from a template, or with a landing page made from your brand." })
|
|
1239
|
+
] }),
|
|
1240
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
|
|
1241
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-title`, children: "Title" }),
|
|
1242
|
+
/* @__PURE__ */ jsx(
|
|
1243
|
+
Input,
|
|
1244
|
+
{
|
|
1245
|
+
id: `${id}-title`,
|
|
1246
|
+
value: title,
|
|
1247
|
+
maxLength: 120,
|
|
1248
|
+
autoFocus: true,
|
|
1249
|
+
onChange: (e) => {
|
|
1250
|
+
const t = e.currentTarget.value;
|
|
1251
|
+
setTitle(t);
|
|
1252
|
+
if (!pathEdited) setPath(t.trim() ? slugify(t) : "");
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
)
|
|
1256
|
+
] }),
|
|
1257
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
|
|
1258
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-path`, children: "Address" }),
|
|
1259
|
+
/* @__PURE__ */ jsx(
|
|
1260
|
+
Input,
|
|
1261
|
+
{
|
|
1262
|
+
id: `${id}-path`,
|
|
1263
|
+
value: path,
|
|
1264
|
+
placeholder: "/about",
|
|
1265
|
+
"aria-invalid": problems.length > 0 || void 0,
|
|
1266
|
+
"aria-describedby": `${id}-path-hint`,
|
|
1267
|
+
onChange: (e) => {
|
|
1268
|
+
setPath(e.currentTarget.value);
|
|
1269
|
+
setPathEdited(true);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
),
|
|
1273
|
+
/* @__PURE__ */ jsx("p", { id: `${id}-path-hint`, className: problems.length ? "text-sm text-destructive" : "text-sm text-muted-foreground", children: problems.length ? problems.join(" ") : "Lowercase letters, digits and hyphens, starting with /." })
|
|
1274
|
+
] }),
|
|
1275
|
+
/* @__PURE__ */ jsxs("fieldset", { className: "flex flex-col gap-2", children: [
|
|
1276
|
+
/* @__PURE__ */ jsx("legend", { className: "mb-2 text-sm font-medium", children: "Start from" }),
|
|
1277
|
+
/* @__PURE__ */ jsx(RadioGroup, { value: templateId, onValueChange: setTemplateId, className: "flex flex-col gap-2", children: templates.map((t) => /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-3 rounded-md border border-border p-3", children: [
|
|
1278
|
+
/* @__PURE__ */ jsx(RadioGroupItem, { id: `${id}-t-${t.id}`, value: t.id, className: "mt-0.5", "aria-describedby": `${id}-t-${t.id}-d` }),
|
|
1279
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col", children: [
|
|
1280
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-t-${t.id}`, children: t.label }),
|
|
1281
|
+
/* @__PURE__ */ jsx("span", { id: `${id}-t-${t.id}-d`, className: "text-sm text-muted-foreground", children: t.description })
|
|
1282
|
+
] })
|
|
1283
|
+
] }, t.id)) })
|
|
1284
|
+
] }),
|
|
1285
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
1286
|
+
/* @__PURE__ */ jsx(Switch, { id: `${id}-menu`, checked: addToMenu, onCheckedChange: setAddToMenu }),
|
|
1287
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-menu`, children: "Add it to the menu" })
|
|
1288
|
+
] }),
|
|
1289
|
+
error ? /* @__PURE__ */ jsx("p", { role: "alert", className: "text-sm text-destructive", children: error }) : null,
|
|
1290
|
+
/* @__PURE__ */ jsxs(DialogFooter, { children: [
|
|
1291
|
+
/* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: () => onOpenChange(false), children: "Cancel" }),
|
|
1292
|
+
/* @__PURE__ */ jsx(Button, { type: "submit", loading: busy, children: "Add page" })
|
|
1293
|
+
] })
|
|
1294
|
+
] }) }) });
|
|
1295
|
+
}
|
|
1296
|
+
function RenameDialog({ page, pages, reserved, onClose, onRename }) {
|
|
1297
|
+
const [path, setPath] = React7.useState("");
|
|
1298
|
+
const [error, setError] = React7.useState(null);
|
|
1299
|
+
const [busy, setBusy] = React7.useState(false);
|
|
1300
|
+
const id = React7.useId();
|
|
1301
|
+
React7.useEffect(() => {
|
|
1302
|
+
setPath(page?.path ?? "");
|
|
1303
|
+
setError(null);
|
|
1304
|
+
}, [page]);
|
|
1305
|
+
if (!page) return null;
|
|
1306
|
+
const problems = path && path !== page.path ? pathProblems(path, pages, { except: page.id, ...reserved ? { reserved } : {} }) : [];
|
|
1307
|
+
return /* @__PURE__ */ jsx(Dialog, { open: true, onOpenChange: (o) => o ? null : onClose(), children: /* @__PURE__ */ jsx(DialogContent, { className: "sm:max-w-md", children: /* @__PURE__ */ jsxs(
|
|
1308
|
+
"form",
|
|
1309
|
+
{
|
|
1310
|
+
className: "flex flex-col gap-4",
|
|
1311
|
+
onSubmit: async (e) => {
|
|
1312
|
+
e.preventDefault();
|
|
1313
|
+
if (path === page.path) return onClose();
|
|
1314
|
+
if (problems.length) return setError(problems.join(" "));
|
|
1315
|
+
setBusy(true);
|
|
1316
|
+
try {
|
|
1317
|
+
await onRename(page.id, path);
|
|
1318
|
+
onClose();
|
|
1319
|
+
} catch (err) {
|
|
1320
|
+
setError(err.message);
|
|
1321
|
+
} finally {
|
|
1322
|
+
setBusy(false);
|
|
1323
|
+
}
|
|
1324
|
+
},
|
|
1325
|
+
children: [
|
|
1326
|
+
/* @__PURE__ */ jsxs(DialogHeader, { children: [
|
|
1327
|
+
/* @__PURE__ */ jsxs(DialogTitle, { children: [
|
|
1328
|
+
"Change the address of \u201C",
|
|
1329
|
+
page.title,
|
|
1330
|
+
"\u201D"
|
|
1331
|
+
] }),
|
|
1332
|
+
/* @__PURE__ */ jsxs(DialogDescription, { children: [
|
|
1333
|
+
"The old address, ",
|
|
1334
|
+
page.path,
|
|
1335
|
+
", will redirect here, so links and search results keep working."
|
|
1336
|
+
] })
|
|
1337
|
+
] }),
|
|
1338
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
|
|
1339
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-path`, children: "New address" }),
|
|
1340
|
+
/* @__PURE__ */ jsx(Input, { id: `${id}-path`, value: path, autoFocus: true, "aria-invalid": problems.length > 0 || void 0, "aria-describedby": `${id}-hint`, onChange: (e) => setPath(e.currentTarget.value) }),
|
|
1341
|
+
/* @__PURE__ */ jsx("p", { id: `${id}-hint`, className: problems.length ? "text-sm text-destructive" : "text-sm text-muted-foreground", children: problems.length ? problems.join(" ") : "Lowercase letters, digits and hyphens, starting with /." })
|
|
1342
|
+
] }),
|
|
1343
|
+
error ? /* @__PURE__ */ jsx("p", { role: "alert", className: "text-sm text-destructive", children: error }) : null,
|
|
1344
|
+
/* @__PURE__ */ jsxs(DialogFooter, { children: [
|
|
1345
|
+
/* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: onClose, children: "Cancel" }),
|
|
1346
|
+
/* @__PURE__ */ jsx(Button, { type: "submit", loading: busy, children: "Change address" })
|
|
1347
|
+
] })
|
|
1348
|
+
]
|
|
1349
|
+
}
|
|
1350
|
+
) }) });
|
|
1351
|
+
}
|
|
1352
|
+
function menuItemProblems(item) {
|
|
1353
|
+
const out = [];
|
|
1354
|
+
if (!item.label.trim()) out.push("Needs a label.");
|
|
1355
|
+
else if (item.label.trim().length > 40) out.push("At most 40 characters.");
|
|
1356
|
+
if (!isSafeHref(item.href.trim())) out.push("Use a site path (/about), a #section, or an https, mailto or tel link.");
|
|
1357
|
+
return out;
|
|
1358
|
+
}
|
|
1359
|
+
function MenuEditor({ nav, pages, busy, onChange }) {
|
|
1360
|
+
const items = nav;
|
|
1361
|
+
const id = React7.useId();
|
|
1362
|
+
const list = React7.useRef(null);
|
|
1363
|
+
const commit = (next) => onChange(next);
|
|
1364
|
+
const move = (i, by) => {
|
|
1365
|
+
const next = [...items];
|
|
1366
|
+
const j = i + by;
|
|
1367
|
+
if (j < 0 || j >= next.length) return;
|
|
1368
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
1369
|
+
commit(next);
|
|
1370
|
+
requestAnimationFrame(() => {
|
|
1371
|
+
const same = list.current?.querySelector(`[data-menu-move="${j}:${by}"]`);
|
|
1372
|
+
(same && !same.disabled ? same : list.current?.querySelector(`[data-menu-move="${j}:${-by}"]`))?.focus();
|
|
1373
|
+
});
|
|
1374
|
+
};
|
|
1375
|
+
const unlinked = pages.filter((p) => !items.some((i) => i.href === p.path));
|
|
1376
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3", children: [
|
|
1377
|
+
items.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "The menu is empty." }) : null,
|
|
1378
|
+
/* @__PURE__ */ jsx("ol", { ref: list, className: "flex flex-col gap-3", children: items.map((item, i) => {
|
|
1379
|
+
const problems = menuItemProblems(item);
|
|
1380
|
+
return /* @__PURE__ */ jsxs("li", { className: "flex flex-col gap-2 rounded-md border border-border p-2", children: [
|
|
1381
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
|
|
1382
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-${i}-label`, className: "text-xs", children: "Label" }),
|
|
1383
|
+
/* @__PURE__ */ jsx(Input, { id: `${id}-${i}-label`, value: item.label, maxLength: 40, disabled: busy, onChange: (e) => commit(items.map((x, k) => k === i ? { ...x, label: e.currentTarget.value } : x)) })
|
|
1384
|
+
] }),
|
|
1385
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
|
|
1386
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-${i}-href`, className: "text-xs", children: "Link" }),
|
|
1387
|
+
/* @__PURE__ */ jsx(Input, { id: `${id}-${i}-href`, value: item.href, disabled: busy, "aria-invalid": problems.length > 0 || void 0, "aria-describedby": `${id}-${i}-err`, onChange: (e) => commit(items.map((x, k) => k === i ? { ...x, href: e.currentTarget.value } : x)) })
|
|
1388
|
+
] }),
|
|
1389
|
+
problems.length ? /* @__PURE__ */ jsxs("p", { id: `${id}-${i}-err`, className: "text-xs text-destructive", children: [
|
|
1390
|
+
problems.join(" "),
|
|
1391
|
+
" Not saved until fixed."
|
|
1392
|
+
] }) : null,
|
|
1393
|
+
/* @__PURE__ */ jsxs("div", { className: "flex gap-1", children: [
|
|
1394
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "icon", variant: "ghost", "data-menu-move": `${i}:-1`, "aria-label": `Move ${item.label || "item"} up`, disabled: busy || i === 0, onClick: () => move(i, -1), children: /* @__PURE__ */ jsx(ArrowUp, { className: "h-4 w-4" }) }),
|
|
1395
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "icon", variant: "ghost", "data-menu-move": `${i}:1`, "aria-label": `Move ${item.label || "item"} down`, disabled: busy || i === items.length - 1, onClick: () => move(i, 1), children: /* @__PURE__ */ jsx(ArrowDown, { className: "h-4 w-4" }) }),
|
|
1396
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "icon", variant: "ghost", "aria-label": `Remove ${item.label || "item"} from the menu`, disabled: busy, onClick: () => commit(items.filter((_, k) => k !== i)), children: /* @__PURE__ */ jsx(Trash2, { className: "h-4 w-4" }) })
|
|
1397
|
+
] })
|
|
1398
|
+
] }, i);
|
|
1399
|
+
}) }),
|
|
1400
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
|
|
1401
|
+
unlinked.slice(0, 4).map((p) => /* @__PURE__ */ jsxs(Button, { type: "button", size: "sm", variant: "outline", disabled: busy || items.length >= 12, onClick: () => commit([...items, { label: p.title.slice(0, 40), href: p.path }]), children: [
|
|
1402
|
+
/* @__PURE__ */ jsx(Plus, { className: "mr-1 h-3 w-3" }),
|
|
1403
|
+
" ",
|
|
1404
|
+
p.title
|
|
1405
|
+
] }, p.id)),
|
|
1406
|
+
/* @__PURE__ */ jsxs(Button, { type: "button", size: "sm", variant: "outline", disabled: busy || items.length >= 12, onClick: () => commit([...items, { label: "", href: "" }]), children: [
|
|
1407
|
+
/* @__PURE__ */ jsx(Plus, { className: "mr-1 h-3 w-3" }),
|
|
1408
|
+
" Link"
|
|
1409
|
+
] })
|
|
1410
|
+
] })
|
|
1411
|
+
] });
|
|
1412
|
+
}
|
|
1413
|
+
function PagesPanel(props) {
|
|
1414
|
+
const [adding, setAdding] = React7.useState(false);
|
|
1415
|
+
const [renaming, setRenaming] = React7.useState(null);
|
|
1416
|
+
const [deleting, setDeleting] = React7.useState(null);
|
|
1417
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-4 p-3", children: [
|
|
1418
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
|
|
1419
|
+
/* @__PURE__ */ jsx("h2", { className: "text-sm font-semibold", children: "Pages" }),
|
|
1420
|
+
props.readOnly ? null : /* @__PURE__ */ jsxs(Button, { type: "button", size: "sm", onClick: () => setAdding(true), disabled: props.busy, children: [
|
|
1421
|
+
/* @__PURE__ */ jsx(Plus, { className: "mr-1 h-4 w-4" }),
|
|
1422
|
+
" Add page"
|
|
1423
|
+
] })
|
|
1424
|
+
] }),
|
|
1425
|
+
/* @__PURE__ */ jsx("ul", { className: "flex flex-col gap-1", "aria-label": "Pages", children: props.pages.map((p, i) => {
|
|
1426
|
+
const current = p.id === props.currentId;
|
|
1427
|
+
return /* @__PURE__ */ jsxs("li", { className: cn("group flex flex-col gap-1 rounded-md border p-2", current ? "border-primary bg-muted" : "border-transparent hover:border-border"), children: [
|
|
1428
|
+
/* @__PURE__ */ jsxs("button", { type: "button", className: "flex items-center gap-2 text-left text-sm focus-visible:outline-2 focus-visible:outline-ring", "aria-current": current ? "page" : void 0, onClick: () => props.onOpen(p.id), disabled: props.busy, children: [
|
|
1429
|
+
p.path === "/" ? /* @__PURE__ */ jsx(Home, { "aria-hidden": true, className: "h-4 w-4 shrink-0" }) : /* @__PURE__ */ jsx(FileText, { "aria-hidden": true, className: "h-4 w-4 shrink-0" }),
|
|
1430
|
+
/* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-col", children: [
|
|
1431
|
+
/* @__PURE__ */ jsx("span", { className: "truncate font-medium", children: p.title }),
|
|
1432
|
+
/* @__PURE__ */ jsxs("span", { className: "truncate text-xs text-muted-foreground", children: [
|
|
1433
|
+
p.path,
|
|
1434
|
+
p.kind === "landing" ? " \xB7 landing" : ""
|
|
1435
|
+
] })
|
|
1436
|
+
] })
|
|
1437
|
+
] }),
|
|
1438
|
+
props.readOnly ? null : /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-1", children: [
|
|
1439
|
+
p.path !== "/" ? /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "ghost", disabled: props.busy, onClick: () => setRenaming(p), "aria-label": `Change the address of ${p.title}`, children: "Address" }) : null,
|
|
1440
|
+
props.canReorder ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1441
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "icon", variant: "ghost", disabled: props.busy || i === 0, "aria-label": `Move ${p.title} up`, onClick: () => props.onReorder(p.id, -1), children: /* @__PURE__ */ jsx(ArrowUp, { className: "h-4 w-4" }) }),
|
|
1442
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "icon", variant: "ghost", disabled: props.busy || i === props.pages.length - 1, "aria-label": `Move ${p.title} down`, onClick: () => props.onReorder(p.id, 1), children: /* @__PURE__ */ jsx(ArrowDown, { className: "h-4 w-4" }) })
|
|
1443
|
+
] }) : null,
|
|
1444
|
+
p.path !== "/" ? /* @__PURE__ */ jsx(Button, { type: "button", size: "icon", variant: "ghost", disabled: props.busy, "aria-label": `Delete ${p.title}`, onClick: () => setDeleting(p), children: /* @__PURE__ */ jsx(Trash2, { className: "h-4 w-4" }) }) : null
|
|
1445
|
+
] })
|
|
1446
|
+
] }, p.id);
|
|
1447
|
+
}) }),
|
|
1448
|
+
/* @__PURE__ */ jsx(Separator, {}),
|
|
1449
|
+
/* @__PURE__ */ jsxs("section", { "aria-labelledby": "gs-menu-heading", className: "flex flex-col gap-2", children: [
|
|
1450
|
+
/* @__PURE__ */ jsx("h2", { id: "gs-menu-heading", className: "text-sm font-semibold", children: "Menu" }),
|
|
1451
|
+
props.readOnly ? props.nav.length ? /* @__PURE__ */ jsx("ol", { className: "flex flex-col gap-1 text-sm", children: props.nav.map((n, i) => /* @__PURE__ */ jsxs("li", { children: [
|
|
1452
|
+
n.label,
|
|
1453
|
+
" ",
|
|
1454
|
+
/* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: n.href })
|
|
1455
|
+
] }, i)) }) : /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "The menu is empty." }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1456
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "The links in the site's header. Use the arrows to reorder them." }),
|
|
1457
|
+
/* @__PURE__ */ jsx(MenuEditor, { nav: props.nav, pages: props.pages, busy: props.busy, onChange: props.onNavChange })
|
|
1458
|
+
] })
|
|
1459
|
+
] }),
|
|
1460
|
+
props.readOnly ? null : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1461
|
+
/* @__PURE__ */ jsx(Separator, {}),
|
|
1462
|
+
/* @__PURE__ */ jsxs("section", { "aria-labelledby": "gs-footer-heading", className: "flex flex-col gap-2", children: [
|
|
1463
|
+
/* @__PURE__ */ jsx("h2", { id: "gs-footer-heading", className: "text-sm font-semibold", children: "Footer links" }),
|
|
1464
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "The footer's links and columns are fields of the footer section." }),
|
|
1465
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "outline", onClick: props.onEditFooter, children: "Edit the footer" })
|
|
1466
|
+
] })
|
|
1467
|
+
] }),
|
|
1468
|
+
/* @__PURE__ */ jsx(AddPageDialog, { open: adding, onOpenChange: setAdding, pages: props.pages, reserved: props.reserved, onCreate: props.onCreate }),
|
|
1469
|
+
/* @__PURE__ */ jsx(RenameDialog, { page: renaming, pages: props.pages, reserved: props.reserved, onClose: () => setRenaming(null), onRename: props.onRename }),
|
|
1470
|
+
/* @__PURE__ */ jsx(AlertDialog, { open: deleting !== null, onOpenChange: (o) => o ? null : setDeleting(null), children: /* @__PURE__ */ jsxs(AlertDialogContent, { children: [
|
|
1471
|
+
/* @__PURE__ */ jsxs(AlertDialogHeader, { children: [
|
|
1472
|
+
/* @__PURE__ */ jsxs(AlertDialogTitle, { children: [
|
|
1473
|
+
"Delete \u201C",
|
|
1474
|
+
deleting?.title,
|
|
1475
|
+
"\u201D?"
|
|
1476
|
+
] }),
|
|
1477
|
+
/* @__PURE__ */ jsx(AlertDialogDescription, { children: "The page leaves the site at its next publish. Releases already published keep it, so a rollback brings it back." })
|
|
1478
|
+
] }),
|
|
1479
|
+
/* @__PURE__ */ jsxs(AlertDialogFooter, { children: [
|
|
1480
|
+
/* @__PURE__ */ jsx(AlertDialogCancel, { children: "Keep it" }),
|
|
1481
|
+
/* @__PURE__ */ jsx(
|
|
1482
|
+
AlertDialogAction,
|
|
1483
|
+
{
|
|
1484
|
+
onClick: () => {
|
|
1485
|
+
if (deleting) void props.onDelete(deleting.id);
|
|
1486
|
+
setDeleting(null);
|
|
1487
|
+
},
|
|
1488
|
+
children: "Delete page"
|
|
1489
|
+
}
|
|
1490
|
+
)
|
|
1491
|
+
] })
|
|
1492
|
+
] }) })
|
|
1493
|
+
] });
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// src/editor/preview.ts
|
|
1497
|
+
async function loadSiteCss() {
|
|
1498
|
+
const { ASSETS } = await import('./assets.generated-QSB7EJWX.js');
|
|
1499
|
+
return ASSETS.css.body;
|
|
1500
|
+
}
|
|
1501
|
+
var styleText = (css) => css.replace(/<\/style/gi, "<\\/style");
|
|
1502
|
+
async function renderPreview(input) {
|
|
1503
|
+
const [{ renderSitePageToString }, { ASSETS }] = await Promise.all([import('./handler-QHH453YG.js'), import('./assets.generated-QSB7EJWX.js')]);
|
|
1504
|
+
const result = await renderSitePageToString(new Request(`https://preview.invalid${input.path}`), {
|
|
1505
|
+
store: input.store,
|
|
1506
|
+
brand: { getBrand: () => input.brand },
|
|
1507
|
+
siteId: input.siteId,
|
|
1508
|
+
mode: input.mode,
|
|
1509
|
+
unknownPath: "not-found",
|
|
1510
|
+
csp: false,
|
|
1511
|
+
...input.forms ? { forms: input.forms } : {}
|
|
1512
|
+
});
|
|
1513
|
+
if (!result) return { status: 404, html: null };
|
|
1514
|
+
if (result.status >= 300 && result.status < 400) return { status: result.status, html: null, location: result.headers.get("location") ?? "" };
|
|
1515
|
+
const css = `<style data-gs-inlined="">${styleText(ASSETS.css.body)}</style>`;
|
|
1516
|
+
const formsCss = `<style data-gs-inlined="">${styleText(ASSETS.formsCss.body)}</style>`;
|
|
1517
|
+
const html = result.html.replace(/<link rel="stylesheet" href="[^"]*\/_gs\/([^"]+)"\/>/g, (_m, name) => name === ASSETS.css.name ? css : name === ASSETS.formsCss.name ? formsCss : "").replace(/<script type="module" src="[^"]*\/_gs\/[^"]*"><\/script>/g, "");
|
|
1518
|
+
return { status: result.status, html };
|
|
1519
|
+
}
|
|
1520
|
+
var usePuck2 = createUsePuck();
|
|
1521
|
+
function SeoFields({ siteName, pagePath, pageTitle, readOnly }) {
|
|
1522
|
+
const seo = usePuck2((s) => s.appState.data.root.props?.seo);
|
|
1523
|
+
const root = usePuck2((s) => s.appState.data.root);
|
|
1524
|
+
const dispatch = usePuck2((s) => s.dispatch);
|
|
1525
|
+
const services = useEditorServices();
|
|
1526
|
+
const id = React7.useId();
|
|
1527
|
+
const set = (patch) => {
|
|
1528
|
+
const next = { ...seo ?? {}, ...patch };
|
|
1529
|
+
for (const k of Object.keys(next)) if (next[k] === void 0 || next[k] === "" || next[k] === false) delete next[k];
|
|
1530
|
+
dispatch({ type: "replaceRoot", root: { ...root, props: { ...root.props ?? {}, seo: next } } });
|
|
1531
|
+
};
|
|
1532
|
+
const title = seo?.title ?? "";
|
|
1533
|
+
const description = seo?.description ?? "";
|
|
1534
|
+
return /* @__PURE__ */ jsxs("section", { "aria-labelledby": `${id}-h`, className: "flex flex-col gap-3", children: [
|
|
1535
|
+
/* @__PURE__ */ jsxs("h3", { id: `${id}-h`, className: "text-sm font-semibold", children: [
|
|
1536
|
+
"Search and sharing for ",
|
|
1537
|
+
pagePath
|
|
1538
|
+
] }),
|
|
1539
|
+
/* @__PURE__ */ jsxs("div", { className: "rounded-md border border-border p-3 text-sm", "aria-label": "Search result preview", children: [
|
|
1540
|
+
/* @__PURE__ */ jsx("p", { className: "truncate text-primary", children: title || pageTitle }),
|
|
1541
|
+
/* @__PURE__ */ jsxs("p", { className: "truncate text-xs text-muted-foreground", children: [
|
|
1542
|
+
siteName,
|
|
1543
|
+
" \xB7 ",
|
|
1544
|
+
pagePath
|
|
1545
|
+
] }),
|
|
1546
|
+
/* @__PURE__ */ jsx("p", { className: "line-clamp-2 text-muted-foreground", children: description || "No description: search engines will pick some text from the page." })
|
|
1547
|
+
] }),
|
|
1548
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", children: [
|
|
1549
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-title`, children: "Search title" }),
|
|
1550
|
+
/* @__PURE__ */ jsx(Input, { id: `${id}-title`, value: title, maxLength: 70, placeholder: pageTitle, "aria-describedby": `${id}-title-n`, disabled: readOnly, onChange: (e) => set({ title: e.currentTarget.value }) }),
|
|
1551
|
+
/* @__PURE__ */ jsxs("p", { id: `${id}-title-n`, className: "text-xs text-muted-foreground", children: [
|
|
1552
|
+
title.length,
|
|
1553
|
+
"/70. Empty uses the page title."
|
|
1554
|
+
] })
|
|
1555
|
+
] }),
|
|
1556
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", children: [
|
|
1557
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-desc`, children: "Description" }),
|
|
1558
|
+
/* @__PURE__ */ jsx(Textarea, { id: `${id}-desc`, rows: 3, value: description, maxLength: 200, "aria-describedby": `${id}-desc-n`, disabled: readOnly, onChange: (e) => set({ description: e.currentTarget.value }) }),
|
|
1559
|
+
/* @__PURE__ */ jsxs("p", { id: `${id}-desc-n`, className: "text-xs text-muted-foreground", children: [
|
|
1560
|
+
description.length,
|
|
1561
|
+
"/200"
|
|
1562
|
+
] })
|
|
1563
|
+
] }),
|
|
1564
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", children: [
|
|
1565
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-medium", children: "Share image" }),
|
|
1566
|
+
seo?.ogImage?.src && isSafeImageSrc(seo.ogImage.src) ? /* @__PURE__ */ jsx("img", { src: seo.ogImage.src, alt: seo.ogImage.alt ?? "", className: "h-20 w-full rounded-md border border-border object-cover" }) : null,
|
|
1567
|
+
readOnly ? seo?.ogImage ? null : /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "None." }) : /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
|
|
1568
|
+
/* @__PURE__ */ jsx(
|
|
1569
|
+
Button,
|
|
1570
|
+
{
|
|
1571
|
+
type: "button",
|
|
1572
|
+
size: "sm",
|
|
1573
|
+
variant: "outline",
|
|
1574
|
+
onClick: async () => {
|
|
1575
|
+
const picked = await services.pickImage(seo?.ogImage ? { current: { src: seo.ogImage.src, alt: seo.ogImage.alt ?? "" } } : {});
|
|
1576
|
+
if (picked) set({ ogImage: { ...picked, alt: picked.alt } });
|
|
1577
|
+
},
|
|
1578
|
+
children: seo?.ogImage ? "Replace share image" : "Choose share image"
|
|
1579
|
+
}
|
|
1580
|
+
),
|
|
1581
|
+
seo?.ogImage ? /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "ghost", onClick: () => set({ ogImage: void 0 }), children: "Remove" }) : null
|
|
1582
|
+
] })
|
|
1583
|
+
] }),
|
|
1584
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
1585
|
+
/* @__PURE__ */ jsx(Switch, { id: `${id}-noindex`, checked: seo?.noindex === true, disabled: readOnly, onCheckedChange: (on) => set({ noindex: on || void 0 }) }),
|
|
1586
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-noindex`, children: "Hide this page from search engines" })
|
|
1587
|
+
] })
|
|
1588
|
+
] });
|
|
1589
|
+
}
|
|
1590
|
+
function IssueList({ issues, currentPageId, onOpenPage, onShown }) {
|
|
1591
|
+
const getSelectorForId = usePuck2((s) => s.getSelectorForId);
|
|
1592
|
+
const dispatch = usePuck2((s) => s.dispatch);
|
|
1593
|
+
const show = (issue) => {
|
|
1594
|
+
if (issue.pageId && issue.pageId !== currentPageId) return onOpenPage(issue.pageId);
|
|
1595
|
+
if (!issue.sectionId) return;
|
|
1596
|
+
const selector = getSelectorForId(issue.sectionId);
|
|
1597
|
+
if (!selector) return;
|
|
1598
|
+
dispatch({ type: "setUi", ui: { itemSelector: selector, rightSideBarVisible: true } });
|
|
1599
|
+
onShown(issue.sectionId);
|
|
1600
|
+
};
|
|
1601
|
+
return /* @__PURE__ */ jsx("ul", { className: "flex flex-col gap-2", children: issues.map((issue, i) => /* @__PURE__ */ jsxs("li", { className: "flex items-start gap-2 text-sm", children: [
|
|
1602
|
+
issue.severity === "error" ? /* @__PURE__ */ jsx(AlertCircle, { "aria-label": "Blocks publishing", className: "mt-0.5 h-4 w-4 shrink-0 text-destructive" }) : /* @__PURE__ */ jsx(AlertTriangle, { "aria-label": "Warning", className: "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" }),
|
|
1603
|
+
/* @__PURE__ */ jsx("span", { className: "flex-1", children: issue.message }),
|
|
1604
|
+
issue.sectionId || issue.pageId && issue.pageId !== currentPageId ? /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "ghost", onClick: () => show(issue), "aria-label": `Show: ${issue.message.length > 80 ? `${issue.message.slice(0, 80)}\u2026` : issue.message}`, children: "Show" }) : null
|
|
1605
|
+
] }, i)) });
|
|
1606
|
+
}
|
|
1607
|
+
function PublishPanel(props) {
|
|
1608
|
+
const [note, setNote] = React7.useState("");
|
|
1609
|
+
const [rollingBack, setRollingBack] = React7.useState(null);
|
|
1610
|
+
const id = React7.useId();
|
|
1611
|
+
const errors = props.issues?.filter((i) => i.severity === "error") ?? [];
|
|
1612
|
+
const warnings = props.issues?.filter((i) => i.severity === "warning") ?? [];
|
|
1613
|
+
const live = props.releases.find((r) => r.id === props.liveReleaseId);
|
|
1614
|
+
const canPublish = props.issues !== null && errors.length === 0 && !props.blockedReason && !props.checking;
|
|
1615
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-4 p-3", children: [
|
|
1616
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
1617
|
+
/* @__PURE__ */ jsx("h2", { className: "text-sm font-semibold", children: "Publish" }),
|
|
1618
|
+
live ? /* @__PURE__ */ jsxs(Badge, { variant: "success", children: [
|
|
1619
|
+
"Live: release ",
|
|
1620
|
+
live.number
|
|
1621
|
+
] }) : /* @__PURE__ */ jsx(Badge, { variant: "muted", children: "Not published" })
|
|
1622
|
+
] }),
|
|
1623
|
+
/* @__PURE__ */ jsxs("section", { "aria-labelledby": `${id}-checks`, "aria-busy": props.checking, className: "flex flex-col gap-2", children: [
|
|
1624
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
|
|
1625
|
+
/* @__PURE__ */ jsx("h3", { id: `${id}-checks`, className: "text-sm font-semibold", children: "Checks" }),
|
|
1626
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "ghost", onClick: props.onCheck, loading: props.checking, children: "Check again" })
|
|
1627
|
+
] }),
|
|
1628
|
+
/* @__PURE__ */ jsx("div", { role: "status", "aria-live": "polite", className: "text-sm", children: props.issues === null ? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "Checking\u2026" }) : errors.length ? /* @__PURE__ */ jsxs("span", { className: "text-destructive", children: [
|
|
1629
|
+
errors.length,
|
|
1630
|
+
" ",
|
|
1631
|
+
errors.length === 1 ? "problem blocks" : "problems block",
|
|
1632
|
+
" publishing."
|
|
1633
|
+
] }) : /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-1", children: [
|
|
1634
|
+
/* @__PURE__ */ jsx(CheckCircle2, { "aria-hidden": true, className: "h-4 w-4" }),
|
|
1635
|
+
" Ready to publish",
|
|
1636
|
+
warnings.length ? ` (${warnings.length} ${warnings.length === 1 ? "suggestion" : "suggestions"})` : "",
|
|
1637
|
+
"."
|
|
1638
|
+
] }) }),
|
|
1639
|
+
props.issues?.length ? /* @__PURE__ */ jsx(IssueList, { issues: [...errors, ...warnings], currentPageId: props.currentPageId, onOpenPage: props.onOpenPage, onShown: props.onShowSection }) : null
|
|
1640
|
+
] }),
|
|
1641
|
+
/* @__PURE__ */ jsx(Separator, {}),
|
|
1642
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
|
|
1643
|
+
/* @__PURE__ */ jsxs(Button, { type: "button", size: "sm", variant: "outline", onClick: () => props.onPreview("preview"), children: [
|
|
1644
|
+
/* @__PURE__ */ jsx(Eye, { "aria-hidden": true, className: "mr-1 h-4 w-4" }),
|
|
1645
|
+
" Preview this page"
|
|
1646
|
+
] }),
|
|
1647
|
+
live ? /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "outline", onClick: () => props.onPreview("live"), children: "View it live" }) : null,
|
|
1648
|
+
props.previewUrl ? /* @__PURE__ */ jsx(Button, { asChild: true, size: "sm", variant: "ghost", children: /* @__PURE__ */ jsxs("a", { href: props.previewUrl(props.pagePath), target: "_blank", rel: "noreferrer", children: [
|
|
1649
|
+
/* @__PURE__ */ jsx(ExternalLink, { "aria-hidden": true, className: "mr-1 h-4 w-4" }),
|
|
1650
|
+
" Preview link"
|
|
1651
|
+
] }) }) : null
|
|
1652
|
+
] }),
|
|
1653
|
+
props.readOnly ? null : /* @__PURE__ */ jsxs(
|
|
1654
|
+
"form",
|
|
1655
|
+
{
|
|
1656
|
+
className: "flex flex-col gap-2",
|
|
1657
|
+
onSubmit: async (e) => {
|
|
1658
|
+
e.preventDefault();
|
|
1659
|
+
if (await props.onPublish(note)) setNote("");
|
|
1660
|
+
},
|
|
1661
|
+
children: [
|
|
1662
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: `${id}-note`, children: "What changed (for the release history)" }),
|
|
1663
|
+
/* @__PURE__ */ jsx(Input, { id: `${id}-note`, value: note, maxLength: 200, placeholder: "New spring timetable", onChange: (e) => setNote(e.currentTarget.value) }),
|
|
1664
|
+
/* @__PURE__ */ jsx(Button, { type: "submit", disabled: !canPublish, loading: props.publishing, "aria-describedby": `${id}-why`, children: "Publish site" }),
|
|
1665
|
+
props.blockedReason || errors.length ? /* @__PURE__ */ jsx("p", { id: `${id}-why`, className: "text-xs text-muted-foreground", children: props.blockedReason ?? "Fix the problems above to publish." }) : null
|
|
1666
|
+
]
|
|
1667
|
+
}
|
|
1668
|
+
),
|
|
1669
|
+
/* @__PURE__ */ jsx(Separator, {}),
|
|
1670
|
+
/* @__PURE__ */ jsx(SeoFields, { siteName: props.siteName, pagePath: props.pagePath, pageTitle: props.pageTitle, readOnly: props.readOnly === true || props.publishing }),
|
|
1671
|
+
/* @__PURE__ */ jsx(Separator, {}),
|
|
1672
|
+
/* @__PURE__ */ jsxs("section", { "aria-labelledby": `${id}-history`, className: "flex flex-col gap-2", children: [
|
|
1673
|
+
/* @__PURE__ */ jsx("h3", { id: `${id}-history`, className: "text-sm font-semibold", children: "Release history" }),
|
|
1674
|
+
props.releases.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Nothing published yet." }) : null,
|
|
1675
|
+
/* @__PURE__ */ jsx("ol", { className: "flex flex-col gap-2", children: props.releases.map((r) => /* @__PURE__ */ jsxs("li", { className: "flex items-start justify-between gap-2 rounded-md border border-border p-2 text-sm", children: [
|
|
1676
|
+
/* @__PURE__ */ jsxs("span", { className: "flex flex-col", children: [
|
|
1677
|
+
/* @__PURE__ */ jsxs("span", { className: "font-medium", children: [
|
|
1678
|
+
"Release ",
|
|
1679
|
+
r.number,
|
|
1680
|
+
r.id === props.liveReleaseId ? /* @__PURE__ */ jsx(Badge, { variant: "success", className: "ml-2", children: "Live" }) : null
|
|
1681
|
+
] }),
|
|
1682
|
+
/* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: new Date(r.publishedAt ?? r.createdAt).toLocaleString() }),
|
|
1683
|
+
r.note ? /* @__PURE__ */ jsx("span", { className: "text-xs", children: r.note }) : null
|
|
1684
|
+
] }),
|
|
1685
|
+
r.id !== props.liveReleaseId && !props.readOnly ? /* @__PURE__ */ jsxs(Button, { type: "button", size: "sm", variant: "ghost", onClick: () => setRollingBack(r), "aria-label": `Roll back to release ${r.number}`, children: [
|
|
1686
|
+
/* @__PURE__ */ jsx(RotateCcw, { "aria-hidden": true, className: "mr-1 h-4 w-4" }),
|
|
1687
|
+
" Roll back"
|
|
1688
|
+
] }) : null
|
|
1689
|
+
] }, r.id)) })
|
|
1690
|
+
] }),
|
|
1691
|
+
/* @__PURE__ */ jsx(AlertDialog, { open: rollingBack !== null, onOpenChange: (o) => o ? null : setRollingBack(null), children: /* @__PURE__ */ jsxs(AlertDialogContent, { children: [
|
|
1692
|
+
/* @__PURE__ */ jsxs(AlertDialogHeader, { children: [
|
|
1693
|
+
/* @__PURE__ */ jsxs(AlertDialogTitle, { children: [
|
|
1694
|
+
"Roll back to release ",
|
|
1695
|
+
rollingBack?.number,
|
|
1696
|
+
"?"
|
|
1697
|
+
] }),
|
|
1698
|
+
/* @__PURE__ */ jsxs(AlertDialogDescription, { children: [
|
|
1699
|
+
"The live site goes back to what release ",
|
|
1700
|
+
rollingBack?.number,
|
|
1701
|
+
" published, at once. Your drafts don't change; publish again to go forward."
|
|
1702
|
+
] })
|
|
1703
|
+
] }),
|
|
1704
|
+
/* @__PURE__ */ jsxs(AlertDialogFooter, { children: [
|
|
1705
|
+
/* @__PURE__ */ jsx(AlertDialogCancel, { children: "Cancel" }),
|
|
1706
|
+
/* @__PURE__ */ jsx(
|
|
1707
|
+
AlertDialogAction,
|
|
1708
|
+
{
|
|
1709
|
+
onClick: () => {
|
|
1710
|
+
if (rollingBack) void props.onRollback(rollingBack.id);
|
|
1711
|
+
setRollingBack(null);
|
|
1712
|
+
},
|
|
1713
|
+
children: "Roll back"
|
|
1714
|
+
}
|
|
1715
|
+
)
|
|
1716
|
+
] })
|
|
1717
|
+
] }) })
|
|
1718
|
+
] });
|
|
1719
|
+
}
|
|
1720
|
+
var usePuck3 = createUsePuck();
|
|
1721
|
+
var VIEWPORTS = {
|
|
1722
|
+
desktop: { width: "100%", label: "Desktop" },
|
|
1723
|
+
tablet: { width: 768, label: "Tablet" },
|
|
1724
|
+
phone: { width: 390, label: "Phone" }
|
|
1725
|
+
};
|
|
1726
|
+
var LOCKED = { drag: false, duplicate: false, delete: false, edit: false, insert: false };
|
|
1727
|
+
var UNLOCKED = { drag: true, duplicate: true, delete: true, edit: true, insert: true };
|
|
1728
|
+
function saveAndDispose(saver) {
|
|
1729
|
+
if (!saver) return;
|
|
1730
|
+
if (!saver.dirty) return saver.dispose();
|
|
1731
|
+
saver.resume();
|
|
1732
|
+
saver.flush().catch((err) => console.warn("[genesis-sites/editor] closed with changes that couldn't be saved.", err)).finally(() => saver.dispose());
|
|
1733
|
+
}
|
|
1734
|
+
var SAVE_LABEL = {
|
|
1735
|
+
saved: "All changes saved",
|
|
1736
|
+
pending: "Unsaved changes",
|
|
1737
|
+
saving: "Saving\u2026",
|
|
1738
|
+
conflict: "Someone else saved: choose a version",
|
|
1739
|
+
error: "Couldn't save; retrying",
|
|
1740
|
+
invalid: "Not saved: fix the problem shown"
|
|
1741
|
+
};
|
|
1742
|
+
function worst(...states) {
|
|
1743
|
+
const order = ["conflict", "error", "saving", "pending", "saved"];
|
|
1744
|
+
for (const s of order) if (states.some((x) => x?.status === s)) return s;
|
|
1745
|
+
return "saved";
|
|
1746
|
+
}
|
|
1747
|
+
function useCanvasFrame(css, href, theme, dark, lang) {
|
|
1748
|
+
return React7.useCallback(
|
|
1749
|
+
({ children, document: document2 }) => {
|
|
1750
|
+
React7.useEffect(() => {
|
|
1751
|
+
if (!document2) return;
|
|
1752
|
+
const nodes = [];
|
|
1753
|
+
if (href) {
|
|
1754
|
+
const link = document2.createElement("link");
|
|
1755
|
+
link.rel = "stylesheet";
|
|
1756
|
+
link.href = href;
|
|
1757
|
+
link.dataset.gsSiteCss = "";
|
|
1758
|
+
nodes.push(link);
|
|
1759
|
+
} else if (css) {
|
|
1760
|
+
const site = document2.createElement("style");
|
|
1761
|
+
site.dataset.gsSiteCss = "";
|
|
1762
|
+
site.textContent = styleText(css);
|
|
1763
|
+
nodes.push(site);
|
|
1764
|
+
}
|
|
1765
|
+
const vars = document2.createElement("style");
|
|
1766
|
+
vars.dataset.gsTheme = "";
|
|
1767
|
+
vars.textContent = styleText(theme.replace(/:root/g, "html:root"));
|
|
1768
|
+
nodes.push(vars);
|
|
1769
|
+
for (const n of nodes) document2.head.appendChild(n);
|
|
1770
|
+
const keepLast = new MutationObserver(() => {
|
|
1771
|
+
const last = document2.head.lastElementChild;
|
|
1772
|
+
if (last && !nodes.includes(last)) for (const n of nodes) document2.head.appendChild(n);
|
|
1773
|
+
});
|
|
1774
|
+
keepLast.observe(document2.head, { childList: true });
|
|
1775
|
+
const html = document2.documentElement;
|
|
1776
|
+
html.lang = lang;
|
|
1777
|
+
html.dataset.gsSite = "";
|
|
1778
|
+
html.classList.toggle("dark", dark);
|
|
1779
|
+
document2.body.classList.add("bg-background", "font-sans", "text-foreground", "antialiased");
|
|
1780
|
+
return () => {
|
|
1781
|
+
keepLast.disconnect();
|
|
1782
|
+
for (const n of nodes) n.remove();
|
|
1783
|
+
};
|
|
1784
|
+
}, [document2]);
|
|
1785
|
+
return /* @__PURE__ */ jsx(Fragment, { children });
|
|
1786
|
+
},
|
|
1787
|
+
[css, href, theme, dark, lang]
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
function Toolbar({ siteName, pageTitle, titleId, saveStatus, mode, viewport, onViewport, onPreview }) {
|
|
1791
|
+
const history = usePuck3((s) => s.history);
|
|
1792
|
+
const dispatch = usePuck3((s) => s.dispatch);
|
|
1793
|
+
const choose = (v) => {
|
|
1794
|
+
onViewport(v);
|
|
1795
|
+
dispatch({ type: "setUi", ui: (ui) => ({ viewports: { ...ui.viewports, current: { width: VIEWPORTS[v].width, height: "auto" } } }) });
|
|
1796
|
+
};
|
|
1797
|
+
return /* @__PURE__ */ jsxs("header", { className: "flex flex-wrap items-center justify-between gap-2 border-b border-border bg-background px-3 py-2", children: [
|
|
1798
|
+
/* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-col", children: [
|
|
1799
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-xs text-muted-foreground", children: siteName }),
|
|
1800
|
+
/* @__PURE__ */ jsx("span", { id: titleId, tabIndex: -1, className: "truncate text-sm font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring", children: pageTitle })
|
|
1801
|
+
] }),
|
|
1802
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", role: "toolbar", "aria-label": "Editor", children: [
|
|
1803
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "icon", variant: "ghost", "aria-label": "Undo", title: "Undo (Ctrl+Z)", disabled: mode !== "edit" || !history.hasPast, onClick: () => history.back(), children: /* @__PURE__ */ jsx(Undo2, { className: "h-4 w-4" }) }),
|
|
1804
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "icon", variant: "ghost", "aria-label": "Redo", title: "Redo (Ctrl+Shift+Z)", disabled: mode !== "edit" || !history.hasFuture, onClick: () => history.forward(), children: /* @__PURE__ */ jsx(Redo2, { className: "h-4 w-4" }) }),
|
|
1805
|
+
/* @__PURE__ */ jsx("span", { className: "mx-1 h-6 w-px bg-border", "aria-hidden": true }),
|
|
1806
|
+
["desktop", "tablet", "phone"].map((v) => {
|
|
1807
|
+
const Icon = v === "desktop" ? Monitor : v === "tablet" ? Tablet : Smartphone;
|
|
1808
|
+
return /* @__PURE__ */ jsx(Button, { type: "button", size: "icon", variant: viewport === v ? "secondary" : "ghost", "aria-label": `${VIEWPORTS[v].label} width`, "aria-pressed": viewport === v, onClick: () => choose(v), children: /* @__PURE__ */ jsx(Icon, { className: "h-4 w-4" }) }, v);
|
|
1809
|
+
})
|
|
1810
|
+
] }),
|
|
1811
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
1812
|
+
/* @__PURE__ */ jsx("span", { role: "status", className: cn("text-xs", saveStatus === "conflict" || saveStatus === "error" || saveStatus === "invalid" ? "text-destructive" : "text-muted-foreground"), children: mode === "view" ? "View only" : mode === "publishing" ? "Publishing\u2026" : SAVE_LABEL[saveStatus] }),
|
|
1813
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "outline", onClick: onPreview, children: "Preview" })
|
|
1814
|
+
] })
|
|
1815
|
+
] });
|
|
1816
|
+
}
|
|
1817
|
+
function useSelectFooter() {
|
|
1818
|
+
const dispatch = usePuck3((s) => s.dispatch);
|
|
1819
|
+
const getSelectorForId = usePuck3((s) => s.getSelectorForId);
|
|
1820
|
+
const footer = usePuck3((s) => s.appState.data.root.props?.footer);
|
|
1821
|
+
return () => {
|
|
1822
|
+
const first = footer?.[0];
|
|
1823
|
+
const selector = first ? getSelectorForId(first.props.id) : void 0;
|
|
1824
|
+
if (selector) dispatch({ type: "setUi", ui: { itemSelector: selector, rightSideBarVisible: true } });
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
function SelectionWatcher({ onSelect }) {
|
|
1828
|
+
const selected = usePuck3((s) => s.selectedItem?.props.id);
|
|
1829
|
+
React7.useEffect(() => {
|
|
1830
|
+
if (selected) onSelect();
|
|
1831
|
+
}, [selected]);
|
|
1832
|
+
return null;
|
|
1833
|
+
}
|
|
1834
|
+
function PagesTab(props) {
|
|
1835
|
+
const selectFooter = useSelectFooter();
|
|
1836
|
+
return /* @__PURE__ */ jsx(
|
|
1837
|
+
PagesPanel,
|
|
1838
|
+
{
|
|
1839
|
+
...props,
|
|
1840
|
+
onEditFooter: () => {
|
|
1841
|
+
selectFooter();
|
|
1842
|
+
props.onFooter();
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
function SiteEditor({ siteId, store, brand, forms, assets, onPublish, reserved, previewUrl, autosaveDelay, initialPageId, siteStylesheet, readOnly = false, onDirtyChange, className }) {
|
|
1848
|
+
const [loadError, setLoadError] = React7.useState(null);
|
|
1849
|
+
const [site, setSite] = React7.useState(null);
|
|
1850
|
+
const [pages, setPages] = React7.useState([]);
|
|
1851
|
+
const [open, setOpen] = React7.useState(null);
|
|
1852
|
+
const [editorKey, setEditorKey] = React7.useState(0);
|
|
1853
|
+
const [siteState, setSiteState] = React7.useState(null);
|
|
1854
|
+
const [pageState, setPageState] = React7.useState(null);
|
|
1855
|
+
const [invalid, setInvalid] = React7.useState(null);
|
|
1856
|
+
const [busy, setBusy] = React7.useState(false);
|
|
1857
|
+
const [notice, setNotice] = React7.useState(null);
|
|
1858
|
+
const [css, setCss] = React7.useState(null);
|
|
1859
|
+
const [h1Id, setH1Id] = React7.useState(null);
|
|
1860
|
+
const [formMap, setFormMap] = React7.useState(/* @__PURE__ */ new Map());
|
|
1861
|
+
const [issues, setIssues] = React7.useState(null);
|
|
1862
|
+
const [checking, setChecking] = React7.useState(false);
|
|
1863
|
+
const [publishing, setPublishing] = React7.useState(false);
|
|
1864
|
+
const [releases, setReleases] = React7.useState([]);
|
|
1865
|
+
const [liveId, setLiveId] = React7.useState(null);
|
|
1866
|
+
const [leftTab, setLeftTab] = React7.useState(readOnly ? "outline" : "add");
|
|
1867
|
+
const [rightTab, setRightTab] = React7.useState("edit");
|
|
1868
|
+
const [viewport, setViewport] = React7.useState("desktop");
|
|
1869
|
+
const [picker, setPicker] = React7.useState(null);
|
|
1870
|
+
const [preview, setPreview] = React7.useState(null);
|
|
1871
|
+
const siteSaver = React7.useRef(null);
|
|
1872
|
+
const pageSaver = React7.useRef(null);
|
|
1873
|
+
const pageSaverFor = React7.useRef(null);
|
|
1874
|
+
const counts = React7.useRef(/* @__PURE__ */ new Map());
|
|
1875
|
+
const focusTitle = React7.useRef(false);
|
|
1876
|
+
const titleId = React7.useId();
|
|
1877
|
+
const [announcement, setAnnouncement] = React7.useState("");
|
|
1878
|
+
const [menuDraft, setMenuDraft] = React7.useState(null);
|
|
1879
|
+
const locked = readOnly || publishing;
|
|
1880
|
+
const lockedRef = React7.useRef(locked);
|
|
1881
|
+
lockedRef.current = locked;
|
|
1882
|
+
const kind = open?.kind ?? "page";
|
|
1883
|
+
const editor = React7.useMemo(() => createEditorConfig({ kind, counts: () => counts.current, readOnly: () => lockedRef.current }), [kind]);
|
|
1884
|
+
const slotKeys = React7.useMemo(() => createEditorConfig().slotKeys, []);
|
|
1885
|
+
const theme = React7.useMemo(() => {
|
|
1886
|
+
try {
|
|
1887
|
+
return siteTheme(brand, site?.theme ?? {});
|
|
1888
|
+
} catch (err) {
|
|
1889
|
+
console.error("[genesis-sites/editor] the brand can't make a theme; the canvas uses Genesis's default.", err);
|
|
1890
|
+
return siteTheme({ ...genesisBrand, name: brandDisplayName(brand) }, site?.theme ?? {});
|
|
1891
|
+
}
|
|
1892
|
+
}, [brand, site?.theme]);
|
|
1893
|
+
const iframe = useCanvasFrame(css, siteStylesheet, themeCss(theme), theme.colorScheme === "dark", site?.lang ?? "en");
|
|
1894
|
+
const loadPage = React7.useCallback(
|
|
1895
|
+
async (pageId, siteDoc) => {
|
|
1896
|
+
const draft = await store.getPageDraft(pageId);
|
|
1897
|
+
if (!draft) throw new Error("That page no longer exists, or you can't edit it.");
|
|
1898
|
+
const previous = pageSaver.current;
|
|
1899
|
+
if (previous) {
|
|
1900
|
+
try {
|
|
1901
|
+
await previous.flush();
|
|
1902
|
+
} catch (err) {
|
|
1903
|
+
throw new Error(`The page you're leaving has a change that isn't saved: ${err.message}`);
|
|
1904
|
+
}
|
|
1905
|
+
previous.dispose();
|
|
1906
|
+
}
|
|
1907
|
+
const saver = readOnly ? null : new Autosaver({ rev: draft.rev, save: (rev, doc) => store.savePageDraft(pageId, rev, doc), delay: autosaveDelay ?? 800, onState: setPageState }, draft.doc);
|
|
1908
|
+
pageSaver.current = saver;
|
|
1909
|
+
pageSaverFor.current = pageId;
|
|
1910
|
+
setPageState(saver?.current ?? null);
|
|
1911
|
+
const placeholders = placeholderSnapshot(draft.doc.content);
|
|
1912
|
+
placeholderSnapshot(siteDoc.header, placeholders);
|
|
1913
|
+
placeholderSnapshot(siteDoc.footer, placeholders);
|
|
1914
|
+
const data = toEditorData(draft.doc, siteDoc, slotKeys);
|
|
1915
|
+
counts.current = countTypes(data);
|
|
1916
|
+
setH1Id(h1SectionId(data.content));
|
|
1917
|
+
setInvalid(null);
|
|
1918
|
+
setOpen({ id: pageId, path: draft.path, kind: draft.kind, data, placeholders });
|
|
1919
|
+
setEditorKey((k) => k + 1);
|
|
1920
|
+
},
|
|
1921
|
+
[store, autosaveDelay, slotKeys, readOnly]
|
|
1922
|
+
);
|
|
1923
|
+
const refreshPublishing = React7.useCallback(async () => {
|
|
1924
|
+
const [list, live] = await Promise.all([store.listReleases(siteId), store.getLive(siteId)]);
|
|
1925
|
+
setReleases(list);
|
|
1926
|
+
setLiveId(live?.id ?? null);
|
|
1927
|
+
}, [store, siteId]);
|
|
1928
|
+
React7.useEffect(() => {
|
|
1929
|
+
let cancelled = false;
|
|
1930
|
+
(async () => {
|
|
1931
|
+
try {
|
|
1932
|
+
const draft = await store.getDraft(siteId);
|
|
1933
|
+
if (!draft) throw new Error(`There's no site "${siteId}", or you can't edit it.`);
|
|
1934
|
+
const list = await store.listPages(siteId);
|
|
1935
|
+
if (cancelled) return;
|
|
1936
|
+
siteSaver.current?.dispose();
|
|
1937
|
+
const saver = readOnly ? null : new Autosaver({ rev: draft.rev, save: (rev, doc) => store.saveSiteDraft(siteId, rev, doc), delay: autosaveDelay ?? 800, onState: setSiteState }, draft.site);
|
|
1938
|
+
siteSaver.current = saver;
|
|
1939
|
+
setSiteState(saver?.current ?? null);
|
|
1940
|
+
setSite(draft.site);
|
|
1941
|
+
setPages(list);
|
|
1942
|
+
const first = list.find((p) => p.id === initialPageId) ?? list.find((p) => p.path === "/") ?? list[0];
|
|
1943
|
+
if (first) await loadPage(first.id, draft.site);
|
|
1944
|
+
await refreshPublishing();
|
|
1945
|
+
} catch (err) {
|
|
1946
|
+
if (!cancelled) setLoadError(err.message);
|
|
1947
|
+
}
|
|
1948
|
+
})();
|
|
1949
|
+
if (!siteStylesheet) loadSiteCss().then(setCss, (err) => console.error("[genesis-sites/editor] the site stylesheet didn't load; the canvas is unstyled.", err));
|
|
1950
|
+
return () => {
|
|
1951
|
+
cancelled = true;
|
|
1952
|
+
saveAndDispose(siteSaver.current);
|
|
1953
|
+
saveAndDispose(pageSaver.current);
|
|
1954
|
+
siteSaver.current = null;
|
|
1955
|
+
pageSaver.current = null;
|
|
1956
|
+
};
|
|
1957
|
+
}, [siteId, store, readOnly]);
|
|
1958
|
+
const menuInvalid = menuDraft !== null && !menuDraft.every((i) => menuItemProblems(i).length === 0);
|
|
1959
|
+
React7.useEffect(() => {
|
|
1960
|
+
const guard = (e) => {
|
|
1961
|
+
if (siteSaver.current?.dirty || pageSaver.current?.dirty || invalid || menuInvalid) {
|
|
1962
|
+
e.preventDefault();
|
|
1963
|
+
e.returnValue = "";
|
|
1964
|
+
}
|
|
1965
|
+
};
|
|
1966
|
+
window.addEventListener("beforeunload", guard);
|
|
1967
|
+
return () => window.removeEventListener("beforeunload", guard);
|
|
1968
|
+
}, [invalid, menuInvalid]);
|
|
1969
|
+
const dirty = !readOnly && (worst(siteState, pageState) !== "saved" || Boolean(invalid) || menuInvalid);
|
|
1970
|
+
const dirtyReported = React7.useRef(false);
|
|
1971
|
+
const onDirtyChangeRef = React7.useRef(onDirtyChange);
|
|
1972
|
+
onDirtyChangeRef.current = onDirtyChange;
|
|
1973
|
+
React7.useEffect(() => {
|
|
1974
|
+
if (dirty === dirtyReported.current) return;
|
|
1975
|
+
dirtyReported.current = dirty;
|
|
1976
|
+
onDirtyChangeRef.current?.(dirty);
|
|
1977
|
+
}, [dirty]);
|
|
1978
|
+
const formSlugs = React7.useMemo(() => {
|
|
1979
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
1980
|
+
for (const item of open?.data.content ?? []) if (item.type.startsWith("form-embed") && typeof item.props.form === "string") slugs.add(item.props.form);
|
|
1981
|
+
return [...slugs].sort().join(",");
|
|
1982
|
+
}, [open]);
|
|
1983
|
+
React7.useEffect(() => {
|
|
1984
|
+
if (!forms || !formSlugs) return;
|
|
1985
|
+
let cancelled = false;
|
|
1986
|
+
Promise.all(formSlugs.split(",").map(async (slug) => [slug, await Promise.resolve(forms(slug, { siteId, pagePath: open?.path ?? "/", mode: "preview" })).catch(() => null)])).then((entries) => {
|
|
1987
|
+
if (!cancelled) setFormMap(new Map(entries));
|
|
1988
|
+
});
|
|
1989
|
+
return () => {
|
|
1990
|
+
cancelled = true;
|
|
1991
|
+
};
|
|
1992
|
+
}, [forms, formSlugs, siteId, open?.path]);
|
|
1993
|
+
const onChange = React7.useCallback(
|
|
1994
|
+
(data) => {
|
|
1995
|
+
if (!open || !site || readOnly || pageSaverFor.current !== open.id) return;
|
|
1996
|
+
const editorData = data;
|
|
1997
|
+
counts.current = countTypes(editorData);
|
|
1998
|
+
setH1Id(h1SectionId(editorData.content ?? []));
|
|
1999
|
+
const result = fromEditorData(data, { slotKeys, placeholders: open.placeholders });
|
|
2000
|
+
const page = PageDoc.safeParse(result.page);
|
|
2001
|
+
const current = siteSaver.current?.latest ?? site;
|
|
2002
|
+
const nextSite = SiteDoc.safeParse({ ...current, header: result.header, footer: result.footer });
|
|
2003
|
+
const problems = [
|
|
2004
|
+
...page.success ? [] : page.error.issues.map((i) => i.path.join(".") === "root.props.title" ? "The page needs a title." : `${i.path.join(".")}: ${i.message}`),
|
|
2005
|
+
...nextSite.success ? [] : nextSite.error.issues.map((i) => `Site ${i.path.join(".")}: ${i.message}`)
|
|
2006
|
+
];
|
|
2007
|
+
setInvalid(problems.length ? problems.join(" ") : null);
|
|
2008
|
+
if (page.success) {
|
|
2009
|
+
pageSaver.current?.update(page.data);
|
|
2010
|
+
const title = page.data.root.props.title;
|
|
2011
|
+
setPages((list) => list.some((p) => p.id === open.id && p.title !== title) ? list.map((p) => p.id === open.id ? { ...p, title } : p) : list);
|
|
2012
|
+
}
|
|
2013
|
+
if (nextSite.success && (stableHash(current.header) !== stableHash(nextSite.data.header) || stableHash(current.footer) !== stableHash(nextSite.data.footer))) {
|
|
2014
|
+
siteSaver.current?.update(nextSite.data);
|
|
2015
|
+
setSite(nextSite.data);
|
|
2016
|
+
}
|
|
2017
|
+
},
|
|
2018
|
+
[open, site, slotKeys, readOnly]
|
|
2019
|
+
);
|
|
2020
|
+
const onNavChange = React7.useCallback(
|
|
2021
|
+
(nav) => {
|
|
2022
|
+
const current = siteSaver.current?.latest ?? site;
|
|
2023
|
+
if (!current || readOnly) return;
|
|
2024
|
+
const next = SiteDoc.safeParse({ ...current, nav });
|
|
2025
|
+
if (!next.success) return setNotice({ tone: "error", text: `The menu can't be saved: ${next.error.issues[0]?.message ?? "invalid"}` });
|
|
2026
|
+
siteSaver.current?.update(next.data);
|
|
2027
|
+
setSite(next.data);
|
|
2028
|
+
},
|
|
2029
|
+
[site, readOnly]
|
|
2030
|
+
);
|
|
2031
|
+
const onMenuEdit = React7.useCallback(
|
|
2032
|
+
(items) => {
|
|
2033
|
+
setMenuDraft(items);
|
|
2034
|
+
if (items.every((i) => menuItemProblems(i).length === 0)) onNavChange(items.map((i) => ({ label: i.label.trim(), href: i.href.trim() })));
|
|
2035
|
+
},
|
|
2036
|
+
[onNavChange]
|
|
2037
|
+
);
|
|
2038
|
+
const flushAll = React7.useCallback(async () => {
|
|
2039
|
+
if (invalid) {
|
|
2040
|
+
setNotice({ tone: "error", text: `Fix this first: ${invalid}` });
|
|
2041
|
+
return false;
|
|
2042
|
+
}
|
|
2043
|
+
if (menuInvalid) {
|
|
2044
|
+
setNotice({ tone: "error", text: "Fix the menu first: a link there can't be saved yet." });
|
|
2045
|
+
return false;
|
|
2046
|
+
}
|
|
2047
|
+
try {
|
|
2048
|
+
await Promise.all([pageSaver.current?.flush(), siteSaver.current?.flush()]);
|
|
2049
|
+
return true;
|
|
2050
|
+
} catch (err) {
|
|
2051
|
+
setNotice({ tone: "error", text: err.message });
|
|
2052
|
+
return false;
|
|
2053
|
+
}
|
|
2054
|
+
}, [invalid, menuInvalid]);
|
|
2055
|
+
const resync = React7.useCallback(async (options2 = {}) => {
|
|
2056
|
+
const [draft, list] = await Promise.all([store.getDraft(siteId), store.listPages(siteId)]);
|
|
2057
|
+
setPages(list);
|
|
2058
|
+
let redraw = false;
|
|
2059
|
+
let siteDoc = siteSaver.current?.latest ?? site;
|
|
2060
|
+
if (draft && siteSaver.current?.dirty && options2.published?.site.includes(stableHash(draft.site))) siteSaver.current.rebase(draft.rev);
|
|
2061
|
+
if (draft && siteSaver.current && !siteSaver.current.dirty) {
|
|
2062
|
+
const ours = siteSaver.current.latest;
|
|
2063
|
+
redraw = stableHash([ours?.header, ours?.footer]) !== stableHash([draft.site.header, draft.site.footer]);
|
|
2064
|
+
siteSaver.current.rebase(draft.rev, draft.site);
|
|
2065
|
+
setSite(draft.site);
|
|
2066
|
+
setMenuDraft(null);
|
|
2067
|
+
siteDoc = draft.site;
|
|
2068
|
+
}
|
|
2069
|
+
let pageDoc = pageSaver.current?.latest ?? null;
|
|
2070
|
+
if (open && pageSaver.current && pageSaverFor.current === open.id) {
|
|
2071
|
+
const p = await store.getPageDraft(open.id);
|
|
2072
|
+
if (p && pageSaver.current.dirty) {
|
|
2073
|
+
if (options2.published?.page.includes(stableHash(p.doc))) pageSaver.current.rebase(p.rev);
|
|
2074
|
+
} else if (p && !pageSaver.current.dirty) {
|
|
2075
|
+
if (stableHash(p.doc) !== stableHash(pageSaver.current.latest)) redraw = true;
|
|
2076
|
+
pageSaver.current.rebase(p.rev, p.doc);
|
|
2077
|
+
pageDoc = p.doc;
|
|
2078
|
+
}
|
|
2079
|
+
if (p && p.path !== open.path) setOpen((o) => o ? { ...o, path: p.path } : o);
|
|
2080
|
+
}
|
|
2081
|
+
if (redraw && open && pageDoc && siteDoc) {
|
|
2082
|
+
const data = toEditorData(pageDoc, siteDoc, slotKeys);
|
|
2083
|
+
const placeholders = placeholderSnapshot([...pageDoc.content, ...siteDoc.header, ...siteDoc.footer]);
|
|
2084
|
+
setOpen((o) => o ? { ...o, data, placeholders } : o);
|
|
2085
|
+
setEditorKey((k) => k + 1);
|
|
2086
|
+
}
|
|
2087
|
+
return { draft, list };
|
|
2088
|
+
}, [store, siteId, open, site, slotKeys]);
|
|
2089
|
+
const runCheck = React7.useCallback(async () => {
|
|
2090
|
+
if (!await flushAll()) return;
|
|
2091
|
+
setChecking(true);
|
|
2092
|
+
try {
|
|
2093
|
+
const result = await checkSite(store, siteId, { brand, ...reserved ? { reserved } : {} });
|
|
2094
|
+
setIssues(result.issues);
|
|
2095
|
+
} catch (err) {
|
|
2096
|
+
setIssues([{ severity: "error", code: "check-failed", message: `The checks couldn't run: ${err.message}` }]);
|
|
2097
|
+
} finally {
|
|
2098
|
+
setChecking(false);
|
|
2099
|
+
}
|
|
2100
|
+
}, [flushAll, store, siteId, brand, reserved]);
|
|
2101
|
+
const settled = worst(siteState, pageState) === "saved" && !invalid;
|
|
2102
|
+
React7.useEffect(() => {
|
|
2103
|
+
if (rightTab !== "publish" || !settled) return;
|
|
2104
|
+
const t = setTimeout(() => void runCheck(), 600);
|
|
2105
|
+
return () => clearTimeout(t);
|
|
2106
|
+
}, [rightTab, settled, siteState?.rev, pageState?.rev]);
|
|
2107
|
+
const openPage = async (pageId) => {
|
|
2108
|
+
if (pageId === open?.id) return;
|
|
2109
|
+
if (!await flushAll()) return;
|
|
2110
|
+
setBusy(true);
|
|
2111
|
+
try {
|
|
2112
|
+
focusTitle.current = true;
|
|
2113
|
+
await loadPage(pageId, siteSaver.current?.latest ?? site);
|
|
2114
|
+
} catch (err) {
|
|
2115
|
+
setNotice({ tone: "error", text: err.message });
|
|
2116
|
+
} finally {
|
|
2117
|
+
setBusy(false);
|
|
2118
|
+
}
|
|
2119
|
+
};
|
|
2120
|
+
const holding = async (fn) => {
|
|
2121
|
+
setBusy(true);
|
|
2122
|
+
try {
|
|
2123
|
+
return await fn();
|
|
2124
|
+
} finally {
|
|
2125
|
+
setBusy(false);
|
|
2126
|
+
}
|
|
2127
|
+
};
|
|
2128
|
+
const createPage = (request) => holding(() => createPageNow(request));
|
|
2129
|
+
const createPageNow = async (request) => {
|
|
2130
|
+
if (!await flushAll()) throw new Error("Save your changes first.");
|
|
2131
|
+
const doc = request.template.doc({ title: request.title, brand });
|
|
2132
|
+
const meta = await store.createPage(siteId, { path: request.path, kind: request.template.kind, doc });
|
|
2133
|
+
const { draft } = await resync();
|
|
2134
|
+
let menuNote = "";
|
|
2135
|
+
if (request.addToMenu && draft) {
|
|
2136
|
+
const nav = [...draft.site.nav, { label: request.title.slice(0, 40), href: request.path }];
|
|
2137
|
+
if (nav.length <= 12) {
|
|
2138
|
+
onNavChange(nav);
|
|
2139
|
+
await siteSaver.current?.flush().catch(() => void 0);
|
|
2140
|
+
} else menuNote = " The menu is full (12 links), so it isn't in the menu.";
|
|
2141
|
+
}
|
|
2142
|
+
focusTitle.current = true;
|
|
2143
|
+
await loadPage(meta.id, siteSaver.current?.latest ?? draft?.site ?? site);
|
|
2144
|
+
setNotice({ tone: "info", text: `Added ${request.title} at ${request.path}.${menuNote}` });
|
|
2145
|
+
};
|
|
2146
|
+
const rename = (pageId, path) => holding(() => renameNow(pageId, path));
|
|
2147
|
+
const renameNow = async (pageId, path) => {
|
|
2148
|
+
if (!await flushAll()) throw new Error("Save your changes first.");
|
|
2149
|
+
const before = pages.find((p) => p.id === pageId)?.path;
|
|
2150
|
+
const moved = await renamePage(store, pageId, path, reserved ? { reserved } : {});
|
|
2151
|
+
const { draft } = await resync();
|
|
2152
|
+
if (draft && before && draft.site.nav.some((n) => n.href === before)) onNavChange(draft.site.nav.map((n) => n.href === before ? { ...n, href: moved.path } : n));
|
|
2153
|
+
setNotice({ tone: "info", text: `Moved to ${moved.path}. ${before} now redirects there.` });
|
|
2154
|
+
};
|
|
2155
|
+
const remove = async (pageId) => {
|
|
2156
|
+
if (!await flushAll()) return;
|
|
2157
|
+
setBusy(true);
|
|
2158
|
+
try {
|
|
2159
|
+
const gone = pages.find((p) => p.id === pageId);
|
|
2160
|
+
await store.deletePage(pageId);
|
|
2161
|
+
const { draft, list } = await resync();
|
|
2162
|
+
if (draft && gone && draft.site.nav.some((n) => n.href === gone.path)) onNavChange(draft.site.nav.filter((n) => n.href !== gone.path));
|
|
2163
|
+
focusTitle.current = true;
|
|
2164
|
+
if (pageId === open?.id) {
|
|
2165
|
+
const home = list.find((p) => p.path === "/") ?? list[0];
|
|
2166
|
+
if (home && draft) await loadPage(home.id, siteSaver.current?.latest ?? draft.site);
|
|
2167
|
+
} else focusPageTitle();
|
|
2168
|
+
setNotice({ tone: "info", text: `Deleted ${gone?.title ?? "the page"}.` });
|
|
2169
|
+
} catch (err) {
|
|
2170
|
+
setNotice({ tone: "error", text: err.message });
|
|
2171
|
+
} finally {
|
|
2172
|
+
setBusy(false);
|
|
2173
|
+
}
|
|
2174
|
+
};
|
|
2175
|
+
const reorder = async (pageId, by) => {
|
|
2176
|
+
try {
|
|
2177
|
+
setPages(await movePageInList(store, siteId, pageId, by));
|
|
2178
|
+
} catch (err) {
|
|
2179
|
+
setNotice({ tone: "error", text: err.message });
|
|
2180
|
+
}
|
|
2181
|
+
};
|
|
2182
|
+
const publishedDrafts = async () => {
|
|
2183
|
+
const pageId = open && pageSaverFor.current === open.id ? open.id : null;
|
|
2184
|
+
const [draft, page] = await Promise.all([store.getDraft(siteId), pageId ? store.getPageDraft(pageId) : null]);
|
|
2185
|
+
const siteRev = siteSaver.current?.current.rev;
|
|
2186
|
+
const pageRev = pageSaver.current?.current.rev;
|
|
2187
|
+
return {
|
|
2188
|
+
site: draft && draft.rev === siteRev ? [stableHash(draft.site), stableHash(migrateSite(draft.site).site)] : [],
|
|
2189
|
+
page: page && page.rev === pageRev ? [stableHash(page.doc), stableHash(migratePage(page.doc).doc)] : []
|
|
2190
|
+
};
|
|
2191
|
+
};
|
|
2192
|
+
const publish = async (note) => {
|
|
2193
|
+
if (readOnly) return false;
|
|
2194
|
+
lockedRef.current = true;
|
|
2195
|
+
setPublishing(true);
|
|
2196
|
+
const savers = [siteSaver.current, pageSaver.current];
|
|
2197
|
+
let held = false;
|
|
2198
|
+
let published = { site: [], page: [] };
|
|
2199
|
+
try {
|
|
2200
|
+
if (!await flushAll()) return false;
|
|
2201
|
+
for (const s of savers) s?.pause();
|
|
2202
|
+
held = true;
|
|
2203
|
+
published = await publishedDrafts();
|
|
2204
|
+
const result = await publishSite(store, siteId, { brand, note, ...reserved ? { reserved } : {} });
|
|
2205
|
+
if ("blocked" in result) {
|
|
2206
|
+
setIssues(result.blocked);
|
|
2207
|
+
setNotice({ tone: "error", text: "Not published: fix the problems the checks list." });
|
|
2208
|
+
return false;
|
|
2209
|
+
}
|
|
2210
|
+
await refreshPublishing();
|
|
2211
|
+
setNotice({ tone: "info", text: `Published release ${result.release.number}.` });
|
|
2212
|
+
onPublish?.(result.release);
|
|
2213
|
+
return true;
|
|
2214
|
+
} catch (err) {
|
|
2215
|
+
setNotice({ tone: "error", text: `Publishing failed: ${err.message}` });
|
|
2216
|
+
return false;
|
|
2217
|
+
} finally {
|
|
2218
|
+
if (held) await resync({ published }).catch((err) => console.error("[genesis-sites/editor] couldn't reload the drafts after publishing.", err));
|
|
2219
|
+
for (const s of savers) s?.resume();
|
|
2220
|
+
setPublishing(false);
|
|
2221
|
+
}
|
|
2222
|
+
};
|
|
2223
|
+
const rollback = async (releaseId) => {
|
|
2224
|
+
if (readOnly) return;
|
|
2225
|
+
try {
|
|
2226
|
+
const release = await store.rollback(siteId, releaseId);
|
|
2227
|
+
await refreshPublishing();
|
|
2228
|
+
setNotice({ tone: "info", text: `The live site is release ${release.number} again.` });
|
|
2229
|
+
} catch (err) {
|
|
2230
|
+
setNotice({ tone: "error", text: `Rolling back failed: ${err.message}` });
|
|
2231
|
+
}
|
|
2232
|
+
};
|
|
2233
|
+
const showPreview = async (mode) => {
|
|
2234
|
+
if (!open) return;
|
|
2235
|
+
if (mode === "preview" && !await flushAll()) return;
|
|
2236
|
+
setPreview({ mode, path: open.path, html: null });
|
|
2237
|
+
try {
|
|
2238
|
+
const result = await renderPreview({ store, siteId, brand, path: open.path, mode, ...forms ? { forms } : {} });
|
|
2239
|
+
setPreview({
|
|
2240
|
+
mode,
|
|
2241
|
+
path: open.path,
|
|
2242
|
+
html: result.html,
|
|
2243
|
+
...result.html ? {} : { note: result.location ? `This address redirects to ${result.location}.` : mode === "live" ? "This page isn't on the live site." : "This page couldn't be drawn." }
|
|
2244
|
+
});
|
|
2245
|
+
} catch (err) {
|
|
2246
|
+
setPreview({ mode, path: open.path, html: null, note: `The preview failed: ${err.message}` });
|
|
2247
|
+
}
|
|
2248
|
+
};
|
|
2249
|
+
const conflict = pageState?.status === "conflict" ? "page" : siteState?.status === "conflict" ? "site" : null;
|
|
2250
|
+
const resolveConflict = async (choice) => {
|
|
2251
|
+
if (!open || !site) return;
|
|
2252
|
+
if (choice === "overwrite") {
|
|
2253
|
+
const saver = conflict === "page" ? pageSaver.current : siteSaver.current;
|
|
2254
|
+
await saver?.overwrite().catch((err) => setNotice({ tone: "error", text: err.message }));
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
if (conflict === "site") {
|
|
2258
|
+
const theirs = siteSaver.current?.reload();
|
|
2259
|
+
if (theirs) {
|
|
2260
|
+
setSite(theirs);
|
|
2261
|
+
setMenuDraft(null);
|
|
2262
|
+
const page = pageSaver.current?.latest;
|
|
2263
|
+
if (page) {
|
|
2264
|
+
const placeholders = placeholderSnapshot(page.content);
|
|
2265
|
+
placeholderSnapshot(theirs.header, placeholders);
|
|
2266
|
+
placeholderSnapshot(theirs.footer, placeholders);
|
|
2267
|
+
setOpen({ ...open, data: toEditorData(page, theirs, slotKeys), placeholders });
|
|
2268
|
+
}
|
|
2269
|
+
setEditorKey((k) => k + 1);
|
|
2270
|
+
}
|
|
2271
|
+
} else {
|
|
2272
|
+
const theirs = pageSaver.current?.reload();
|
|
2273
|
+
if (theirs) {
|
|
2274
|
+
const siteDoc = siteSaver.current?.latest ?? site;
|
|
2275
|
+
setPages((list) => list.map((p) => p.id === open.id ? { ...p, title: theirs.root.props.title } : p));
|
|
2276
|
+
setOpen({ ...open, data: toEditorData(theirs, siteDoc, slotKeys), placeholders: placeholderSnapshot([...theirs.content, ...siteDoc.header, ...siteDoc.footer]) });
|
|
2277
|
+
setEditorKey((k) => k + 1);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
};
|
|
2281
|
+
function focusPageTitle() {
|
|
2282
|
+
requestAnimationFrame(() => document.getElementById(titleId)?.focus());
|
|
2283
|
+
}
|
|
2284
|
+
React7.useEffect(() => setViewport("desktop"), [editorKey]);
|
|
2285
|
+
React7.useEffect(() => {
|
|
2286
|
+
if (!focusTitle.current) return;
|
|
2287
|
+
focusTitle.current = false;
|
|
2288
|
+
focusPageTitle();
|
|
2289
|
+
}, [editorKey]);
|
|
2290
|
+
const announce = React7.useCallback((message) => {
|
|
2291
|
+
setAnnouncement("");
|
|
2292
|
+
requestAnimationFrame(() => setAnnouncement(message));
|
|
2293
|
+
}, []);
|
|
2294
|
+
const keepDeleteInPanel = (e) => {
|
|
2295
|
+
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
|
2296
|
+
const target = e.target;
|
|
2297
|
+
const textEntry = target.closest("textarea, [contenteditable=''], [contenteditable='true']") || target instanceof HTMLInputElement && !["button", "checkbox", "radio", "submit", "reset", "file", "range", "color"].includes(target.type);
|
|
2298
|
+
if (!textEntry) e.preventDefault();
|
|
2299
|
+
};
|
|
2300
|
+
const services = React7.useMemo(
|
|
2301
|
+
() => ({
|
|
2302
|
+
siteId,
|
|
2303
|
+
store,
|
|
2304
|
+
...assets ? { assets } : {},
|
|
2305
|
+
pickImage: (opts) => new Promise((resolve) => setPicker({ ...opts?.current ? { current: opts.current } : {}, ...opts?.altElsewhere ? { altElsewhere: true } : {}, resolve }))
|
|
2306
|
+
}),
|
|
2307
|
+
[siteId, store, assets]
|
|
2308
|
+
);
|
|
2309
|
+
const metadata = React7.useMemo(
|
|
2310
|
+
() => ({
|
|
2311
|
+
site: { name: site?.name ?? "", lang: site?.lang ?? "en", nav: site?.nav ?? [], ...brand && "logo" in brand && brand.logo ? { logo: { src: brand.logo.src, alt: brand.logo.alt?.trim() || brandDisplayName(brand) } } : {} },
|
|
2312
|
+
pagePath: open?.path ?? "/",
|
|
2313
|
+
forms: Object.fromEntries(formMap),
|
|
2314
|
+
h1Id,
|
|
2315
|
+
placeholders: Object.fromEntries(open?.placeholders ?? [])
|
|
2316
|
+
}),
|
|
2317
|
+
[site?.name, site?.lang, site?.nav, brand, open?.path, open?.placeholders, formMap, h1Id]
|
|
2318
|
+
);
|
|
2319
|
+
const overrides = React7.useMemo(() => genesisOverrides(editor.components, { iframe }, { announce }), [editor.components, iframe, announce]);
|
|
2320
|
+
const permissions = locked ? LOCKED : UNLOCKED;
|
|
2321
|
+
if (loadError) {
|
|
2322
|
+
return /* @__PURE__ */ jsxs(Alert, { variant: "destructive", className, children: [
|
|
2323
|
+
/* @__PURE__ */ jsx(AlertTitle, { children: "The editor couldn't open this site" }),
|
|
2324
|
+
/* @__PURE__ */ jsx(AlertDescription, { children: loadError })
|
|
2325
|
+
] });
|
|
2326
|
+
}
|
|
2327
|
+
if (!site || !open) {
|
|
2328
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("flex h-full min-h-64 items-center justify-center gap-2 text-sm text-muted-foreground", className), role: "status", children: [
|
|
2329
|
+
/* @__PURE__ */ jsx(Spinner, {}),
|
|
2330
|
+
" Opening the editor\u2026"
|
|
2331
|
+
] });
|
|
2332
|
+
}
|
|
2333
|
+
const saveStatus = invalid || menuInvalid ? "invalid" : worst(siteState, pageState);
|
|
2334
|
+
const pageMeta = pages.find((p) => p.id === open.id);
|
|
2335
|
+
const pageTitle = pageMeta?.title ?? open.data.root.props.title;
|
|
2336
|
+
const blockedReason = invalid ? `Fix this first: ${invalid}` : conflict ? "Choose a version in the conflict first." : null;
|
|
2337
|
+
return /* @__PURE__ */ jsx(EditorServicesContext.Provider, { value: services, children: /* @__PURE__ */ jsx(EditorLockContext.Provider, { value: locked, children: /* @__PURE__ */ jsxs("div", { className: cn("flex h-full min-h-0 flex-col bg-background text-foreground", className), "data-gs-editor": "", children: [
|
|
2338
|
+
/* @__PURE__ */ jsx(
|
|
2339
|
+
Puck,
|
|
2340
|
+
{
|
|
2341
|
+
config: editor.config,
|
|
2342
|
+
data: open.data,
|
|
2343
|
+
onChange,
|
|
2344
|
+
permissions,
|
|
2345
|
+
overrides,
|
|
2346
|
+
metadata,
|
|
2347
|
+
iframe: { enabled: true, waitForStyles: true },
|
|
2348
|
+
viewports: [
|
|
2349
|
+
{ width: 390, label: "Phone", icon: "Smartphone" },
|
|
2350
|
+
{ width: 768, label: "Tablet", icon: "Tablet" },
|
|
2351
|
+
{ width: "100%", label: "Desktop", icon: "Monitor" }
|
|
2352
|
+
],
|
|
2353
|
+
headerTitle: site.name,
|
|
2354
|
+
children: /* @__PURE__ */ jsxs("div", { className: "flex h-full min-h-0 flex-col", children: [
|
|
2355
|
+
/* @__PURE__ */ jsx(SelectionWatcher, { onSelect: () => setRightTab("edit") }),
|
|
2356
|
+
/* @__PURE__ */ jsx("p", { role: "status", className: "sr-only", children: announcement }),
|
|
2357
|
+
/* @__PURE__ */ jsx(Toolbar, { siteName: site.name, pageTitle: `${pageTitle} (${open.path})`, titleId, saveStatus, mode: readOnly ? "view" : publishing ? "publishing" : "edit", viewport, onViewport: setViewport, onPreview: () => void showPreview("preview") }),
|
|
2358
|
+
readOnly ? /* @__PURE__ */ jsx("p", { role: "note", className: "border-b border-border bg-muted px-3 py-2 text-sm", "data-gs-view-only": "", children: "View only: you can look through this site's pages, but not change or publish them." }) : null,
|
|
2359
|
+
notice || invalid ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2 border-b border-border px-3 py-2", children: [
|
|
2360
|
+
invalid ? /* @__PURE__ */ jsxs("p", { role: "alert", className: "text-sm text-destructive", children: [
|
|
2361
|
+
"Not saved yet: ",
|
|
2362
|
+
invalid
|
|
2363
|
+
] }) : null,
|
|
2364
|
+
notice ? /* @__PURE__ */ jsxs("div", { role: notice.tone === "error" ? "alert" : "status", className: cn("flex items-center justify-between gap-2 text-sm", notice.tone === "error" ? "text-destructive" : "text-foreground"), children: [
|
|
2365
|
+
/* @__PURE__ */ jsx("span", { children: notice.text }),
|
|
2366
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "ghost", onClick: () => setNotice(null), "aria-label": "Dismiss", children: "Dismiss" })
|
|
2367
|
+
] }) : null
|
|
2368
|
+
] }) : null,
|
|
2369
|
+
/* @__PURE__ */ jsxs("div", { className: "grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[17rem_minmax(0,1fr)_20rem]", inert: busy || void 0, "aria-busy": busy || void 0, children: [
|
|
2370
|
+
/* @__PURE__ */ jsx("aside", { className: "flex min-h-0 flex-col border-b border-border lg:border-b-0 lg:border-r", "aria-label": "Sections and pages", onKeyDown: keepDeleteInPanel, children: /* @__PURE__ */ jsxs(Tabs, { defaultValue: "add", value: leftTab, onValueChange: setLeftTab, className: "flex min-h-0 flex-1 flex-col", children: [
|
|
2371
|
+
/* @__PURE__ */ jsxs(TabsList, { className: cn("m-2 grid", readOnly ? "grid-cols-2" : "grid-cols-3"), children: [
|
|
2372
|
+
readOnly ? null : /* @__PURE__ */ jsx(TabsTrigger, { value: "add", children: "Add" }),
|
|
2373
|
+
/* @__PURE__ */ jsx(TabsTrigger, { value: "outline", children: "Outline" }),
|
|
2374
|
+
/* @__PURE__ */ jsx(TabsTrigger, { value: "pages", children: "Pages" })
|
|
2375
|
+
] }),
|
|
2376
|
+
readOnly ? null : /* @__PURE__ */ jsxs(TabsContent, { value: "add", className: "mt-0 min-h-0 flex-1 overflow-y-auto px-2 pb-4", "data-gs-drawer": "", children: [
|
|
2377
|
+
/* @__PURE__ */ jsx("p", { className: "px-1 pb-2 text-xs text-muted-foreground", children: publishing ? "Publishing: sections can be added again in a moment." : "Drag a section onto the page, or press Add to put it at the end." }),
|
|
2378
|
+
/* @__PURE__ */ jsx(Puck.Components, {})
|
|
2379
|
+
] }),
|
|
2380
|
+
/* @__PURE__ */ jsx(TabsContent, { value: "outline", className: "mt-0 min-h-0 flex-1 overflow-y-auto px-2 pb-4", children: /* @__PURE__ */ jsx(Puck.Outline, {}) }),
|
|
2381
|
+
/* @__PURE__ */ jsx(TabsContent, { value: "pages", className: "mt-0 min-h-0 flex-1 overflow-y-auto", children: /* @__PURE__ */ jsx(
|
|
2382
|
+
PagesTab,
|
|
2383
|
+
{
|
|
2384
|
+
pages,
|
|
2385
|
+
currentId: open.id,
|
|
2386
|
+
nav: menuDraft ?? site.nav,
|
|
2387
|
+
reserved,
|
|
2388
|
+
canReorder: Boolean(store.reorderPages),
|
|
2389
|
+
busy: busy || publishing,
|
|
2390
|
+
readOnly,
|
|
2391
|
+
onOpen: (id) => void openPage(id),
|
|
2392
|
+
onCreate: createPage,
|
|
2393
|
+
onRename: rename,
|
|
2394
|
+
onDelete: remove,
|
|
2395
|
+
onReorder: reorder,
|
|
2396
|
+
onNavChange: onMenuEdit,
|
|
2397
|
+
onFooter: () => setRightTab("edit")
|
|
2398
|
+
}
|
|
2399
|
+
) })
|
|
2400
|
+
] }) }),
|
|
2401
|
+
/* @__PURE__ */ jsx("section", { className: "relative min-h-[28rem] min-w-0 bg-muted", "aria-label": "Page canvas", "data-gs-canvas": "", children: /* @__PURE__ */ jsx(Puck.Preview, {}) }),
|
|
2402
|
+
/* @__PURE__ */ jsx("aside", { className: "flex min-h-0 flex-col border-t border-border lg:border-l lg:border-t-0", "aria-label": "Section settings and publishing", onKeyDown: keepDeleteInPanel, children: /* @__PURE__ */ jsxs(Tabs, { defaultValue: "edit", value: rightTab, onValueChange: setRightTab, className: "flex min-h-0 flex-1 flex-col", children: [
|
|
2403
|
+
/* @__PURE__ */ jsxs(TabsList, { className: "m-2 grid grid-cols-2", children: [
|
|
2404
|
+
/* @__PURE__ */ jsx(TabsTrigger, { value: "edit", children: "Edit" }),
|
|
2405
|
+
/* @__PURE__ */ jsx(TabsTrigger, { value: "publish", children: readOnly ? "Releases" : "Publish" })
|
|
2406
|
+
] }),
|
|
2407
|
+
/* @__PURE__ */ jsx(TabsContent, { value: "edit", className: "mt-0 min-h-0 flex-1 overflow-y-auto px-2 pb-4", "data-gs-fields": "", children: /* @__PURE__ */ jsx(Puck.Fields, {}) }),
|
|
2408
|
+
/* @__PURE__ */ jsx(TabsContent, { value: "publish", className: "mt-0 min-h-0 flex-1 overflow-y-auto", children: /* @__PURE__ */ jsx(
|
|
2409
|
+
PublishPanel,
|
|
2410
|
+
{
|
|
2411
|
+
pagePath: open.path,
|
|
2412
|
+
pageTitle,
|
|
2413
|
+
siteName: site.name,
|
|
2414
|
+
issues,
|
|
2415
|
+
checking,
|
|
2416
|
+
publishing,
|
|
2417
|
+
blockedReason,
|
|
2418
|
+
releases,
|
|
2419
|
+
liveReleaseId: liveId,
|
|
2420
|
+
previewUrl,
|
|
2421
|
+
currentPageId: open.id,
|
|
2422
|
+
readOnly,
|
|
2423
|
+
onCheck: () => void runCheck(),
|
|
2424
|
+
onPublish: publish,
|
|
2425
|
+
onRollback: rollback,
|
|
2426
|
+
onPreview: (mode) => void showPreview(mode),
|
|
2427
|
+
onOpenPage: (id) => void openPage(id),
|
|
2428
|
+
onShowSection: (id) => {
|
|
2429
|
+
setRightTab("edit");
|
|
2430
|
+
scrollCanvasTo(id);
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
) })
|
|
2434
|
+
] }) })
|
|
2435
|
+
] })
|
|
2436
|
+
] })
|
|
2437
|
+
},
|
|
2438
|
+
editorKey
|
|
2439
|
+
),
|
|
2440
|
+
/* @__PURE__ */ jsx(
|
|
2441
|
+
AssetPicker,
|
|
2442
|
+
{
|
|
2443
|
+
open: picker !== null,
|
|
2444
|
+
siteId,
|
|
2445
|
+
store,
|
|
2446
|
+
assets,
|
|
2447
|
+
current: picker?.current,
|
|
2448
|
+
altElsewhere: picker?.altElsewhere,
|
|
2449
|
+
onDone: (image) => {
|
|
2450
|
+
picker?.resolve(image);
|
|
2451
|
+
setPicker(null);
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
),
|
|
2455
|
+
/* @__PURE__ */ jsx(AlertDialog, { open: conflict !== null, children: /* @__PURE__ */ jsxs(AlertDialogContent, { children: [
|
|
2456
|
+
/* @__PURE__ */ jsxs(AlertDialogHeader, { children: [
|
|
2457
|
+
/* @__PURE__ */ jsx(AlertDialogTitle, { children: conflict === "page" ? "Someone else saved this page" : "Someone else saved the site's header, footer or menu" }),
|
|
2458
|
+
/* @__PURE__ */ jsx(AlertDialogDescription, { children: "Their changes reached the server while you were editing. Load their version (your unsaved changes here are dropped), or keep yours (theirs are replaced)." })
|
|
2459
|
+
] }),
|
|
2460
|
+
/* @__PURE__ */ jsxs(AlertDialogFooter, { children: [
|
|
2461
|
+
/* @__PURE__ */ jsx(AlertDialogCancel, { onClick: () => void resolveConflict("reload"), children: "Load their version" }),
|
|
2462
|
+
/* @__PURE__ */ jsx(AlertDialogAction, { onClick: () => void resolveConflict("overwrite"), children: "Keep mine" })
|
|
2463
|
+
] })
|
|
2464
|
+
] }) }),
|
|
2465
|
+
/* @__PURE__ */ jsx(Dialog, { open: preview !== null, onOpenChange: (o) => o ? null : setPreview(null), children: /* @__PURE__ */ jsxs(DialogContent, { className: "flex h-[90vh] w-[95vw] max-w-6xl flex-col sm:max-w-6xl", children: [
|
|
2466
|
+
/* @__PURE__ */ jsxs(DialogHeader, { children: [
|
|
2467
|
+
/* @__PURE__ */ jsx(DialogTitle, { children: preview?.mode === "live" ? `Live: ${preview.path}` : `Preview: ${preview?.path}` }),
|
|
2468
|
+
/* @__PURE__ */ jsx(DialogDescription, { children: preview?.mode === "live" ? "What visitors get now, from the published release." : "Your drafts, drawn by the same renderer the live site uses." })
|
|
2469
|
+
] }),
|
|
2470
|
+
preview?.html ? /* @__PURE__ */ jsx("iframe", { title: `${preview.mode === "live" ? "Live page" : "Preview"} of ${preview.path}`, srcDoc: preview.html, sandbox: "", className: "min-h-0 w-full flex-1 rounded-md border border-border bg-background" }) : preview?.note ? /* @__PURE__ */ jsx("p", { className: "text-sm", children: preview.note }) : /* @__PURE__ */ jsxs("div", { role: "status", className: "flex items-center gap-2 text-sm text-muted-foreground", children: [
|
|
2471
|
+
/* @__PURE__ */ jsx(Spinner, {}),
|
|
2472
|
+
" Drawing the page\u2026"
|
|
2473
|
+
] })
|
|
2474
|
+
] }) })
|
|
2475
|
+
] }) }) });
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
export { AssetPicker, AutosaveBlockedError, Autosaver, IMAGE_TYPES, NODE_KEYS, SiteEditor, checkSite, countTypes, createEditorConfig, fromEditorData, genesisOverrides, h1SectionId, humanize, itemToNode, movePageInList, nodeToItem, pageTemplates, pathProblems, placeholderSnapshot, presetComponentName, pruneEmpty, publishSite, renamePage, renderPreview, safeSectionId, sectionFields, sectionTypeOf, slugify, toEditorData, uploadProblem };
|
|
2479
|
+
//# sourceMappingURL=editor.js.map
|
|
2480
|
+
//# sourceMappingURL=editor.js.map
|