@orion-studios/cms 0.5.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.
@@ -0,0 +1,3824 @@
1
+ 'use client';
2
+ "use client";
3
+ import {
4
+ FORM_FIELD_TYPES,
5
+ FormRenderer
6
+ } from "../chunk-WQDHEQDE.js";
7
+
8
+ // src/studio/Studio.tsx
9
+ import { useCallback as useCallback5, useEffect as useEffect11, useMemo as useMemo5, useState as useState12 } from "react";
10
+
11
+ // src/studio/api.ts
12
+ var StudioApiError = class extends Error {
13
+ status;
14
+ fieldErrors;
15
+ issues;
16
+ usage;
17
+ constructor(status, message, extra) {
18
+ super(message);
19
+ this.status = status;
20
+ this.fieldErrors = extra?.fieldErrors;
21
+ this.issues = extra?.issues;
22
+ this.usage = extra?.usage;
23
+ }
24
+ };
25
+ function createStudioApi(options) {
26
+ const base = options.basePath ?? "/api/cms";
27
+ const request = async (method, path, body) => {
28
+ const token = await options.getToken();
29
+ const isForm = typeof FormData !== "undefined" && body instanceof FormData;
30
+ return fetch(`${base}${path}`, {
31
+ method,
32
+ headers: {
33
+ ...token ? { authorization: `Bearer ${token}` } : {},
34
+ ...body !== void 0 && !isForm ? { "content-type": "application/json" } : {}
35
+ },
36
+ ...body !== void 0 ? { body: isForm ? body : JSON.stringify(body) } : {}
37
+ });
38
+ };
39
+ const call = async (method, path, body) => {
40
+ const response = await request(method, path, body);
41
+ const payload = await response.json().catch(() => ({}));
42
+ if (!response.ok) {
43
+ throw new StudioApiError(response.status, String(payload.error || "Request failed."), {
44
+ fieldErrors: payload.fieldErrors,
45
+ issues: payload.issues,
46
+ usage: payload.usage
47
+ });
48
+ }
49
+ return payload;
50
+ };
51
+ return {
52
+ me: () => call(
53
+ "GET",
54
+ "/me"
55
+ ),
56
+ // ---- Pages ------------------------------------------------------------
57
+ listPages: () => call("GET", "/pages"),
58
+ getPage: (id) => call("GET", `/pages/${id}`),
59
+ createPage: (input) => call("POST", "/pages", input),
60
+ saveDraft: (id, input) => call("PATCH", `/pages/${id}`, input),
61
+ publish: (id) => call("POST", `/pages/${id}/publish`),
62
+ unpublish: (id) => call("POST", `/pages/${id}/unpublish`),
63
+ duplicatePage: (id) => call("POST", `/pages/${id}/duplicate`),
64
+ deletePage: (id) => call("DELETE", `/pages/${id}`),
65
+ previewToken: (id) => call("POST", `/pages/${id}/preview`),
66
+ listVersions: (id) => call("GET", `/pages/${id}/versions`),
67
+ getVersion: (versionId) => call("GET", `/versions/${versionId}`),
68
+ restoreVersion: (versionId) => call("POST", `/versions/${versionId}/restore`),
69
+ // ---- Globals ----------------------------------------------------------
70
+ getGlobal: (key) => call("GET", `/globals/${key}`),
71
+ updateGlobal: (key, data) => call("PATCH", `/globals/${key}`, { data }),
72
+ listGlobalVersions: (key) => call(
73
+ "GET",
74
+ `/globals/${key}/versions`
75
+ ),
76
+ restoreGlobalVersion: (versionId) => call(
77
+ "POST",
78
+ `/global-versions/${versionId}/restore`
79
+ ),
80
+ // ---- Media ------------------------------------------------------------
81
+ listMedia: () => call("GET", "/media"),
82
+ uploadMedia: (form) => call("POST", "/media", form),
83
+ updateMedia: (id, input) => call("PATCH", `/media/${id}`, input),
84
+ replaceMedia: (id, form) => call("POST", `/media/${id}/replace`, form),
85
+ mediaUsage: (id) => call("GET", `/media/${id}/usage`),
86
+ deleteMedia: (id, force = false) => call("DELETE", `/media/${id}${force ? "?force=true" : ""}`),
87
+ // ---- Forms ------------------------------------------------------------
88
+ listForms: () => call("GET", "/forms"),
89
+ createForm: (input) => call("POST", "/forms", input),
90
+ getForm: (slug) => call("GET", `/forms/${slug}`),
91
+ updateForm: (slug, input) => call("PATCH", `/forms/${slug}`, input),
92
+ deleteForm: (slug) => call("DELETE", `/forms/${slug}`),
93
+ // ---- Submissions --------------------------------------------------------
94
+ listSubmissions: (params) => {
95
+ const query = new URLSearchParams();
96
+ if (params?.form) query.set("form", params.form);
97
+ if (params?.beforeId) query.set("beforeId", String(params.beforeId));
98
+ if (params?.unread) query.set("unread", "1");
99
+ if (params?.limit) query.set("limit", String(params.limit));
100
+ const suffix = query.toString() ? `?${query.toString()}` : "";
101
+ return call("GET", `/submissions${suffix}`);
102
+ },
103
+ markSubmission: (id, read) => call("PATCH", `/submissions/${id}`, { read }),
104
+ deleteSubmission: (id) => call("DELETE", `/submissions/${id}`),
105
+ exportSubmissionsCsv: async (form) => {
106
+ const response = await request("GET", `/submissions/export${form ? `?form=${form}` : ""}`);
107
+ if (!response.ok) throw new StudioApiError(response.status, "Export failed.");
108
+ return response.text();
109
+ },
110
+ // ---- Redirects ----------------------------------------------------------
111
+ listRedirects: () => call("GET", "/redirects"),
112
+ createRedirect: (input) => call("POST", "/redirects", input),
113
+ deleteRedirect: (id) => call("DELETE", `/redirects/${id}`),
114
+ // ---- Dashboard / activity / analytics --------------------------------------
115
+ dashboard: () => call("GET", "/dashboard"),
116
+ listActivity: () => call("GET", "/activity"),
117
+ analytics: (params) => {
118
+ const query = new URLSearchParams();
119
+ if (params?.from) query.set("from", params.from);
120
+ if (params?.to) query.set("to", params.to);
121
+ const suffix = query.toString() ? `?${query.toString()}` : "";
122
+ return call("GET", `/analytics${suffix}`);
123
+ },
124
+ // ---- Users ----------------------------------------------------------------
125
+ listUsers: () => call("GET", "/users"),
126
+ createUser: (input) => call("POST", "/users", input),
127
+ updateUser: (id, input) => call("PATCH", `/users/${id}`, input),
128
+ deleteUser: (id) => call("DELETE", `/users/${id}`)
129
+ };
130
+ }
131
+
132
+ // src/studio/auth.tsx
133
+ import { createClient } from "@supabase/supabase-js";
134
+ import { useCallback, useEffect, useMemo, useState as useState2 } from "react";
135
+
136
+ // src/studio/PasswordInput.tsx
137
+ import { useState } from "react";
138
+ import { jsx, jsxs } from "react/jsx-runtime";
139
+ var EyeIcon = ({ off }) => /* @__PURE__ */ jsxs(
140
+ "svg",
141
+ {
142
+ "aria-hidden": "true",
143
+ fill: "none",
144
+ height: "18",
145
+ stroke: "currentColor",
146
+ strokeLinecap: "round",
147
+ strokeLinejoin: "round",
148
+ strokeWidth: "1.7",
149
+ viewBox: "0 0 24 24",
150
+ width: "18",
151
+ children: [
152
+ /* @__PURE__ */ jsx("path", { d: "M2.5 12s3.5-6.5 9.5-6.5S21.5 12 21.5 12s-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z" }),
153
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "2.8" }),
154
+ off ? /* @__PURE__ */ jsx("line", { x1: "4", x2: "20", y1: "20", y2: "4" }) : null
155
+ ]
156
+ }
157
+ );
158
+ function PasswordInput({ className, ...props }) {
159
+ const [visible, setVisible] = useState(false);
160
+ return /* @__PURE__ */ jsxs("div", { className: "ost-password", children: [
161
+ /* @__PURE__ */ jsx(
162
+ "input",
163
+ {
164
+ ...props,
165
+ className: className ? `ost-input ${className}` : "ost-input",
166
+ type: visible ? "text" : "password"
167
+ }
168
+ ),
169
+ /* @__PURE__ */ jsx(
170
+ "button",
171
+ {
172
+ "aria-label": visible ? "Hide password" : "Show password",
173
+ "aria-pressed": visible,
174
+ className: "ost-password-toggle",
175
+ onClick: () => setVisible((current) => !current),
176
+ tabIndex: -1,
177
+ title: visible ? "Hide password" : "Show password",
178
+ type: "button",
179
+ children: /* @__PURE__ */ jsx(EyeIcon, { off: visible })
180
+ }
181
+ )
182
+ ] });
183
+ }
184
+
185
+ // src/studio/auth.tsx
186
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
187
+ var browserClient = null;
188
+ function getBrowserSupabase() {
189
+ if (!browserClient) {
190
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
191
+ const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
192
+ if (!url || !anonKey) {
193
+ throw new Error("Orion Studio: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY must be set.");
194
+ }
195
+ browserClient = createClient(url, anonKey);
196
+ }
197
+ return browserClient;
198
+ }
199
+ var isMemoryMode = () => (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) && (process.env.NODE_ENV !== "production" || process.env.NEXT_PUBLIC_CMS_MEMORY === "true");
200
+ var MEMORY_DEV_TOKEN = "orion-dev-token";
201
+ var memorySession = {
202
+ access_token: MEMORY_DEV_TOKEN,
203
+ user: { id: "dev", email: "dev@local" }
204
+ };
205
+ function useStudioSession() {
206
+ const memory = isMemoryMode();
207
+ const supabase = useMemo(() => memory ? null : getBrowserSupabase(), [memory]);
208
+ const [session, setSession] = useState2(memory ? memorySession : null);
209
+ const [loading, setLoading] = useState2(!memory);
210
+ useEffect(() => {
211
+ if (!supabase) return;
212
+ let active = true;
213
+ supabase.auth.getSession().then(({ data }) => {
214
+ if (!active) return;
215
+ setSession(data.session);
216
+ setLoading(false);
217
+ });
218
+ const { data: subscription } = supabase.auth.onAuthStateChange((_event, nextSession) => {
219
+ if (active) setSession(nextSession);
220
+ });
221
+ return () => {
222
+ active = false;
223
+ subscription.subscription.unsubscribe();
224
+ };
225
+ }, [supabase]);
226
+ const getToken = useCallback(async () => {
227
+ if (!supabase) return MEMORY_DEV_TOKEN;
228
+ const { data } = await supabase.auth.getSession();
229
+ return data.session?.access_token ?? null;
230
+ }, [supabase]);
231
+ const signOut = useCallback(async () => {
232
+ if (supabase) await supabase.auth.signOut();
233
+ }, [supabase]);
234
+ return { session, loading, getToken, signOut };
235
+ }
236
+ function LoginView({ siteName, logoUrl }) {
237
+ const supabase = useMemo(getBrowserSupabase, []);
238
+ const [email, setEmail] = useState2("");
239
+ const [password, setPassword] = useState2("");
240
+ const [error, setError] = useState2("");
241
+ const [busy, setBusy] = useState2(false);
242
+ const submit = async (event) => {
243
+ event.preventDefault();
244
+ setBusy(true);
245
+ setError("");
246
+ const { error: signInError } = await supabase.auth.signInWithPassword({ email, password });
247
+ if (signInError) setError("Invalid email or password.");
248
+ setBusy(false);
249
+ };
250
+ return /* @__PURE__ */ jsx2("div", { className: "ost-root ost-login", children: /* @__PURE__ */ jsxs2("form", { className: "ost-login-card", onSubmit: submit, noValidate: true, children: [
251
+ logoUrl ? /* @__PURE__ */ jsx2("img", { alt: siteName, className: "ost-login-logo", src: logoUrl }) : null,
252
+ /* @__PURE__ */ jsxs2("div", { className: "ost-login-heading", children: [
253
+ /* @__PURE__ */ jsx2("h1", { children: siteName }),
254
+ /* @__PURE__ */ jsx2("p", { className: "ost-muted", children: "Sign in to the Studio" })
255
+ ] }),
256
+ /* @__PURE__ */ jsxs2("label", { className: "ost-label", children: [
257
+ "Email",
258
+ /* @__PURE__ */ jsx2(
259
+ "input",
260
+ {
261
+ autoComplete: "email",
262
+ autoFocus: true,
263
+ className: "ost-input",
264
+ onChange: (event) => setEmail(event.target.value),
265
+ placeholder: "you@company.com",
266
+ type: "email",
267
+ value: email
268
+ }
269
+ )
270
+ ] }),
271
+ /* @__PURE__ */ jsxs2("label", { className: "ost-label", children: [
272
+ "Password",
273
+ /* @__PURE__ */ jsx2(
274
+ PasswordInput,
275
+ {
276
+ autoComplete: "current-password",
277
+ onChange: (event) => setPassword(event.target.value),
278
+ placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
279
+ value: password
280
+ }
281
+ )
282
+ ] }),
283
+ error ? /* @__PURE__ */ jsx2("div", { className: "ost-login-error", role: "alert", children: error }) : null,
284
+ /* @__PURE__ */ jsx2("button", { className: "ost-btn ost-btn-primary ost-login-submit", disabled: busy, type: "submit", children: busy ? "Signing in\u2026" : "Sign in" }),
285
+ /* @__PURE__ */ jsx2("p", { className: "ost-login-foot", children: "Powered by Orion Studios" })
286
+ ] }) });
287
+ }
288
+
289
+ // src/studio/views/AnalyticsView.tsx
290
+ import { useEffect as useEffect2, useMemo as useMemo2, useState as useState3 } from "react";
291
+ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
292
+ var RANGES = [
293
+ { key: "7", label: "7 days", days: 7 },
294
+ { key: "30", label: "30 days", days: 30 },
295
+ { key: "90", label: "90 days", days: 90 }
296
+ ];
297
+ var pct = (value) => `${Math.round(value * 100)}%`;
298
+ function delta(current, previous) {
299
+ if (previous === 0) return current > 0 ? { text: "new", direction: "up" } : { text: "\u2014", direction: "flat" };
300
+ const change = (current - previous) / previous;
301
+ if (Math.abs(change) < 5e-3) return { text: "\u2014", direction: "flat" };
302
+ return {
303
+ text: `${change > 0 ? "+" : ""}${Math.round(change * 100)}%`,
304
+ direction: change > 0 ? "up" : "down"
305
+ };
306
+ }
307
+ function Kpi({
308
+ label,
309
+ value,
310
+ previous,
311
+ highlight
312
+ }) {
313
+ const change = previous ? delta(previous.current, previous.prior) : null;
314
+ return /* @__PURE__ */ jsxs3("div", { className: `ost-card ost-kpi${highlight ? " is-highlight" : ""}`, children: [
315
+ /* @__PURE__ */ jsx3("span", { className: "ost-kpi-label", children: label }),
316
+ /* @__PURE__ */ jsx3("strong", { className: "ost-kpi-value", children: value }),
317
+ change ? /* @__PURE__ */ jsxs3("span", { className: `ost-kpi-delta is-${change.direction}`, children: [
318
+ change.text,
319
+ change.text !== "\u2014" && change.text !== "new" ? " vs previous" : ""
320
+ ] }) : null
321
+ ] });
322
+ }
323
+ function TrendChart({ trend }) {
324
+ const width = 720;
325
+ const height = 120;
326
+ const max = Math.max(1, ...trend.map((day) => day.visitors));
327
+ const barWidth = width / Math.max(trend.length, 1);
328
+ const formatDay = (iso) => (/* @__PURE__ */ new Date(`${iso}T12:00:00Z`)).toLocaleDateString(void 0, { month: "short", day: "numeric" });
329
+ return /* @__PURE__ */ jsxs3("div", { className: "ost-trend-wrap", children: [
330
+ /* @__PURE__ */ jsx3("svg", { className: "ost-trend", preserveAspectRatio: "none", viewBox: `0 0 ${width} ${height}`, children: trend.map((day, index) => {
331
+ const barHeight = Math.max(2, day.visitors / max * height);
332
+ const conversionHeight = day.conversions > 0 ? Math.max(3, day.conversions / max * height) : 0;
333
+ return /* @__PURE__ */ jsxs3("g", { children: [
334
+ /* @__PURE__ */ jsx3(
335
+ "rect",
336
+ {
337
+ className: "ost-trend-bar",
338
+ height: barHeight,
339
+ rx: "2",
340
+ width: Math.max(barWidth - 3, 2),
341
+ x: index * barWidth + 1,
342
+ y: height - barHeight,
343
+ children: /* @__PURE__ */ jsx3("title", { children: `${day.day}: ${day.visitors} visitor${day.visitors === 1 ? "" : "s"}, ${day.conversions} conversion${day.conversions === 1 ? "" : "s"}` })
344
+ }
345
+ ),
346
+ conversionHeight > 0 ? /* @__PURE__ */ jsx3(
347
+ "rect",
348
+ {
349
+ className: "ost-trend-conversion",
350
+ height: conversionHeight,
351
+ rx: "2",
352
+ width: Math.max(barWidth - 3, 2),
353
+ x: index * barWidth + 1,
354
+ y: height - conversionHeight
355
+ }
356
+ ) : null
357
+ ] }, day.day);
358
+ }) }),
359
+ trend.length > 0 ? /* @__PURE__ */ jsxs3("div", { className: "ost-trend-dates", children: [
360
+ /* @__PURE__ */ jsx3("span", { children: formatDay(trend[0].day) }),
361
+ /* @__PURE__ */ jsx3("span", { children: formatDay(trend[trend.length - 1].day) })
362
+ ] }) : null
363
+ ] });
364
+ }
365
+ function BarList({
366
+ rows,
367
+ max
368
+ }) {
369
+ const top = max ?? Math.max(1, ...rows.map((row) => row.value));
370
+ return /* @__PURE__ */ jsxs3("div", { className: "ost-barlist", children: [
371
+ rows.map((row) => /* @__PURE__ */ jsxs3("div", { className: "ost-barlist-row", children: [
372
+ /* @__PURE__ */ jsx3("span", { className: "ost-barlist-label", title: row.label, children: row.label }),
373
+ /* @__PURE__ */ jsx3("span", { className: "ost-barlist-track", children: /* @__PURE__ */ jsx3("span", { className: "ost-barlist-fill", style: { width: `${Math.max(row.value / top * 100, 2)}%` } }) }),
374
+ /* @__PURE__ */ jsxs3("span", { className: "ost-barlist-value", children: [
375
+ row.value,
376
+ row.detail ? /* @__PURE__ */ jsxs3("span", { className: "ost-muted", children: [
377
+ " ",
378
+ row.detail
379
+ ] }) : null
380
+ ] })
381
+ ] }, row.label)),
382
+ rows.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "Nothing recorded yet." }) : null
383
+ ] });
384
+ }
385
+ function AnalyticsView({ api }) {
386
+ const [rangeKey, setRangeKey] = useState3("30");
387
+ const [data, setData] = useState3(null);
388
+ const [error, setError] = useState3("");
389
+ useEffect2(() => {
390
+ const range = RANGES.find((entry) => entry.key === rangeKey) ?? RANGES[1];
391
+ const to = /* @__PURE__ */ new Date();
392
+ const from = new Date(to.getTime() - range.days * 864e5);
393
+ setData(null);
394
+ api.analytics({ from: from.toISOString(), to: to.toISOString() }).then(
395
+ setData,
396
+ (e) => setError(e.message)
397
+ );
398
+ }, [api, rangeKey]);
399
+ const localHours = useMemo2(() => {
400
+ if (!data) return [];
401
+ const offset = Math.round((/* @__PURE__ */ new Date()).getTimezoneOffset() / -60);
402
+ return data.hours.map((_, hour) => ({
403
+ hour,
404
+ count: data.hours[((hour - offset) % 24 + 24) % 24]
405
+ }));
406
+ }, [data]);
407
+ if (error) return /* @__PURE__ */ jsx3("div", { className: "ost-view", children: /* @__PURE__ */ jsx3("div", { className: "ost-error", children: error }) });
408
+ const kpis = data?.kpis ?? null;
409
+ const previous = data?.previous;
410
+ return /* @__PURE__ */ jsxs3("div", { className: "ost-view ost-view-wide", children: [
411
+ /* @__PURE__ */ jsxs3("header", { className: "ost-view-header", children: [
412
+ /* @__PURE__ */ jsxs3("div", { children: [
413
+ /* @__PURE__ */ jsx3("h2", { children: "Analytics" }),
414
+ /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "Consent-aware first-party analytics \u2014 what the site does for the business." })
415
+ ] }),
416
+ /* @__PURE__ */ jsx3("div", { className: "ost-row", children: RANGES.map((range) => /* @__PURE__ */ jsx3(
417
+ "button",
418
+ {
419
+ className: `ost-btn${rangeKey === range.key ? " ost-btn-primary" : ""}`,
420
+ onClick: () => setRangeKey(range.key),
421
+ type: "button",
422
+ children: range.label
423
+ },
424
+ range.key
425
+ )) })
426
+ ] }),
427
+ !data ? /* @__PURE__ */ jsx3("div", { className: "ost-loading", children: "Crunching the numbers\u2026" }) : null,
428
+ data && kpis ? /* @__PURE__ */ jsxs3(Fragment, { children: [
429
+ /* @__PURE__ */ jsxs3("div", { className: "ost-kpi-grid", children: [
430
+ /* @__PURE__ */ jsx3(
431
+ Kpi,
432
+ {
433
+ highlight: true,
434
+ label: "Calls tapped",
435
+ previous: previous ? { current: kpis.calls, prior: previous.calls } : void 0,
436
+ value: String(kpis.calls)
437
+ }
438
+ ),
439
+ /* @__PURE__ */ jsx3(
440
+ Kpi,
441
+ {
442
+ highlight: true,
443
+ label: "Requests sent",
444
+ previous: previous ? { current: kpis.formSubmits, prior: previous.formSubmits } : void 0,
445
+ value: String(kpis.formSubmits)
446
+ }
447
+ ),
448
+ /* @__PURE__ */ jsx3(
449
+ Kpi,
450
+ {
451
+ label: "Visitors",
452
+ previous: previous ? { current: kpis.visitors, prior: previous.visitors } : void 0,
453
+ value: String(kpis.visitors)
454
+ }
455
+ ),
456
+ /* @__PURE__ */ jsx3(
457
+ Kpi,
458
+ {
459
+ label: "Returning visitors",
460
+ previous: previous ? { current: kpis.returningVisitors, prior: previous.returningVisitors } : void 0,
461
+ value: String(kpis.returningVisitors)
462
+ }
463
+ ),
464
+ /* @__PURE__ */ jsx3(
465
+ Kpi,
466
+ {
467
+ label: "Visitor-days",
468
+ previous: previous ? { current: kpis.sessions, prior: previous.sessions } : void 0,
469
+ value: String(kpis.sessions)
470
+ }
471
+ ),
472
+ /* @__PURE__ */ jsx3(
473
+ Kpi,
474
+ {
475
+ label: "Pageviews",
476
+ previous: previous ? { current: kpis.pageviews, prior: previous.pageviews } : void 0,
477
+ value: String(kpis.pageviews)
478
+ }
479
+ ),
480
+ /* @__PURE__ */ jsx3(Kpi, { label: "Pages per visitor", value: kpis.pagesPerVisitor.toFixed(1) }),
481
+ /* @__PURE__ */ jsx3(Kpi, { label: "Conversion rate", value: pct(kpis.conversionRate) }),
482
+ /* @__PURE__ */ jsx3(
483
+ Kpi,
484
+ {
485
+ label: "Portal clicks",
486
+ previous: previous ? { current: kpis.portalClicks, prior: previous.portalClicks } : void 0,
487
+ value: String(kpis.portalClicks)
488
+ }
489
+ )
490
+ ] }),
491
+ /* @__PURE__ */ jsxs3("p", { className: "ost-muted ost-analytics-note", children: [
492
+ "Returning visitors and cross-day totals use the pseudonymous cookie only after the visitor accepts analytics.",
493
+ kpis.visitors > 0 ? ` ${kpis.identifiedVisitors} of ${kpis.visitors} visitors in this range were cookie-identified.` : ""
494
+ ] }),
495
+ /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
496
+ /* @__PURE__ */ jsxs3("h3", { children: [
497
+ "Daily visitors ",
498
+ /* @__PURE__ */ jsxs3("span", { className: "ost-trend-key", children: [
499
+ "\u25A0 visitors ",
500
+ /* @__PURE__ */ jsx3("em", { children: "\u25A0 conversions" })
501
+ ] })
502
+ ] }),
503
+ /* @__PURE__ */ jsx3(TrendChart, { trend: data.trend })
504
+ ] }),
505
+ /* @__PURE__ */ jsxs3("div", { className: "ost-analytics-columns", children: [
506
+ /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
507
+ /* @__PURE__ */ jsx3("h3", { children: "Top pages" }),
508
+ /* @__PURE__ */ jsx3(
509
+ BarList,
510
+ {
511
+ rows: data.pages.slice(0, 12).map((page) => ({
512
+ label: page.path === "/" ? "Home" : page.path,
513
+ value: page.views,
514
+ detail: page.conversions > 0 ? `\xB7 ${page.conversions} conv` : void 0
515
+ }))
516
+ }
517
+ )
518
+ ] }),
519
+ /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
520
+ /* @__PURE__ */ jsx3("h3", { children: "Where visitors come from" }),
521
+ /* @__PURE__ */ jsx3(
522
+ BarList,
523
+ {
524
+ rows: data.sources.map((source) => ({
525
+ label: source.source,
526
+ value: source.sessions,
527
+ detail: source.conversions > 0 ? `\xB7 ${source.conversions} conv` : void 0
528
+ }))
529
+ }
530
+ )
531
+ ] }),
532
+ /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
533
+ /* @__PURE__ */ jsx3("h3", { children: "Form funnel" }),
534
+ data.forms.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "No form activity yet." }) : null,
535
+ data.forms.map((form) => /* @__PURE__ */ jsxs3("div", { className: "ost-funnel", children: [
536
+ /* @__PURE__ */ jsx3("strong", { children: form.form }),
537
+ /* @__PURE__ */ jsx3(
538
+ BarList,
539
+ {
540
+ max: Math.max(form.views, 1),
541
+ rows: [
542
+ { label: "Saw the form", value: form.views },
543
+ { label: "Started filling", value: form.starts },
544
+ { label: "Submitted", value: form.submits }
545
+ ]
546
+ }
547
+ )
548
+ ] }, form.form))
549
+ ] }),
550
+ /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
551
+ /* @__PURE__ */ jsx3("h3", { children: "Visitor locations" }),
552
+ /* @__PURE__ */ jsx3(
553
+ BarList,
554
+ {
555
+ rows: data.locations.map((location) => ({
556
+ label: location.location,
557
+ value: location.sessions
558
+ }))
559
+ }
560
+ )
561
+ ] }),
562
+ /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
563
+ /* @__PURE__ */ jsx3("h3", { children: "Common journeys" }),
564
+ data.paths.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "Not enough multi-page visits yet." }) : null,
565
+ data.paths.map((journey) => /* @__PURE__ */ jsxs3("div", { className: "ost-journey", children: [
566
+ /* @__PURE__ */ jsx3("span", { className: "ost-journey-path", children: journey.path.map((step, index) => /* @__PURE__ */ jsxs3("span", { children: [
567
+ index > 0 ? /* @__PURE__ */ jsx3("span", { className: "ost-journey-arrow", children: " \u2192 " }) : null,
568
+ step === "/" ? "Home" : step
569
+ ] }, index)) }),
570
+ /* @__PURE__ */ jsxs3("span", { className: "ost-muted", children: [
571
+ "\xD7",
572
+ journey.count,
573
+ journey.converted > 0 ? ` \xB7 ${journey.converted} converted` : ""
574
+ ] })
575
+ ] }, journey.path.join("\u2192")))
576
+ ] }),
577
+ /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
578
+ /* @__PURE__ */ jsx3("h3", { children: "Housekeeping" }),
579
+ /* @__PURE__ */ jsx3("div", { className: "ost-row ost-housekeeping", children: data.devices.map((device) => /* @__PURE__ */ jsxs3("span", { className: "ost-pill", children: [
580
+ device.device,
581
+ ": ",
582
+ device.sessions
583
+ ] }, device.device)) }),
584
+ /* @__PURE__ */ jsx3("h4", { className: "ost-analytics-subhead", children: "Busiest hours" }),
585
+ /* @__PURE__ */ jsx3("div", { className: "ost-hours", children: localHours.map((entry) => {
586
+ const max = Math.max(1, ...localHours.map((h) => h.count));
587
+ return /* @__PURE__ */ jsx3(
588
+ "span",
589
+ {
590
+ className: "ost-hour-bar",
591
+ style: { height: `${Math.max(entry.count / max * 100, 4)}%` },
592
+ title: `${entry.hour}:00 \u2014 ${entry.count} views`
593
+ },
594
+ entry.hour
595
+ );
596
+ }) }),
597
+ /* @__PURE__ */ jsx3("h4", { className: "ost-analytics-subhead", children: "Broken links (404s)" }),
598
+ data.notFound.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "None \u2014 nice." }) : null,
599
+ /* @__PURE__ */ jsx3(BarList, { rows: data.notFound.slice(0, 8).map((entry) => ({ label: entry.path, value: entry.count })) })
600
+ ] })
601
+ ] })
602
+ ] }) : null
603
+ ] });
604
+ }
605
+
606
+ // src/studio/views/DashboardView.tsx
607
+ import { useEffect as useEffect3, useState as useState4 } from "react";
608
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
609
+ var ACTION_LABELS = {
610
+ "page.create": "created page",
611
+ "page.publish": "published",
612
+ "page.publish.scheduled": "auto-published",
613
+ "page.unpublish": "unpublished",
614
+ "page.delete": "deleted page",
615
+ "page.duplicate": "duplicated",
616
+ "page.rename": "renamed",
617
+ "page.restore": "restored",
618
+ "page.schedule": "scheduled",
619
+ "page.unschedule": "unscheduled",
620
+ "global.save": "updated global",
621
+ "global.restore": "restored global",
622
+ "media.upload": "uploaded",
623
+ "media.replace": "replaced",
624
+ "media.delete": "deleted file",
625
+ "form.create": "created form",
626
+ "form.update": "updated form",
627
+ "form.delete": "deleted form",
628
+ "redirect.save": "saved redirect",
629
+ "user.create": "added user",
630
+ "user.update": "updated user",
631
+ "user.delete": "removed user"
632
+ };
633
+ function DashboardView({
634
+ api,
635
+ siteName,
636
+ onOpenPage,
637
+ onOpenSubmissions
638
+ }) {
639
+ const [data, setData] = useState4(null);
640
+ const [week, setWeek] = useState4(null);
641
+ const [error, setError] = useState4("");
642
+ useEffect3(() => {
643
+ api.dashboard().then(setData, (e) => setError(e.message));
644
+ const to = /* @__PURE__ */ new Date();
645
+ const from = new Date(to.getTime() - 7 * 864e5);
646
+ api.analytics({ from: from.toISOString(), to: to.toISOString() }).then(
647
+ (summary) => setWeek({
648
+ visitors: summary.kpis.visitors,
649
+ calls: summary.kpis.calls,
650
+ formSubmits: summary.kpis.formSubmits
651
+ }),
652
+ () => void 0
653
+ );
654
+ }, [api]);
655
+ if (error) return /* @__PURE__ */ jsx4("div", { className: "ost-view", children: /* @__PURE__ */ jsx4("div", { className: "ost-error", children: error }) });
656
+ if (!data) return /* @__PURE__ */ jsx4("div", { className: "ost-view", children: /* @__PURE__ */ jsx4("div", { className: "ost-loading", children: "Loading\u2026" }) });
657
+ const { pages, submissions, activity } = data;
658
+ return /* @__PURE__ */ jsxs4("div", { className: "ost-view", children: [
659
+ /* @__PURE__ */ jsx4("header", { className: "ost-view-header", children: /* @__PURE__ */ jsxs4("div", { children: [
660
+ /* @__PURE__ */ jsx4("h2", { children: "Welcome back" }),
661
+ /* @__PURE__ */ jsxs4("p", { className: "ost-muted", children: [
662
+ siteName,
663
+ pages.lastPublishedAt ? ` \xB7 last published ${new Date(pages.lastPublishedAt).toLocaleString()}` : ""
664
+ ] }),
665
+ week && (week.visitors > 0 || week.calls > 0 || week.formSubmits > 0) ? /* @__PURE__ */ jsxs4("p", { className: "ost-dash-week", children: [
666
+ "This week: ",
667
+ /* @__PURE__ */ jsx4("strong", { children: week.visitors }),
668
+ " visitor",
669
+ week.visitors === 1 ? "" : "s",
670
+ " \xB7",
671
+ " ",
672
+ /* @__PURE__ */ jsx4("strong", { children: week.calls }),
673
+ " call",
674
+ week.calls === 1 ? "" : "s",
675
+ " tapped \xB7",
676
+ " ",
677
+ /* @__PURE__ */ jsx4("strong", { children: week.formSubmits }),
678
+ " request",
679
+ week.formSubmits === 1 ? "" : "s"
680
+ ] }) : null
681
+ ] }) }),
682
+ /* @__PURE__ */ jsxs4("div", { className: "ost-dash-columns", children: [
683
+ /* @__PURE__ */ jsxs4("div", { className: "ost-dash-column", children: [
684
+ /* @__PURE__ */ jsxs4("section", { className: "ost-card ost-dash-card", children: [
685
+ /* @__PURE__ */ jsxs4("header", { className: "ost-dash-card-head", children: [
686
+ /* @__PURE__ */ jsx4("h3", { children: "Submissions" }),
687
+ /* @__PURE__ */ jsx4("button", { className: "ost-btn", onClick: onOpenSubmissions, type: "button", children: "Open inbox" })
688
+ ] }),
689
+ /* @__PURE__ */ jsx4("p", { className: "ost-dash-stat", children: submissions.unread > 0 ? /* @__PURE__ */ jsxs4("strong", { className: "ost-dash-unread", children: [
690
+ submissions.unread,
691
+ " unread"
692
+ ] }) : /* @__PURE__ */ jsx4("span", { className: "ost-muted", children: "All caught up" }) }),
693
+ submissions.recent.map((submission) => /* @__PURE__ */ jsxs4(
694
+ "button",
695
+ {
696
+ className: "ost-dash-row",
697
+ onClick: onOpenSubmissions,
698
+ type: "button",
699
+ children: [
700
+ /* @__PURE__ */ jsxs4("span", { children: [
701
+ !submission.read_at ? /* @__PURE__ */ jsx4("span", { className: "ost-unread-dot" }) : null,
702
+ String(submission.data.name || submission.data.email || `#${submission.id}`)
703
+ ] }),
704
+ /* @__PURE__ */ jsx4("span", { className: "ost-muted", children: new Date(submission.created_at).toLocaleDateString() })
705
+ ]
706
+ },
707
+ submission.id
708
+ ))
709
+ ] }),
710
+ /* @__PURE__ */ jsxs4("section", { className: "ost-card ost-dash-card", children: [
711
+ /* @__PURE__ */ jsx4("header", { className: "ost-dash-card-head", children: /* @__PURE__ */ jsx4("h3", { children: "Waiting to publish" }) }),
712
+ pages.pendingDrafts.length === 0 ? /* @__PURE__ */ jsx4("p", { className: "ost-muted", children: "Every page is live and up to date." }) : pages.pendingDrafts.map((page) => /* @__PURE__ */ jsxs4("button", { className: "ost-dash-row", onClick: () => onOpenPage(page.id), type: "button", children: [
713
+ /* @__PURE__ */ jsx4("span", { children: page.title || page.path }),
714
+ /* @__PURE__ */ jsx4("span", { className: "ost-muted", children: page.publish_at ? `scheduled ${new Date(page.publish_at).toLocaleString()}` : page.status === "draft" ? "draft" : "edited since publish" })
715
+ ] }, page.id))
716
+ ] })
717
+ ] }),
718
+ /* @__PURE__ */ jsxs4("div", { className: "ost-dash-column", children: [
719
+ /* @__PURE__ */ jsxs4("section", { className: "ost-card ost-dash-card", children: [
720
+ /* @__PURE__ */ jsx4("header", { className: "ost-dash-card-head", children: /* @__PURE__ */ jsx4("h3", { children: "Recently edited" }) }),
721
+ pages.recent.map((page) => /* @__PURE__ */ jsxs4("button", { className: "ost-dash-row", onClick: () => onOpenPage(page.id), type: "button", children: [
722
+ /* @__PURE__ */ jsx4("span", { children: page.path === "/" ? "Home" : page.title || page.path }),
723
+ /* @__PURE__ */ jsx4("span", { className: "ost-muted", children: new Date(page.updated_at).toLocaleDateString() })
724
+ ] }, page.id))
725
+ ] }),
726
+ activity.length > 0 ? /* @__PURE__ */ jsxs4("section", { className: "ost-card ost-dash-card", children: [
727
+ /* @__PURE__ */ jsx4("header", { className: "ost-dash-card-head", children: /* @__PURE__ */ jsx4("h3", { children: "Recent activity" }) }),
728
+ activity.map((event) => /* @__PURE__ */ jsxs4("div", { className: "ost-dash-row ost-dash-row-static", children: [
729
+ /* @__PURE__ */ jsxs4("span", { children: [
730
+ /* @__PURE__ */ jsx4("strong", { children: event.actor_name || "System" }),
731
+ " ",
732
+ ACTION_LABELS[event.action] || event.action,
733
+ " ",
734
+ /* @__PURE__ */ jsx4("span", { className: "ost-muted", children: event.subject })
735
+ ] }),
736
+ /* @__PURE__ */ jsx4("span", { className: "ost-muted", children: new Date(event.created_at).toLocaleDateString() })
737
+ ] }, event.id))
738
+ ] }) : null
739
+ ] })
740
+ ] })
741
+ ] });
742
+ }
743
+
744
+ // src/studio/views/FormsView.tsx
745
+ import { useEffect as useEffect4, useState as useState5 } from "react";
746
+
747
+ // src/studio/fields.ts
748
+ var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
749
+ var isArrayIndex = (key) => /^\d+$/.test(key);
750
+ function pathGet(target, path) {
751
+ if (!path) return target;
752
+ let current = target;
753
+ for (const key of path.split(".")) {
754
+ if (Array.isArray(current) && isArrayIndex(key)) {
755
+ current = current[Number(key)];
756
+ } else if (isRecord(current)) {
757
+ current = current[key];
758
+ } else {
759
+ return void 0;
760
+ }
761
+ }
762
+ return current;
763
+ }
764
+ function setIn(container, keys, value) {
765
+ const [key, ...rest] = keys;
766
+ if (isArrayIndex(key)) {
767
+ const next2 = Array.isArray(container) ? [...container] : [];
768
+ const index = Number(key);
769
+ next2[index] = rest.length === 0 ? value : setIn(next2[index], rest, value);
770
+ return next2;
771
+ }
772
+ const next = isRecord(container) ? { ...container } : {};
773
+ next[key] = rest.length === 0 ? value : setIn(next[key], rest, value);
774
+ return next;
775
+ }
776
+ function pathSet(target, path, value) {
777
+ return setIn(target, path.split("."), value);
778
+ }
779
+ function moveItem(items, from, to) {
780
+ if (to < 0 || to >= items.length || from === to) return items;
781
+ const next = [...items];
782
+ const [moved] = next.splice(from, 1);
783
+ next.splice(to, 0, moved);
784
+ return next;
785
+ }
786
+
787
+ // src/studio/views/FormsView.tsx
788
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
789
+ var TYPE_LABELS = {
790
+ text: "Short text",
791
+ textarea: "Long text",
792
+ email: "Email",
793
+ phone: "Phone",
794
+ url: "Website",
795
+ select: "Dropdown",
796
+ radio: "Multiple choice (one)",
797
+ checkbox: "Checkbox(es)",
798
+ date: "Date",
799
+ number: "Number",
800
+ hidden: "Hidden"
801
+ };
802
+ var slugify = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
803
+ var nameFromLabel = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
804
+ function FormsView({
805
+ api,
806
+ canWrite,
807
+ canDelete,
808
+ onOpenSubmissions
809
+ }) {
810
+ const [forms, setForms] = useState5(null);
811
+ const [openSlug, setOpenSlug] = useState5(null);
812
+ const [creating, setCreating] = useState5(false);
813
+ const [newTitle, setNewTitle] = useState5("");
814
+ const [error, setError] = useState5("");
815
+ const load = () => api.listForms().then(({ forms: list }) => setForms(list), (e) => setError(e.message));
816
+ useEffect4(() => {
817
+ load();
818
+ }, []);
819
+ const create = async (event) => {
820
+ event.preventDefault();
821
+ setError("");
822
+ try {
823
+ const { form } = await api.createForm({ title: newTitle, slug: slugify(newTitle) });
824
+ setCreating(false);
825
+ setNewTitle("");
826
+ await load();
827
+ setOpenSlug(form.slug);
828
+ } catch (createError) {
829
+ setError(createError instanceof Error ? createError.message : "Could not create form.");
830
+ }
831
+ };
832
+ if (openSlug) {
833
+ return /* @__PURE__ */ jsx5(
834
+ FormEditor,
835
+ {
836
+ api,
837
+ canWrite,
838
+ onBack: () => {
839
+ setOpenSlug(null);
840
+ load();
841
+ },
842
+ slug: openSlug
843
+ }
844
+ );
845
+ }
846
+ return /* @__PURE__ */ jsxs5("div", { className: "ost-view", children: [
847
+ /* @__PURE__ */ jsxs5("header", { className: "ost-view-header", children: [
848
+ /* @__PURE__ */ jsxs5("div", { children: [
849
+ /* @__PURE__ */ jsx5("h2", { children: "Forms" }),
850
+ /* @__PURE__ */ jsx5("p", { className: "ost-muted", children: "Each form's fields, notifications, and success message." })
851
+ ] }),
852
+ canWrite ? /* @__PURE__ */ jsx5("button", { className: "ost-btn ost-btn-primary", onClick: () => setCreating(!creating), type: "button", children: "New form" }) : null
853
+ ] }),
854
+ creating ? /* @__PURE__ */ jsxs5("form", { className: "ost-card ost-create-form", onSubmit: create, children: [
855
+ /* @__PURE__ */ jsxs5("label", { className: "ost-label", children: [
856
+ "Form name",
857
+ /* @__PURE__ */ jsx5("input", { className: "ost-input", onChange: (e) => setNewTitle(e.target.value), value: newTitle })
858
+ ] }),
859
+ /* @__PURE__ */ jsx5("button", { className: "ost-btn ost-btn-primary", disabled: !newTitle.trim(), type: "submit", children: "Create" })
860
+ ] }) : null,
861
+ error ? /* @__PURE__ */ jsx5("div", { className: "ost-error", children: error }) : null,
862
+ !forms ? /* @__PURE__ */ jsx5("div", { className: "ost-loading", children: "Loading\u2026" }) : null,
863
+ forms && forms.length === 0 ? /* @__PURE__ */ jsx5("div", { className: "ost-card", children: "No forms yet." }) : null,
864
+ /* @__PURE__ */ jsx5("div", { className: "ost-list", children: (forms || []).map((form) => /* @__PURE__ */ jsxs5("div", { className: "ost-list-item ost-form-row", children: [
865
+ /* @__PURE__ */ jsxs5("button", { className: "ost-form-open", onClick: () => setOpenSlug(form.slug), type: "button", children: [
866
+ /* @__PURE__ */ jsx5("strong", { children: form.title }),
867
+ /* @__PURE__ */ jsxs5("span", { className: "ost-muted", children: [
868
+ " /",
869
+ form.slug
870
+ ] })
871
+ ] }),
872
+ /* @__PURE__ */ jsxs5("span", { className: "ost-row", children: [
873
+ /* @__PURE__ */ jsxs5("button", { className: "ost-btn", onClick: () => onOpenSubmissions(form.id), type: "button", children: [
874
+ form.submissionCount ?? 0,
875
+ " submission",
876
+ (form.submissionCount ?? 0) === 1 ? "" : "s",
877
+ form.unreadCount ? ` (${form.unreadCount} new)` : ""
878
+ ] }),
879
+ canDelete ? /* @__PURE__ */ jsx5(
880
+ "button",
881
+ {
882
+ className: "ost-btn ost-btn-danger",
883
+ onClick: async () => {
884
+ if (!window.confirm(
885
+ `Delete "${form.title}" and its ${form.submissionCount ?? 0} submissions? This cannot be undone.`
886
+ ))
887
+ return;
888
+ await api.deleteForm(form.slug).catch(() => void 0);
889
+ load();
890
+ },
891
+ type: "button",
892
+ children: "Delete"
893
+ }
894
+ ) : null
895
+ ] })
896
+ ] }, form.id)) })
897
+ ] });
898
+ }
899
+ function FormEditor({
900
+ api,
901
+ slug,
902
+ canWrite,
903
+ onBack
904
+ }) {
905
+ const [form, setForm] = useState5(null);
906
+ const [title, setTitle] = useState5("");
907
+ const [successMessage, setSuccessMessage] = useState5("");
908
+ const [config, setConfig] = useState5({ steps: [] });
909
+ const [notifyEmails, setNotifyEmails] = useState5("");
910
+ const [notifySubject, setNotifySubject] = useState5("");
911
+ const [autoReply, setAutoReply] = useState5(false);
912
+ const [dirty, setDirty] = useState5(false);
913
+ const [message, setMessage] = useState5("");
914
+ const [error, setError] = useState5("");
915
+ const [previewKey, setPreviewKey] = useState5(0);
916
+ useEffect4(() => {
917
+ api.getForm(slug).then(({ form: loaded }) => {
918
+ setForm(loaded);
919
+ setTitle(loaded.title);
920
+ setSuccessMessage(loaded.success_message);
921
+ const loadedConfig = loaded.config || {};
922
+ setConfig({
923
+ steps: loadedConfig.steps && loadedConfig.steps.length > 0 ? loadedConfig.steps : [{ title: "", fields: [] }]
924
+ });
925
+ const notify = loaded.notify || loadedConfig.notify || {};
926
+ setNotifyEmails((notify.emails || []).join(", "));
927
+ setNotifySubject(notify.subject || "");
928
+ setAutoReply(notify.autoReply === true);
929
+ }, (e) => setError(e.message));
930
+ }, [api, slug]);
931
+ const touch = () => {
932
+ setDirty(true);
933
+ setMessage("");
934
+ setPreviewKey((key) => key + 1);
935
+ };
936
+ const updateConfig = (next) => {
937
+ setConfig(next);
938
+ touch();
939
+ };
940
+ const updateStep = (index, patch) => {
941
+ updateConfig({
942
+ ...config,
943
+ steps: (config.steps || []).map(
944
+ (step, stepIndex) => stepIndex === index ? { ...step, ...patch } : step
945
+ )
946
+ });
947
+ };
948
+ const save = async () => {
949
+ setError("");
950
+ const emails = notifyEmails.split(/[,\s]+/).map((entry) => entry.trim()).filter(Boolean);
951
+ try {
952
+ const { form: saved } = await api.updateForm(slug, {
953
+ title,
954
+ config: { steps: config.steps },
955
+ notify: {
956
+ emails,
957
+ ...notifySubject.trim() ? { subject: notifySubject.trim() } : {},
958
+ autoReply
959
+ },
960
+ successMessage
961
+ });
962
+ setForm(saved);
963
+ setDirty(false);
964
+ setMessage("Saved \u2014 live on the site.");
965
+ } catch (saveError) {
966
+ setError(saveError instanceof Error ? saveError.message : "Save failed.");
967
+ }
968
+ };
969
+ if (!form && !error) return /* @__PURE__ */ jsx5("div", { className: "ost-loading", children: "Loading form\u2026" });
970
+ if (error && !form) return /* @__PURE__ */ jsx5("div", { className: "ost-error", children: error });
971
+ const steps = config.steps || [];
972
+ return /* @__PURE__ */ jsxs5("div", { className: "ost-view ost-view-wide", children: [
973
+ /* @__PURE__ */ jsxs5("header", { className: "ost-view-header", children: [
974
+ /* @__PURE__ */ jsxs5("div", { className: "ost-row", children: [
975
+ /* @__PURE__ */ jsx5("button", { className: "ost-btn", onClick: onBack, type: "button", children: "\u2190 Forms" }),
976
+ /* @__PURE__ */ jsx5(
977
+ "input",
978
+ {
979
+ className: "ost-input ost-title-input",
980
+ disabled: !canWrite,
981
+ onChange: (event) => {
982
+ setTitle(event.target.value);
983
+ touch();
984
+ },
985
+ value: title
986
+ }
987
+ )
988
+ ] }),
989
+ /* @__PURE__ */ jsxs5("div", { className: "ost-row", children: [
990
+ message ? /* @__PURE__ */ jsx5("span", { className: "ost-muted", children: message }) : null,
991
+ error ? /* @__PURE__ */ jsx5("span", { className: "ost-error", children: error }) : null,
992
+ canWrite ? /* @__PURE__ */ jsx5("button", { className: "ost-btn ost-btn-primary", disabled: !dirty, onClick: save, type: "button", children: "Save" }) : null
993
+ ] })
994
+ ] }),
995
+ /* @__PURE__ */ jsxs5("div", { className: "ost-form-editor", children: [
996
+ /* @__PURE__ */ jsxs5("div", { className: "ost-form-config", children: [
997
+ steps.map((step, stepIndex) => /* @__PURE__ */ jsxs5("fieldset", { className: "ost-fieldset", children: [
998
+ /* @__PURE__ */ jsx5("legend", { children: steps.length > 1 ? `Step ${stepIndex + 1}` : "Fields" }),
999
+ steps.length > 1 ? /* @__PURE__ */ jsxs5("div", { className: "ost-row", children: [
1000
+ /* @__PURE__ */ jsxs5("label", { className: "ost-label ost-grow", children: [
1001
+ "Step title",
1002
+ /* @__PURE__ */ jsx5(
1003
+ "input",
1004
+ {
1005
+ className: "ost-input",
1006
+ disabled: !canWrite,
1007
+ onChange: (event) => updateStep(stepIndex, { title: event.target.value }),
1008
+ value: step.title || ""
1009
+ }
1010
+ )
1011
+ ] }),
1012
+ canWrite ? /* @__PURE__ */ jsx5(
1013
+ "button",
1014
+ {
1015
+ className: "ost-btn ost-btn-danger",
1016
+ onClick: () => updateConfig({
1017
+ ...config,
1018
+ steps: steps.filter((_, index) => index !== stepIndex)
1019
+ }),
1020
+ type: "button",
1021
+ children: "Remove step"
1022
+ }
1023
+ ) : null
1024
+ ] }) : null,
1025
+ (step.fields || []).map((field, fieldIndex) => /* @__PURE__ */ jsx5(
1026
+ FieldEditor,
1027
+ {
1028
+ canWrite,
1029
+ field,
1030
+ onChange: (next) => updateStep(stepIndex, {
1031
+ fields: (step.fields || []).map(
1032
+ (current, index) => index === fieldIndex ? next : current
1033
+ )
1034
+ }),
1035
+ onMove: (direction) => updateStep(stepIndex, {
1036
+ fields: moveItem(step.fields || [], fieldIndex, fieldIndex + direction)
1037
+ }),
1038
+ onRemove: () => updateStep(stepIndex, {
1039
+ fields: (step.fields || []).filter((_, index) => index !== fieldIndex)
1040
+ })
1041
+ },
1042
+ fieldIndex
1043
+ )),
1044
+ canWrite ? /* @__PURE__ */ jsx5(
1045
+ "button",
1046
+ {
1047
+ className: "ost-btn",
1048
+ onClick: () => updateStep(stepIndex, {
1049
+ fields: [
1050
+ ...step.fields || [],
1051
+ { label: "New field", name: `field_${(step.fields || []).length + 1}`, type: "text", required: false }
1052
+ ]
1053
+ }),
1054
+ type: "button",
1055
+ children: "+ Add field"
1056
+ }
1057
+ ) : null
1058
+ ] }, stepIndex)),
1059
+ canWrite ? /* @__PURE__ */ jsx5(
1060
+ "button",
1061
+ {
1062
+ className: "ost-btn",
1063
+ onClick: () => updateConfig({ ...config, steps: [...steps, { title: "", fields: [] }] }),
1064
+ type: "button",
1065
+ children: "+ Add step"
1066
+ }
1067
+ ) : null,
1068
+ /* @__PURE__ */ jsxs5("fieldset", { className: "ost-fieldset", children: [
1069
+ /* @__PURE__ */ jsx5("legend", { children: "After submit" }),
1070
+ /* @__PURE__ */ jsxs5("label", { className: "ost-label", children: [
1071
+ "Success message",
1072
+ /* @__PURE__ */ jsx5(
1073
+ "textarea",
1074
+ {
1075
+ className: "ost-input ost-textarea",
1076
+ disabled: !canWrite,
1077
+ onChange: (event) => {
1078
+ setSuccessMessage(event.target.value);
1079
+ touch();
1080
+ },
1081
+ rows: 2,
1082
+ value: successMessage
1083
+ }
1084
+ )
1085
+ ] })
1086
+ ] }),
1087
+ /* @__PURE__ */ jsxs5("fieldset", { className: "ost-fieldset", children: [
1088
+ /* @__PURE__ */ jsx5("legend", { children: "Email notifications" }),
1089
+ /* @__PURE__ */ jsxs5("label", { className: "ost-label", children: [
1090
+ "Send new submissions to",
1091
+ /* @__PURE__ */ jsx5(
1092
+ "input",
1093
+ {
1094
+ className: "ost-input",
1095
+ disabled: !canWrite,
1096
+ onChange: (event) => {
1097
+ setNotifyEmails(event.target.value);
1098
+ touch();
1099
+ },
1100
+ placeholder: "you@business.com, office@business.com",
1101
+ value: notifyEmails
1102
+ }
1103
+ )
1104
+ ] }),
1105
+ /* @__PURE__ */ jsxs5("label", { className: "ost-label", children: [
1106
+ "Subject (optional)",
1107
+ /* @__PURE__ */ jsx5(
1108
+ "input",
1109
+ {
1110
+ className: "ost-input",
1111
+ disabled: !canWrite,
1112
+ onChange: (event) => {
1113
+ setNotifySubject(event.target.value);
1114
+ touch();
1115
+ },
1116
+ placeholder: "New {form} submission",
1117
+ value: notifySubject
1118
+ }
1119
+ )
1120
+ ] }),
1121
+ /* @__PURE__ */ jsxs5("label", { className: "ost-label ost-checkbox", children: [
1122
+ /* @__PURE__ */ jsx5(
1123
+ "input",
1124
+ {
1125
+ checked: autoReply,
1126
+ disabled: !canWrite,
1127
+ onChange: (event) => {
1128
+ setAutoReply(event.target.checked);
1129
+ touch();
1130
+ },
1131
+ type: "checkbox"
1132
+ }
1133
+ ),
1134
+ "Auto-reply to the submitter with the success message"
1135
+ ] }),
1136
+ /* @__PURE__ */ jsx5("p", { className: "ost-muted", children: "Requires the site's email sending to be configured (RESEND_API_KEY)." })
1137
+ ] })
1138
+ ] }),
1139
+ /* @__PURE__ */ jsxs5("div", { className: "ost-form-preview", children: [
1140
+ /* @__PURE__ */ jsx5("h4", { children: "Preview" }),
1141
+ /* @__PURE__ */ jsx5("div", { className: "ost-form-preview-frame", children: /* @__PURE__ */ jsx5(
1142
+ FormRenderer,
1143
+ {
1144
+ classNames: {
1145
+ form: "ost-preview-form",
1146
+ field: "ost-label",
1147
+ label: "ost-preview-label",
1148
+ input: "ost-input",
1149
+ textarea: "ost-input ost-textarea",
1150
+ select: "ost-input",
1151
+ checkbox: "ost-label ost-checkbox",
1152
+ button: "ost-btn ost-btn-primary",
1153
+ errorText: "ost-error",
1154
+ formError: "ost-error",
1155
+ success: "ost-card",
1156
+ stepTitle: "ost-muted"
1157
+ },
1158
+ config,
1159
+ preview: true,
1160
+ slug,
1161
+ submitLabel: "Send",
1162
+ successMessage
1163
+ },
1164
+ previewKey
1165
+ ) }),
1166
+ /* @__PURE__ */ jsx5("p", { className: "ost-muted", children: "This is the same renderer the site uses \u2014 changes apply on save." })
1167
+ ] })
1168
+ ] })
1169
+ ] });
1170
+ }
1171
+ function FieldEditor({
1172
+ field,
1173
+ canWrite,
1174
+ onChange,
1175
+ onMove,
1176
+ onRemove
1177
+ }) {
1178
+ const type = field.type || "text";
1179
+ const hasOptions = type === "select" || type === "radio" || type === "checkbox";
1180
+ const optionsText = (field.options || []).map((option) => typeof option === "string" ? option : option.value || "").filter(Boolean).join("\n");
1181
+ return /* @__PURE__ */ jsxs5("details", { className: "ost-item", children: [
1182
+ /* @__PURE__ */ jsxs5("summary", { children: [
1183
+ /* @__PURE__ */ jsxs5("span", { children: [
1184
+ field.label || field.name || "Untitled field",
1185
+ /* @__PURE__ */ jsxs5("span", { className: "ost-muted", children: [
1186
+ " \xB7 ",
1187
+ TYPE_LABELS[type] || type,
1188
+ field.required ? " \xB7 required" : ""
1189
+ ] })
1190
+ ] }),
1191
+ canWrite ? /* @__PURE__ */ jsxs5("span", { className: "ost-item-actions", children: [
1192
+ /* @__PURE__ */ jsx5(
1193
+ "button",
1194
+ {
1195
+ onClick: (event) => {
1196
+ event.preventDefault();
1197
+ onMove(-1);
1198
+ },
1199
+ type: "button",
1200
+ children: "\u2191"
1201
+ }
1202
+ ),
1203
+ /* @__PURE__ */ jsx5(
1204
+ "button",
1205
+ {
1206
+ onClick: (event) => {
1207
+ event.preventDefault();
1208
+ onMove(1);
1209
+ },
1210
+ type: "button",
1211
+ children: "\u2193"
1212
+ }
1213
+ ),
1214
+ /* @__PURE__ */ jsx5(
1215
+ "button",
1216
+ {
1217
+ onClick: (event) => {
1218
+ event.preventDefault();
1219
+ onRemove();
1220
+ },
1221
+ type: "button",
1222
+ children: "\u2715"
1223
+ }
1224
+ )
1225
+ ] }) : null
1226
+ ] }),
1227
+ /* @__PURE__ */ jsxs5("div", { className: "ost-item-body", children: [
1228
+ /* @__PURE__ */ jsxs5("label", { className: "ost-label", children: [
1229
+ "Label",
1230
+ /* @__PURE__ */ jsx5(
1231
+ "input",
1232
+ {
1233
+ className: "ost-input",
1234
+ disabled: !canWrite,
1235
+ onChange: (event) => {
1236
+ const label = event.target.value;
1237
+ const derived = nameFromLabel(label);
1238
+ const wasDerived = field.name === nameFromLabel(field.label || "");
1239
+ onChange({ ...field, label, ...wasDerived && derived ? { name: derived } : {} });
1240
+ },
1241
+ value: field.label || ""
1242
+ }
1243
+ )
1244
+ ] }),
1245
+ /* @__PURE__ */ jsxs5("label", { className: "ost-label", children: [
1246
+ "Field name (stored key)",
1247
+ /* @__PURE__ */ jsx5(
1248
+ "input",
1249
+ {
1250
+ className: "ost-input",
1251
+ disabled: !canWrite,
1252
+ onChange: (event) => onChange({ ...field, name: nameFromLabel(event.target.value) }),
1253
+ value: field.name || ""
1254
+ }
1255
+ )
1256
+ ] }),
1257
+ /* @__PURE__ */ jsxs5("label", { className: "ost-label", children: [
1258
+ "Type",
1259
+ /* @__PURE__ */ jsx5(
1260
+ "select",
1261
+ {
1262
+ className: "ost-input",
1263
+ disabled: !canWrite,
1264
+ onChange: (event) => onChange({ ...field, type: event.target.value }),
1265
+ value: type,
1266
+ children: FORM_FIELD_TYPES.map((entry) => /* @__PURE__ */ jsx5("option", { value: entry, children: TYPE_LABELS[entry] }, entry))
1267
+ }
1268
+ )
1269
+ ] }),
1270
+ hasOptions ? /* @__PURE__ */ jsxs5("label", { className: "ost-label", children: [
1271
+ "Options (one per line)",
1272
+ /* @__PURE__ */ jsx5(
1273
+ "textarea",
1274
+ {
1275
+ className: "ost-input ost-textarea",
1276
+ disabled: !canWrite,
1277
+ onChange: (event) => onChange({
1278
+ ...field,
1279
+ options: event.target.value.split("\n").map((entry) => entry.trim()).filter(Boolean)
1280
+ }),
1281
+ rows: 4,
1282
+ value: optionsText
1283
+ }
1284
+ )
1285
+ ] }) : null,
1286
+ /* @__PURE__ */ jsxs5("label", { className: "ost-label", children: [
1287
+ "Placeholder (optional)",
1288
+ /* @__PURE__ */ jsx5(
1289
+ "input",
1290
+ {
1291
+ className: "ost-input",
1292
+ disabled: !canWrite,
1293
+ onChange: (event) => onChange({ ...field, placeholder: event.target.value }),
1294
+ value: field.placeholder || ""
1295
+ }
1296
+ )
1297
+ ] }),
1298
+ /* @__PURE__ */ jsxs5("label", { className: "ost-label ost-checkbox", children: [
1299
+ /* @__PURE__ */ jsx5(
1300
+ "input",
1301
+ {
1302
+ checked: field.required === true,
1303
+ disabled: !canWrite,
1304
+ onChange: (event) => onChange({ ...field, required: event.target.checked }),
1305
+ type: "checkbox"
1306
+ }
1307
+ ),
1308
+ "Required"
1309
+ ] })
1310
+ ] })
1311
+ ] });
1312
+ }
1313
+
1314
+ // src/studio/views/PageEditor.tsx
1315
+ import { useCallback as useCallback2, useEffect as useEffect6, useMemo as useMemo3, useRef, useState as useState7 } from "react";
1316
+
1317
+ // src/studio/inline.ts
1318
+ function collectInlineTargets(definition, data) {
1319
+ const targets = [];
1320
+ const addStringTarget = (path, value, multiline, fallback) => {
1321
+ const resolved = value ?? fallback;
1322
+ if (typeof resolved === "string" && resolved.trim().length > 0) {
1323
+ targets.push({ path, value: resolved, multiline });
1324
+ }
1325
+ };
1326
+ const addArrayTargets = (path, value, multiline) => {
1327
+ if (!Array.isArray(value)) return;
1328
+ for (const [index, item] of value.entries()) {
1329
+ addStringTarget(`${path}.${index}`, item, multiline);
1330
+ }
1331
+ };
1332
+ for (const field of definition.editor.fields) {
1333
+ if (field.input === "text" || field.input === "textarea") {
1334
+ addStringTarget(
1335
+ field.key,
1336
+ pathGet(data, field.key),
1337
+ field.input === "textarea",
1338
+ pathGet(definition.defaultData, field.key)
1339
+ );
1340
+ continue;
1341
+ }
1342
+ if (field.input === "link") {
1343
+ addStringTarget(`${field.key}.label`, pathGet(data, `${field.key}.label`), false);
1344
+ continue;
1345
+ }
1346
+ if (field.input === "media") {
1347
+ addStringTarget(`${field.key}.caption`, pathGet(data, `${field.key}.caption`), true);
1348
+ continue;
1349
+ }
1350
+ if (field.input === "paragraphs" || field.input === "stringList") {
1351
+ addArrayTargets(field.key, pathGet(data, field.key), field.input === "paragraphs");
1352
+ continue;
1353
+ }
1354
+ if (field.input === "itemList" && field.itemFields) {
1355
+ const items = pathGet(data, field.key);
1356
+ if (!Array.isArray(items)) continue;
1357
+ for (const [index, item] of items.entries()) {
1358
+ for (const itemField of field.itemFields) {
1359
+ const itemFieldType = itemField.input || "text";
1360
+ const itemPath = `${field.key}.${index}.${itemField.key}`;
1361
+ if (itemFieldType === "text" || itemFieldType === "textarea") {
1362
+ addStringTarget(
1363
+ itemPath,
1364
+ pathGet(item, itemField.key),
1365
+ itemFieldType === "textarea",
1366
+ pathGet(field.itemTemplate || {}, itemField.key)
1367
+ );
1368
+ continue;
1369
+ }
1370
+ if (itemFieldType === "link") {
1371
+ addStringTarget(`${itemPath}.label`, pathGet(item, `${itemField.key}.label`), false);
1372
+ continue;
1373
+ }
1374
+ if (itemFieldType === "media") {
1375
+ addStringTarget(`${itemPath}.caption`, pathGet(item, `${itemField.key}.caption`), true);
1376
+ continue;
1377
+ }
1378
+ if (itemFieldType === "paragraphs" || itemFieldType === "stringList") {
1379
+ addArrayTargets(itemPath, pathGet(item, itemField.key), itemFieldType === "paragraphs");
1380
+ }
1381
+ }
1382
+ }
1383
+ }
1384
+ }
1385
+ return targets;
1386
+ }
1387
+ var normalizeText = (value) => value.replace(/ /g, " ").replace(/\s+/g, " ").trim();
1388
+ var stripDisplayPrefix = (value) => value.replace(/^[-–—:•·]\s*/, "").trim();
1389
+ var isEditableTextLeaf = (element) => {
1390
+ if (element.children.length > 0) return false;
1391
+ if (element.closest(".ost-blockchrome, .ost-insert-zone, .ost-add-section")) return false;
1392
+ if (element.closest('[aria-hidden="true"]')) return false;
1393
+ const ignoredTags = /* @__PURE__ */ new Set([
1394
+ "AREA",
1395
+ "AUDIO",
1396
+ "BR",
1397
+ "BUTTON",
1398
+ "CANVAS",
1399
+ "IFRAME",
1400
+ "IMG",
1401
+ "INPUT",
1402
+ "METER",
1403
+ "NOSCRIPT",
1404
+ "OPTION",
1405
+ "PICTURE",
1406
+ "PROGRESS",
1407
+ "SCRIPT",
1408
+ "SELECT",
1409
+ "STYLE",
1410
+ "SVG",
1411
+ "TEXTAREA",
1412
+ "VIDEO"
1413
+ ]);
1414
+ if (ignoredTags.has(element.tagName)) return false;
1415
+ return normalizeText(element.textContent || "").length > 0;
1416
+ };
1417
+ var elementMatchesTarget = (element, target) => {
1418
+ const elementText = normalizeText(element.textContent || "");
1419
+ const targetText = normalizeText(target.value);
1420
+ return elementText === targetText || stripDisplayPrefix(elementText) === targetText;
1421
+ };
1422
+ function bindInlineEditing(container, targets, commit, select) {
1423
+ const cleanups = [];
1424
+ const bound = [];
1425
+ const usedElements = /* @__PURE__ */ new Set();
1426
+ const elements = Array.from(container.querySelectorAll("*")).filter(isEditableTextLeaf);
1427
+ const targetGroups = /* @__PURE__ */ new Map();
1428
+ for (const target of targets) {
1429
+ const key = normalizeText(target.value);
1430
+ targetGroups.set(key, [...targetGroups.get(key) || [], target]);
1431
+ }
1432
+ const bind = (target, element) => {
1433
+ usedElements.add(element);
1434
+ bound.push(target.path);
1435
+ element.setAttribute("data-orion-inline", target.path);
1436
+ element.setAttribute("spellcheck", "false");
1437
+ try {
1438
+ element.contentEditable = "plaintext-only";
1439
+ } catch {
1440
+ element.contentEditable = "true";
1441
+ }
1442
+ const onKeyDown = (event) => {
1443
+ if (event.key === "Enter" && !target.multiline) {
1444
+ event.preventDefault();
1445
+ element.blur();
1446
+ }
1447
+ if (event.key === "Escape") {
1448
+ element.textContent = target.value;
1449
+ element.blur();
1450
+ }
1451
+ event.stopPropagation();
1452
+ };
1453
+ const onBlur = () => {
1454
+ const next = (element.textContent || "").replace(/ /g, " ");
1455
+ if (next !== target.value) commit(target.path, next);
1456
+ };
1457
+ const onClick = (event) => {
1458
+ select?.();
1459
+ if (element.closest("a")) event.preventDefault();
1460
+ };
1461
+ const onFocus = () => {
1462
+ select?.();
1463
+ };
1464
+ element.addEventListener("keydown", onKeyDown);
1465
+ element.addEventListener("blur", onBlur);
1466
+ element.addEventListener("click", onClick);
1467
+ element.addEventListener("focus", onFocus);
1468
+ cleanups.push(() => {
1469
+ element.removeEventListener("keydown", onKeyDown);
1470
+ element.removeEventListener("blur", onBlur);
1471
+ element.removeEventListener("click", onClick);
1472
+ element.removeEventListener("focus", onFocus);
1473
+ element.removeAttribute("contenteditable");
1474
+ element.removeAttribute("data-orion-inline");
1475
+ });
1476
+ };
1477
+ for (const groupTargets of targetGroups.values()) {
1478
+ const matches = elements.filter(
1479
+ (element) => !usedElements.has(element) && elementMatchesTarget(element, groupTargets[0])
1480
+ );
1481
+ if (matches.length !== groupTargets.length) continue;
1482
+ groupTargets.forEach((target, index) => bind(target, matches[index]));
1483
+ }
1484
+ return {
1485
+ bound,
1486
+ cleanup: () => {
1487
+ for (const dispose of cleanups) dispose();
1488
+ }
1489
+ };
1490
+ }
1491
+ function applyInlineCommit(data, path, value) {
1492
+ return pathSet(data, path, value);
1493
+ }
1494
+
1495
+ // src/studio/Inspector.tsx
1496
+ import { useEffect as useEffect5, useId, useState as useState6 } from "react";
1497
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1498
+ var isImage = (item) => item.mime_type.startsWith("image/");
1499
+ function MediaPickerField({ value, onChange, media, mediaUrl, onUpload }) {
1500
+ const [open, setOpen] = useState6(false);
1501
+ const [uploading, setUploading] = useState6(false);
1502
+ const images = media.filter(isImage);
1503
+ const selected = media.find((item) => item.id === value.mediaId);
1504
+ const currentSrc = selected ? mediaUrl(selected.storage_path, { width: 320 }) : value.src;
1505
+ return /* @__PURE__ */ jsxs6("div", { className: "ost-media-field", children: [
1506
+ currentSrc ? (
1507
+ // eslint-disable-next-line @next/next/no-img-element
1508
+ /* @__PURE__ */ jsx6("img", { alt: value.alt, className: "ost-media-thumb", src: currentSrc })
1509
+ ) : /* @__PURE__ */ jsx6("div", { className: "ost-media-empty", children: "No image" }),
1510
+ /* @__PURE__ */ jsxs6("div", { className: "ost-row", children: [
1511
+ /* @__PURE__ */ jsx6("button", { className: "ost-btn", onClick: () => setOpen(!open), type: "button", children: open ? "Close library" : "Choose image" }),
1512
+ value.mediaId || value.src ? /* @__PURE__ */ jsx6(
1513
+ "button",
1514
+ {
1515
+ className: "ost-btn",
1516
+ onClick: () => onChange({ ...value, mediaId: null, src: "" }),
1517
+ type: "button",
1518
+ children: "Remove"
1519
+ }
1520
+ ) : null
1521
+ ] }),
1522
+ open ? /* @__PURE__ */ jsxs6("div", { className: "ost-media-grid", children: [
1523
+ /* @__PURE__ */ jsxs6("label", { className: "ost-media-upload", children: [
1524
+ uploading ? "Uploading\u2026" : "+ Upload",
1525
+ /* @__PURE__ */ jsx6(
1526
+ "input",
1527
+ {
1528
+ accept: "image/*",
1529
+ hidden: true,
1530
+ onChange: async (event) => {
1531
+ const file = event.currentTarget.files?.[0];
1532
+ event.currentTarget.value = "";
1533
+ if (!file) return;
1534
+ setUploading(true);
1535
+ const uploaded = await onUpload(file);
1536
+ setUploading(false);
1537
+ if (uploaded) {
1538
+ onChange({
1539
+ mediaId: uploaded.id,
1540
+ // Resolve the URL now so renderers never need a media lookup.
1541
+ src: mediaUrl(uploaded.storage_path),
1542
+ alt: uploaded.alt || value.alt
1543
+ });
1544
+ setOpen(false);
1545
+ }
1546
+ },
1547
+ type: "file"
1548
+ }
1549
+ )
1550
+ ] }),
1551
+ images.map((item) => /* @__PURE__ */ jsx6(
1552
+ "button",
1553
+ {
1554
+ className: `ost-media-cell${item.id === value.mediaId ? " is-selected" : ""}`,
1555
+ onClick: () => {
1556
+ onChange({ mediaId: item.id, src: mediaUrl(item.storage_path), alt: item.alt || value.alt });
1557
+ setOpen(false);
1558
+ },
1559
+ title: item.filename,
1560
+ type: "button",
1561
+ children: /* @__PURE__ */ jsx6("img", { alt: item.alt, src: mediaUrl(item.storage_path, { width: 160 }) })
1562
+ },
1563
+ item.id
1564
+ ))
1565
+ ] }) : null,
1566
+ /* @__PURE__ */ jsxs6("label", { className: "ost-label", children: [
1567
+ "Alt text",
1568
+ /* @__PURE__ */ jsx6(
1569
+ "input",
1570
+ {
1571
+ className: "ost-input",
1572
+ onChange: (event) => onChange({ ...value, alt: event.target.value }),
1573
+ type: "text",
1574
+ value: value.alt
1575
+ }
1576
+ )
1577
+ ] })
1578
+ ] });
1579
+ }
1580
+ function FilePickerField({ value, onChange, media, mediaUrl, onUpload }) {
1581
+ const [open, setOpen] = useState6(false);
1582
+ const [uploading, setUploading] = useState6(false);
1583
+ const files = media.filter((item) => !isImage(item));
1584
+ return /* @__PURE__ */ jsxs6("div", { className: "ost-media-field", children: [
1585
+ value.filename ? /* @__PURE__ */ jsxs6("div", { className: "ost-file-current", children: [
1586
+ "\u{1F4C4} ",
1587
+ value.filename
1588
+ ] }) : /* @__PURE__ */ jsx6("div", { className: "ost-media-empty", children: "No file" }),
1589
+ /* @__PURE__ */ jsxs6("div", { className: "ost-row", children: [
1590
+ /* @__PURE__ */ jsx6("button", { className: "ost-btn", onClick: () => setOpen(!open), type: "button", children: open ? "Close" : "Choose file" }),
1591
+ value.mediaId || value.src ? /* @__PURE__ */ jsx6(
1592
+ "button",
1593
+ {
1594
+ className: "ost-btn",
1595
+ onClick: () => onChange({ mediaId: null, src: "", filename: "" }),
1596
+ type: "button",
1597
+ children: "Remove"
1598
+ }
1599
+ ) : null
1600
+ ] }),
1601
+ open ? /* @__PURE__ */ jsxs6("div", { className: "ost-list", children: [
1602
+ /* @__PURE__ */ jsxs6("label", { className: "ost-btn", children: [
1603
+ uploading ? "Uploading\u2026" : "+ Upload PDF",
1604
+ /* @__PURE__ */ jsx6(
1605
+ "input",
1606
+ {
1607
+ accept: "application/pdf",
1608
+ hidden: true,
1609
+ onChange: async (event) => {
1610
+ const file = event.currentTarget.files?.[0];
1611
+ event.currentTarget.value = "";
1612
+ if (!file) return;
1613
+ setUploading(true);
1614
+ const uploaded = await onUpload(file);
1615
+ setUploading(false);
1616
+ if (uploaded) {
1617
+ onChange({
1618
+ mediaId: uploaded.id,
1619
+ src: mediaUrl(uploaded.storage_path),
1620
+ filename: uploaded.filename
1621
+ });
1622
+ setOpen(false);
1623
+ }
1624
+ },
1625
+ type: "file"
1626
+ }
1627
+ )
1628
+ ] }),
1629
+ files.map((item) => /* @__PURE__ */ jsxs6(
1630
+ "button",
1631
+ {
1632
+ className: `ost-list-item${item.id === value.mediaId ? " is-active" : ""}`,
1633
+ onClick: () => {
1634
+ onChange({ mediaId: item.id, src: mediaUrl(item.storage_path), filename: item.filename });
1635
+ setOpen(false);
1636
+ },
1637
+ type: "button",
1638
+ children: [
1639
+ "\u{1F4C4} ",
1640
+ item.filename
1641
+ ]
1642
+ },
1643
+ item.id
1644
+ )),
1645
+ files.length === 0 ? /* @__PURE__ */ jsx6("span", { className: "ost-muted", children: "No files in the library yet." }) : null
1646
+ ] }) : null
1647
+ ] });
1648
+ }
1649
+ function ParagraphsField({
1650
+ label,
1651
+ value,
1652
+ onChange
1653
+ }) {
1654
+ const [text, setText] = useState6(value.join("\n\n"));
1655
+ const parse = (raw) => raw.split("\n\n").map((entry) => entry.trim()).filter(Boolean);
1656
+ useEffect5(() => {
1657
+ setText((current) => JSON.stringify(parse(current)) === JSON.stringify(value) ? current : value.join("\n\n"));
1658
+ }, [JSON.stringify(value)]);
1659
+ return /* @__PURE__ */ jsxs6("label", { className: "ost-label", children: [
1660
+ label,
1661
+ /* @__PURE__ */ jsx6(
1662
+ "textarea",
1663
+ {
1664
+ className: "ost-input ost-textarea",
1665
+ onChange: (event) => {
1666
+ setText(event.target.value);
1667
+ onChange(parse(event.target.value));
1668
+ },
1669
+ placeholder: "Separate entries with a blank line",
1670
+ rows: Math.min(12, Math.max(4, value.length * 2)),
1671
+ value: text
1672
+ }
1673
+ )
1674
+ ] });
1675
+ }
1676
+ function Inspector({
1677
+ fields,
1678
+ data,
1679
+ onChange,
1680
+ canChangeStructure,
1681
+ media,
1682
+ mediaUrl,
1683
+ onUploadMedia,
1684
+ pages
1685
+ }) {
1686
+ const datalistId = useId();
1687
+ const set = (path, value) => onChange(pathSet(data, path, value));
1688
+ const renderField = (field) => {
1689
+ const raw = pathGet(data, field.key);
1690
+ switch (field.input) {
1691
+ case "textarea":
1692
+ return /* @__PURE__ */ jsxs6("label", { className: "ost-label", children: [
1693
+ field.label,
1694
+ /* @__PURE__ */ jsx6(
1695
+ "textarea",
1696
+ {
1697
+ className: "ost-input ost-textarea",
1698
+ onChange: (event) => set(field.key, event.target.value),
1699
+ value: typeof raw === "string" ? raw : ""
1700
+ }
1701
+ )
1702
+ ] }, field.key);
1703
+ case "number":
1704
+ return /* @__PURE__ */ jsxs6("label", { className: "ost-label", children: [
1705
+ field.label,
1706
+ /* @__PURE__ */ jsx6(
1707
+ "input",
1708
+ {
1709
+ className: "ost-input",
1710
+ onChange: (event) => set(field.key, event.target.value === "" ? 0 : Number(event.target.value)),
1711
+ type: "number",
1712
+ value: typeof raw === "number" ? raw : ""
1713
+ }
1714
+ )
1715
+ ] }, field.key);
1716
+ case "checkbox":
1717
+ return /* @__PURE__ */ jsxs6("label", { className: "ost-label ost-checkbox", children: [
1718
+ /* @__PURE__ */ jsx6(
1719
+ "input",
1720
+ {
1721
+ checked: Boolean(raw),
1722
+ onChange: (event) => set(field.key, event.target.checked),
1723
+ type: "checkbox"
1724
+ }
1725
+ ),
1726
+ field.label
1727
+ ] }, field.key);
1728
+ case "select":
1729
+ return /* @__PURE__ */ jsxs6("label", { className: "ost-label", children: [
1730
+ field.label,
1731
+ /* @__PURE__ */ jsx6(
1732
+ "select",
1733
+ {
1734
+ className: "ost-input",
1735
+ onChange: (event) => set(field.key, event.target.value),
1736
+ value: typeof raw === "string" ? raw : field.options?.[0]?.value || "",
1737
+ children: (field.options || []).map((option) => /* @__PURE__ */ jsx6("option", { value: option.value, children: option.label }, option.value))
1738
+ }
1739
+ )
1740
+ ] }, field.key);
1741
+ case "link": {
1742
+ const value = raw || {};
1743
+ return /* @__PURE__ */ jsxs6("fieldset", { className: "ost-fieldset", children: [
1744
+ /* @__PURE__ */ jsx6("legend", { children: field.label }),
1745
+ /* @__PURE__ */ jsxs6("label", { className: "ost-label", children: [
1746
+ "Label",
1747
+ /* @__PURE__ */ jsx6(
1748
+ "input",
1749
+ {
1750
+ className: "ost-input",
1751
+ onChange: (event) => set(`${field.key}.label`, event.target.value),
1752
+ type: "text",
1753
+ value: value.label || ""
1754
+ }
1755
+ )
1756
+ ] }),
1757
+ /* @__PURE__ */ jsxs6("label", { className: "ost-label", children: [
1758
+ "Link",
1759
+ /* @__PURE__ */ jsx6(
1760
+ "input",
1761
+ {
1762
+ className: "ost-input",
1763
+ list: pages && pages.length > 0 ? datalistId : void 0,
1764
+ onChange: (event) => set(`${field.key}.href`, event.target.value),
1765
+ placeholder: "/about or https://\u2026",
1766
+ type: "text",
1767
+ value: value.href || ""
1768
+ }
1769
+ )
1770
+ ] }),
1771
+ /* @__PURE__ */ jsxs6("label", { className: "ost-label", children: [
1772
+ "Style",
1773
+ /* @__PURE__ */ jsxs6(
1774
+ "select",
1775
+ {
1776
+ className: "ost-input",
1777
+ onChange: (event) => set(`${field.key}.variant`, event.target.value),
1778
+ value: value.variant || "solid",
1779
+ children: [
1780
+ /* @__PURE__ */ jsx6("option", { value: "solid", children: "Solid" }),
1781
+ /* @__PURE__ */ jsx6("option", { value: "outline", children: "Outline" }),
1782
+ /* @__PURE__ */ jsx6("option", { value: "line", children: "Line" })
1783
+ ]
1784
+ }
1785
+ )
1786
+ ] })
1787
+ ] }, field.key);
1788
+ }
1789
+ case "media": {
1790
+ const value = raw || {};
1791
+ return /* @__PURE__ */ jsxs6("fieldset", { className: "ost-fieldset", children: [
1792
+ /* @__PURE__ */ jsx6("legend", { children: field.label }),
1793
+ /* @__PURE__ */ jsx6(
1794
+ MediaPickerField,
1795
+ {
1796
+ media,
1797
+ mediaUrl,
1798
+ onChange: (next) => set(field.key, { ...value, ...next }),
1799
+ onUpload: onUploadMedia,
1800
+ value: { mediaId: value.mediaId ?? null, src: value.src || "", alt: value.alt || "" }
1801
+ }
1802
+ )
1803
+ ] }, field.key);
1804
+ }
1805
+ case "file": {
1806
+ const value = raw || {};
1807
+ return /* @__PURE__ */ jsxs6("fieldset", { className: "ost-fieldset", children: [
1808
+ /* @__PURE__ */ jsx6("legend", { children: field.label }),
1809
+ /* @__PURE__ */ jsx6(
1810
+ FilePickerField,
1811
+ {
1812
+ media,
1813
+ mediaUrl,
1814
+ onChange: (next) => set(field.key, next),
1815
+ onUpload: onUploadMedia,
1816
+ value: {
1817
+ mediaId: value.mediaId ?? null,
1818
+ src: value.src || "",
1819
+ filename: value.filename || ""
1820
+ }
1821
+ }
1822
+ )
1823
+ ] }, field.key);
1824
+ }
1825
+ case "paragraphs":
1826
+ case "stringList": {
1827
+ const values = Array.isArray(raw) ? raw : [];
1828
+ return /* @__PURE__ */ jsx6(
1829
+ ParagraphsField,
1830
+ {
1831
+ label: field.label,
1832
+ onChange: (next) => set(field.key, next),
1833
+ value: values
1834
+ },
1835
+ field.key
1836
+ );
1837
+ }
1838
+ case "itemList": {
1839
+ const items = Array.isArray(raw) ? raw : [];
1840
+ return /* @__PURE__ */ jsxs6("fieldset", { className: "ost-fieldset", children: [
1841
+ /* @__PURE__ */ jsx6("legend", { children: field.label }),
1842
+ items.map((item, index) => /* @__PURE__ */ jsxs6("details", { className: "ost-item", children: [
1843
+ /* @__PURE__ */ jsxs6("summary", { children: [
1844
+ /* @__PURE__ */ jsx6("span", { children: String(
1845
+ field.itemLabelKey && pathGet(item, field.itemLabelKey) || `Item ${index + 1}`
1846
+ ) }),
1847
+ canChangeStructure ? /* @__PURE__ */ jsxs6("span", { className: "ost-item-actions", children: [
1848
+ /* @__PURE__ */ jsx6(
1849
+ "button",
1850
+ {
1851
+ disabled: index === 0,
1852
+ onClick: (event) => {
1853
+ event.preventDefault();
1854
+ set(field.key, moveItem(items, index, index - 1));
1855
+ },
1856
+ type: "button",
1857
+ children: "\u2191"
1858
+ }
1859
+ ),
1860
+ /* @__PURE__ */ jsx6(
1861
+ "button",
1862
+ {
1863
+ disabled: index === items.length - 1,
1864
+ onClick: (event) => {
1865
+ event.preventDefault();
1866
+ set(field.key, moveItem(items, index, index + 1));
1867
+ },
1868
+ type: "button",
1869
+ children: "\u2193"
1870
+ }
1871
+ ),
1872
+ /* @__PURE__ */ jsx6(
1873
+ "button",
1874
+ {
1875
+ onClick: (event) => {
1876
+ event.preventDefault();
1877
+ set(field.key, items.filter((_, itemIndex) => itemIndex !== index));
1878
+ },
1879
+ type: "button",
1880
+ children: "\u2715"
1881
+ }
1882
+ )
1883
+ ] }) : null
1884
+ ] }),
1885
+ /* @__PURE__ */ jsx6("div", { className: "ost-item-body", children: /* @__PURE__ */ jsx6(
1886
+ Inspector,
1887
+ {
1888
+ canChangeStructure,
1889
+ data: item,
1890
+ fields: field.itemFields || [],
1891
+ media,
1892
+ mediaUrl,
1893
+ onChange: (nextItem) => set(field.key, items.map((current, itemIndex) => itemIndex === index ? nextItem : current)),
1894
+ onUploadMedia,
1895
+ pages
1896
+ }
1897
+ ) })
1898
+ ] }, index)),
1899
+ canChangeStructure ? /* @__PURE__ */ jsxs6(
1900
+ "button",
1901
+ {
1902
+ className: "ost-btn",
1903
+ onClick: () => set(field.key, [...items, JSON.parse(JSON.stringify(field.itemTemplate || {}))]),
1904
+ type: "button",
1905
+ children: [
1906
+ "+ Add ",
1907
+ field.label.replace(/s$/, "").toLowerCase()
1908
+ ]
1909
+ }
1910
+ ) : null
1911
+ ] }, field.key);
1912
+ }
1913
+ default:
1914
+ return /* @__PURE__ */ jsxs6("label", { className: "ost-label", children: [
1915
+ field.label,
1916
+ /* @__PURE__ */ jsx6(
1917
+ "input",
1918
+ {
1919
+ className: "ost-input",
1920
+ onChange: (event) => set(field.key, event.target.value),
1921
+ type: "text",
1922
+ value: typeof raw === "string" ? raw : ""
1923
+ }
1924
+ )
1925
+ ] }, field.key);
1926
+ }
1927
+ };
1928
+ return /* @__PURE__ */ jsxs6("div", { className: "ost-inspector", children: [
1929
+ fields.map(renderField),
1930
+ pages && pages.length > 0 ? /* @__PURE__ */ jsx6("datalist", { id: datalistId, children: pages.map((page) => /* @__PURE__ */ jsx6("option", { label: page.title, value: page.path }, page.path)) }) : null
1931
+ ] });
1932
+ }
1933
+
1934
+ // src/studio/views/PageEditor.tsx
1935
+ import { Fragment as Fragment2, jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
1936
+ var AUTOSAVE_DELAY_MS = 8e3;
1937
+ function PageEditor({
1938
+ api,
1939
+ registry,
1940
+ pageId,
1941
+ role,
1942
+ canChangeStructure,
1943
+ canPublish,
1944
+ media,
1945
+ mediaUrl,
1946
+ onUploadMedia,
1947
+ onBack,
1948
+ onOpenPage
1949
+ }) {
1950
+ const [page, setPage] = useState7(null);
1951
+ const [layout, setLayout] = useState7([]);
1952
+ const [title, setTitle] = useState7("");
1953
+ const [seo, setSeo] = useState7({});
1954
+ const [selectedId, setSelectedId] = useState7(null);
1955
+ const [dirty, setDirty] = useState7(false);
1956
+ const [busy, setBusy] = useState7(null);
1957
+ const [message, setMessage] = useState7("");
1958
+ const [error, setError] = useState7("");
1959
+ const [paletteAt, setPaletteAt] = useState7(false);
1960
+ const [versions, setVersions] = useState7(null);
1961
+ const [versionPreview, setVersionPreview] = useState7(null);
1962
+ const [settingsOpen, setSettingsOpen] = useState7(false);
1963
+ const [pages, setPages] = useState7([]);
1964
+ const [dragIndex, setDragIndex] = useState7(null);
1965
+ const historyRef = useRef([]);
1966
+ const futureRef = useRef([]);
1967
+ const editRevRef = useRef(0);
1968
+ const savePromiseRef = useRef(null);
1969
+ const canDelete = role === "admin" || role === "developer";
1970
+ const canRename = role !== "content";
1971
+ useEffect6(() => {
1972
+ let active = true;
1973
+ api.getPage(pageId).then(
1974
+ ({ page: loaded }) => {
1975
+ if (!active) return;
1976
+ setPage(loaded);
1977
+ setLayout(loaded.draft_layout || []);
1978
+ setTitle(loaded.title);
1979
+ setSeo(loaded.seo || {});
1980
+ historyRef.current = [];
1981
+ futureRef.current = [];
1982
+ },
1983
+ (loadError) => active && setError(loadError.message)
1984
+ );
1985
+ api.listPages().then(
1986
+ ({ pages: list }) => active && setPages(list.map((entry) => ({ path: entry.path, title: entry.title }))),
1987
+ () => void 0
1988
+ );
1989
+ return () => {
1990
+ active = false;
1991
+ };
1992
+ }, [api, pageId]);
1993
+ const pushHistory = useCallback2((snapshot) => {
1994
+ historyRef.current = [...historyRef.current.slice(-49), snapshot];
1995
+ futureRef.current = [];
1996
+ }, []);
1997
+ const markDirty = () => {
1998
+ editRevRef.current += 1;
1999
+ setDirty(true);
2000
+ setMessage("");
2001
+ setError("");
2002
+ };
2003
+ const updateLayout = (next) => {
2004
+ pushHistory({ layout, title, seo });
2005
+ setLayout(next);
2006
+ markDirty();
2007
+ };
2008
+ const updateTitle = (next) => {
2009
+ pushHistory({ layout, title, seo });
2010
+ setTitle(next);
2011
+ markDirty();
2012
+ };
2013
+ const updateSeo = (next) => {
2014
+ pushHistory({ layout, title, seo });
2015
+ setSeo(next);
2016
+ markDirty();
2017
+ };
2018
+ const undo = useCallback2(() => {
2019
+ const previous = historyRef.current.pop();
2020
+ if (!previous) return;
2021
+ futureRef.current.push({ layout, title, seo });
2022
+ setLayout(previous.layout);
2023
+ setTitle(previous.title);
2024
+ setSeo(previous.seo);
2025
+ editRevRef.current += 1;
2026
+ setDirty(true);
2027
+ setMessage("");
2028
+ }, [layout, title, seo]);
2029
+ const redo = useCallback2(() => {
2030
+ const next = futureRef.current.pop();
2031
+ if (!next) return;
2032
+ historyRef.current.push({ layout, title, seo });
2033
+ setLayout(next.layout);
2034
+ setTitle(next.title);
2035
+ setSeo(next.seo);
2036
+ editRevRef.current += 1;
2037
+ setDirty(true);
2038
+ setMessage("");
2039
+ }, [layout, title, seo]);
2040
+ const selectedIndex = selectedId ? layout.findIndex((block) => block.id === selectedId) : -1;
2041
+ const selected = selectedIndex >= 0 ? layout[selectedIndex] : null;
2042
+ const selectedDefinition = selected ? registry.get(selected.type) : void 0;
2043
+ const changedSincePublish = useMemo3(() => {
2044
+ if (!page) return false;
2045
+ if (page.status !== "published") return true;
2046
+ return dirty || JSON.stringify(layout) !== JSON.stringify(page.published_layout ?? []);
2047
+ }, [page, layout, dirty]);
2048
+ const canvasRef = useRef(null);
2049
+ const layoutRef = useRef(layout);
2050
+ layoutRef.current = layout;
2051
+ const commitInline = useCallback2(
2052
+ (blockIndex, path, value) => {
2053
+ const current = layoutRef.current;
2054
+ const block = current[blockIndex];
2055
+ if (!block) return;
2056
+ historyRef.current = [
2057
+ ...historyRef.current.slice(-49),
2058
+ { layout: current, title, seo }
2059
+ ];
2060
+ futureRef.current = [];
2061
+ const nextData = applyInlineCommit(block.data, path, value);
2062
+ const next = current.map(
2063
+ (entry, index) => index === blockIndex ? { ...entry, data: nextData } : entry
2064
+ );
2065
+ setLayout(next);
2066
+ editRevRef.current += 1;
2067
+ setDirty(true);
2068
+ setMessage("");
2069
+ },
2070
+ [title, seo]
2071
+ );
2072
+ useEffect6(() => {
2073
+ const canvas = canvasRef.current;
2074
+ if (!canvas) return;
2075
+ const cleanups = [];
2076
+ const wrappers = canvas.querySelectorAll("[data-orion-block-index]");
2077
+ wrappers.forEach((wrapper) => {
2078
+ const blockIndex = Number(wrapper.dataset.orionBlockIndex);
2079
+ const block = layout[blockIndex];
2080
+ if (!block) return;
2081
+ const definition = registry.get(block.type);
2082
+ if (!definition) return;
2083
+ const targets = collectInlineTargets(definition, block.data);
2084
+ const { cleanup } = bindInlineEditing(
2085
+ wrapper,
2086
+ targets,
2087
+ (path, value) => commitInline(blockIndex, path, value),
2088
+ () => setSelectedId(block.id)
2089
+ );
2090
+ cleanups.push(cleanup);
2091
+ });
2092
+ return () => {
2093
+ for (const dispose of cleanups) dispose();
2094
+ };
2095
+ }, [layout, registry, commitInline]);
2096
+ const save = useCallback2(async () => {
2097
+ const prior = savePromiseRef.current ?? Promise.resolve(true);
2098
+ const run = prior.catch(() => void 0).then(async () => {
2099
+ const revAtStart = editRevRef.current;
2100
+ setBusy("save");
2101
+ setError("");
2102
+ try {
2103
+ const { page: saved } = await api.saveDraft(pageId, { title, seo, layout });
2104
+ setPage(saved);
2105
+ if (editRevRef.current === revAtStart) {
2106
+ setDirty(false);
2107
+ setMessage("Draft saved.");
2108
+ }
2109
+ return true;
2110
+ } catch (saveError) {
2111
+ setError(saveError instanceof Error ? saveError.message : "Save failed.");
2112
+ return false;
2113
+ } finally {
2114
+ setBusy(null);
2115
+ }
2116
+ });
2117
+ savePromiseRef.current = run;
2118
+ try {
2119
+ return await run;
2120
+ } finally {
2121
+ if (savePromiseRef.current === run) savePromiseRef.current = null;
2122
+ }
2123
+ }, [api, pageId, title, seo, layout]);
2124
+ const publish = async () => {
2125
+ if (dirty && !await save()) return;
2126
+ setBusy("publish");
2127
+ setError("");
2128
+ try {
2129
+ const { page: published } = await api.publish(pageId);
2130
+ setPage(published);
2131
+ setMessage("Published \u2014 live on the site.");
2132
+ } catch (publishError) {
2133
+ setError(publishError instanceof Error ? publishError.message : "Publish failed.");
2134
+ } finally {
2135
+ setBusy(null);
2136
+ }
2137
+ };
2138
+ const openPreview = async () => {
2139
+ if (dirty && !await save()) return;
2140
+ setBusy("preview");
2141
+ try {
2142
+ const { url } = await api.previewToken(pageId);
2143
+ window.open(url, "_blank", "noopener");
2144
+ } catch (previewError) {
2145
+ setError(previewError instanceof Error ? previewError.message : "Preview failed.");
2146
+ } finally {
2147
+ setBusy(null);
2148
+ }
2149
+ };
2150
+ useEffect6(() => {
2151
+ if (!dirty || busy || error) return;
2152
+ const timer = setTimeout(() => {
2153
+ void save();
2154
+ }, AUTOSAVE_DELAY_MS);
2155
+ return () => clearTimeout(timer);
2156
+ }, [dirty, busy, error, layout, title, seo, save]);
2157
+ useEffect6(() => {
2158
+ if (!dirty) return;
2159
+ const handler = (event) => {
2160
+ event.preventDefault();
2161
+ event.returnValue = "";
2162
+ };
2163
+ window.addEventListener("beforeunload", handler);
2164
+ return () => window.removeEventListener("beforeunload", handler);
2165
+ }, [dirty]);
2166
+ const guardedBack = () => {
2167
+ if (dirty && !window.confirm("You have unsaved changes. Leave without saving?")) return;
2168
+ onBack();
2169
+ };
2170
+ useEffect6(() => {
2171
+ const handler = (event) => {
2172
+ const meta = event.metaKey || event.ctrlKey;
2173
+ if (!meta) return;
2174
+ const target = event.target;
2175
+ const inEditable = target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
2176
+ if (event.key.toLowerCase() === "s") {
2177
+ event.preventDefault();
2178
+ void save();
2179
+ return;
2180
+ }
2181
+ if (inEditable) return;
2182
+ if (event.key.toLowerCase() === "z") {
2183
+ event.preventDefault();
2184
+ if (event.shiftKey) redo();
2185
+ else undo();
2186
+ }
2187
+ };
2188
+ window.addEventListener("keydown", handler);
2189
+ return () => window.removeEventListener("keydown", handler);
2190
+ }, [save, undo, redo]);
2191
+ const loadVersions = async () => {
2192
+ if (versions) {
2193
+ setVersions(null);
2194
+ return;
2195
+ }
2196
+ const { versions: list } = await api.listVersions(pageId);
2197
+ setVersions(list);
2198
+ };
2199
+ const restore = async (versionId) => {
2200
+ const { page: restored } = await api.restoreVersion(versionId);
2201
+ pushHistory({ layout, title, seo });
2202
+ setLayout(restored.draft_layout || []);
2203
+ setTitle(restored.title);
2204
+ setSeo(restored.seo || {});
2205
+ setVersions(null);
2206
+ setVersionPreview(null);
2207
+ setDirty(false);
2208
+ setMessage("Version restored into draft.");
2209
+ };
2210
+ const palette = useMemo3(() => registry.palette(), [registry]);
2211
+ const insertBlock = (type, at) => {
2212
+ const instance = registry.createInstance(type);
2213
+ const next = [...layout];
2214
+ next.splice(at, 0, instance);
2215
+ updateLayout(next);
2216
+ setSelectedId(instance.id);
2217
+ setPaletteAt(false);
2218
+ };
2219
+ const duplicateBlock = (index) => {
2220
+ const source = layout[index];
2221
+ const copy = {
2222
+ ...JSON.parse(JSON.stringify(source)),
2223
+ id: `b_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
2224
+ };
2225
+ const next = [...layout];
2226
+ next.splice(index + 1, 0, copy);
2227
+ updateLayout(next);
2228
+ setSelectedId(copy.id);
2229
+ };
2230
+ const statusChip = () => {
2231
+ if (!page) return { label: "", live: false };
2232
+ if (page.publish_at) return { label: `Scheduled ${new Date(page.publish_at).toLocaleString()}`, live: false };
2233
+ if (page.status !== "published") return { label: "Draft", live: false };
2234
+ if (changedSincePublish) return { label: "Live \xB7 edited", live: true };
2235
+ return { label: "Live", live: true };
2236
+ };
2237
+ if (!page && !error) return /* @__PURE__ */ jsx7("div", { className: "ost-loading", children: "Loading page\u2026" });
2238
+ if (error && !page) return /* @__PURE__ */ jsx7("div", { className: "ost-error", children: error });
2239
+ const chip = statusChip();
2240
+ return /* @__PURE__ */ jsxs7("div", { className: "ost-editor", children: [
2241
+ /* @__PURE__ */ jsxs7("header", { className: "ost-editor-bar", children: [
2242
+ /* @__PURE__ */ jsx7("button", { className: "ost-btn", onClick: guardedBack, type: "button", children: "\u2190 Pages" }),
2243
+ /* @__PURE__ */ jsx7(
2244
+ "input",
2245
+ {
2246
+ className: "ost-input ost-title-input",
2247
+ onChange: (event) => updateTitle(event.target.value),
2248
+ value: title
2249
+ }
2250
+ ),
2251
+ /* @__PURE__ */ jsx7("span", { className: `ost-pill${chip.live ? " is-live" : ""}`, children: chip.label }),
2252
+ /* @__PURE__ */ jsxs7("div", { className: "ost-editor-actions", children: [
2253
+ message ? /* @__PURE__ */ jsx7("span", { className: "ost-muted", children: message }) : null,
2254
+ error ? /* @__PURE__ */ jsx7("span", { className: "ost-error", children: error }) : null,
2255
+ /* @__PURE__ */ jsx7("button", { className: "ost-btn", onClick: () => setSettingsOpen(true), type: "button", children: "Settings" }),
2256
+ /* @__PURE__ */ jsx7("button", { className: "ost-btn", disabled: busy !== null, onClick: loadVersions, type: "button", children: "History" }),
2257
+ /* @__PURE__ */ jsx7("button", { className: "ost-btn", disabled: busy !== null, onClick: openPreview, type: "button", children: busy === "preview" ? "Opening\u2026" : "Preview" }),
2258
+ /* @__PURE__ */ jsx7(
2259
+ "button",
2260
+ {
2261
+ className: "ost-btn",
2262
+ disabled: busy !== null || !dirty,
2263
+ onClick: save,
2264
+ type: "button",
2265
+ children: busy === "save" ? "Saving\u2026" : "Save draft"
2266
+ }
2267
+ ),
2268
+ canPublish ? /* @__PURE__ */ jsx7(
2269
+ "button",
2270
+ {
2271
+ className: "ost-btn ost-btn-primary",
2272
+ disabled: busy !== null || !changedSincePublish && !dirty,
2273
+ onClick: publish,
2274
+ title: page?.published_at ? `Last published ${new Date(page.published_at).toLocaleString()}` : void 0,
2275
+ type: "button",
2276
+ children: busy === "publish" ? "Publishing\u2026" : "Publish"
2277
+ }
2278
+ ) : null
2279
+ ] })
2280
+ ] }),
2281
+ versions ? /* @__PURE__ */ jsxs7("div", { className: "ost-versions", children: [
2282
+ versions.length === 0 ? /* @__PURE__ */ jsx7("span", { className: "ost-muted", children: "No versions yet." }) : null,
2283
+ versions.map((version) => /* @__PURE__ */ jsxs7("div", { className: "ost-version-row", children: [
2284
+ /* @__PURE__ */ jsx7("span", { className: "ost-pill", children: version.kind }),
2285
+ /* @__PURE__ */ jsx7("span", { children: version.title }),
2286
+ /* @__PURE__ */ jsxs7("span", { className: "ost-muted", children: [
2287
+ version.author,
2288
+ " \xB7 ",
2289
+ new Date(version.created_at).toLocaleString()
2290
+ ] }),
2291
+ /* @__PURE__ */ jsx7(
2292
+ "button",
2293
+ {
2294
+ className: "ost-btn",
2295
+ onClick: async () => {
2296
+ const { version: detail } = await api.getVersion(version.id);
2297
+ setVersionPreview(detail);
2298
+ },
2299
+ type: "button",
2300
+ children: "View"
2301
+ }
2302
+ ),
2303
+ /* @__PURE__ */ jsx7("button", { className: "ost-btn", onClick: () => restore(version.id), type: "button", children: "Restore" })
2304
+ ] }, version.id))
2305
+ ] }) : null,
2306
+ /* @__PURE__ */ jsxs7("div", { className: "ost-editor-body", children: [
2307
+ /* @__PURE__ */ jsxs7(
2308
+ "main",
2309
+ {
2310
+ className: "ost-canvas",
2311
+ onClickCapture: (event) => {
2312
+ const anchor = event.target.closest("a");
2313
+ if (anchor && canvasRef.current?.contains(anchor)) {
2314
+ event.preventDefault();
2315
+ }
2316
+ },
2317
+ ref: canvasRef,
2318
+ children: [
2319
+ layout.length === 0 ? /* @__PURE__ */ jsxs7("div", { className: "ost-canvas-empty", children: [
2320
+ /* @__PURE__ */ jsx7("strong", { children: "No sections yet" }),
2321
+ canChangeStructure ? /* @__PURE__ */ jsx7("button", { className: "ost-btn ost-btn-primary", onClick: () => setPaletteAt(0), type: "button", children: "+ Add your first section" }) : /* @__PURE__ */ jsx7("span", { className: "ost-muted", children: "An editor can add sections to this page." })
2322
+ ] }) : null,
2323
+ /* @__PURE__ */ jsx7("div", { className: "ost-page", children: layout.map((block, index) => {
2324
+ const definition = registry.get(block.type);
2325
+ const Preview = definition?.preview;
2326
+ const isSelected = block.id === selectedId;
2327
+ return /* @__PURE__ */ jsxs7("div", { children: [
2328
+ canChangeStructure ? /* @__PURE__ */ jsx7(
2329
+ "button",
2330
+ {
2331
+ className: "ost-insert-zone",
2332
+ onClick: () => setPaletteAt(index),
2333
+ title: "Insert section here",
2334
+ type: "button",
2335
+ children: /* @__PURE__ */ jsx7("span", { children: "+" })
2336
+ }
2337
+ ) : null,
2338
+ /* @__PURE__ */ jsxs7(
2339
+ "div",
2340
+ {
2341
+ className: `ost-blockwrap${isSelected ? " is-selected" : ""}${dragIndex === index ? " is-dragging" : ""}`,
2342
+ "data-orion-block-index": index,
2343
+ onClick: () => setSelectedId(block.id),
2344
+ onDragOver: (event) => {
2345
+ if (dragIndex === null) return;
2346
+ event.preventDefault();
2347
+ },
2348
+ onDrop: (event) => {
2349
+ if (dragIndex === null) return;
2350
+ event.preventDefault();
2351
+ const moved = layout[dragIndex];
2352
+ updateLayout(moveItem(layout, dragIndex, index));
2353
+ if (moved) setSelectedId(moved.id);
2354
+ setDragIndex(null);
2355
+ },
2356
+ children: [
2357
+ /* @__PURE__ */ jsxs7("div", { className: "ost-blockchrome", onClick: (event) => event.stopPropagation(), children: [
2358
+ /* @__PURE__ */ jsxs7(
2359
+ "button",
2360
+ {
2361
+ className: "ost-blockchip",
2362
+ draggable: canChangeStructure,
2363
+ onClick: () => setSelectedId(block.id),
2364
+ onDragEnd: () => setDragIndex(null),
2365
+ onDragStart: (event) => {
2366
+ setDragIndex(index);
2367
+ event.dataTransfer.effectAllowed = "move";
2368
+ },
2369
+ title: canChangeStructure ? "Drag to reorder" : void 0,
2370
+ type: "button",
2371
+ children: [
2372
+ canChangeStructure ? /* @__PURE__ */ jsx7("span", { className: "ost-drag-dots", children: "\u22EE\u22EE" }) : null,
2373
+ definition?.label || block.type
2374
+ ]
2375
+ }
2376
+ ),
2377
+ canChangeStructure ? /* @__PURE__ */ jsxs7("span", { className: "ost-blockactions", children: [
2378
+ /* @__PURE__ */ jsx7(
2379
+ "button",
2380
+ {
2381
+ disabled: index === 0,
2382
+ onClick: () => updateLayout(moveItem(layout, index, index - 1)),
2383
+ title: "Move up",
2384
+ type: "button",
2385
+ children: "\u2191"
2386
+ }
2387
+ ),
2388
+ /* @__PURE__ */ jsx7(
2389
+ "button",
2390
+ {
2391
+ disabled: index === layout.length - 1,
2392
+ onClick: () => updateLayout(moveItem(layout, index, index + 1)),
2393
+ title: "Move down",
2394
+ type: "button",
2395
+ children: "\u2193"
2396
+ }
2397
+ ),
2398
+ /* @__PURE__ */ jsx7("button", { onClick: () => duplicateBlock(index), title: "Duplicate section", type: "button", children: "\u29C9" }),
2399
+ /* @__PURE__ */ jsx7(
2400
+ "button",
2401
+ {
2402
+ onClick: () => {
2403
+ updateLayout(layout.filter((_, blockIndex) => blockIndex !== index));
2404
+ setSelectedId(null);
2405
+ },
2406
+ title: "Remove section",
2407
+ type: "button",
2408
+ children: "\u2715"
2409
+ }
2410
+ )
2411
+ ] }) : null
2412
+ ] }),
2413
+ Preview ? /* @__PURE__ */ jsx7(Preview, { data: block.data, editing: true }) : /* @__PURE__ */ jsx7("div", { className: "ost-block-fallback", children: block.type })
2414
+ ]
2415
+ }
2416
+ )
2417
+ ] }, block.id);
2418
+ }) }),
2419
+ canChangeStructure && layout.length > 0 ? /* @__PURE__ */ jsx7("button", { className: "ost-add-section", onClick: () => setPaletteAt(layout.length), type: "button", children: "+ Add section" }) : null
2420
+ ]
2421
+ }
2422
+ ),
2423
+ /* @__PURE__ */ jsx7("aside", { className: "ost-sidebar-panel", children: selected && selectedDefinition ? /* @__PURE__ */ jsxs7(Fragment2, { children: [
2424
+ /* @__PURE__ */ jsx7("h3", { children: selectedDefinition.label }),
2425
+ /* @__PURE__ */ jsx7(
2426
+ Inspector,
2427
+ {
2428
+ canChangeStructure,
2429
+ data: selected.data,
2430
+ fields: selectedDefinition.editor.fields,
2431
+ media,
2432
+ mediaUrl,
2433
+ onChange: (nextData) => updateLayout(
2434
+ layout.map(
2435
+ (current) => current.id === selectedId ? { ...current, data: nextData } : current
2436
+ )
2437
+ ),
2438
+ onUploadMedia,
2439
+ pages
2440
+ }
2441
+ )
2442
+ ] }) : /* @__PURE__ */ jsx7("p", { className: "ost-muted", children: "Select a section to edit its content." }) })
2443
+ ] }),
2444
+ paletteAt !== false ? /* @__PURE__ */ jsx7("div", { className: "ost-palette", onClick: () => setPaletteAt(false), children: /* @__PURE__ */ jsxs7("div", { className: "ost-palette-card", onClick: (event) => event.stopPropagation(), children: [
2445
+ /* @__PURE__ */ jsx7("h3", { children: "Add a section" }),
2446
+ /* @__PURE__ */ jsx7("div", { className: "ost-palette-grid", children: palette.map((entry) => /* @__PURE__ */ jsxs7(
2447
+ "button",
2448
+ {
2449
+ className: "ost-palette-item",
2450
+ onClick: () => insertBlock(entry.type, paletteAt),
2451
+ type: "button",
2452
+ children: [
2453
+ /* @__PURE__ */ jsx7("strong", { children: entry.label }),
2454
+ entry.description ? /* @__PURE__ */ jsx7("span", { children: entry.description }) : null
2455
+ ]
2456
+ },
2457
+ entry.type
2458
+ )) })
2459
+ ] }) }) : null,
2460
+ versionPreview ? /* @__PURE__ */ jsx7("div", { className: "ost-palette", onClick: () => setVersionPreview(null), children: /* @__PURE__ */ jsxs7("div", { className: "ost-version-preview", onClick: (event) => event.stopPropagation(), children: [
2461
+ /* @__PURE__ */ jsxs7("header", { className: "ost-version-preview-bar", children: [
2462
+ /* @__PURE__ */ jsxs7("strong", { children: [
2463
+ versionPreview.kind,
2464
+ " \xB7 ",
2465
+ new Date(versionPreview.created_at).toLocaleString()
2466
+ ] }),
2467
+ /* @__PURE__ */ jsx7("span", { className: "ost-muted", children: versionPreview.title }),
2468
+ /* @__PURE__ */ jsxs7("span", { className: "ost-row", children: [
2469
+ /* @__PURE__ */ jsx7("button", { className: "ost-btn ost-btn-primary", onClick: () => restore(versionPreview.id), type: "button", children: "Restore this version" }),
2470
+ /* @__PURE__ */ jsx7("button", { className: "ost-btn", onClick: () => setVersionPreview(null), type: "button", children: "Close" })
2471
+ ] })
2472
+ ] }),
2473
+ /* @__PURE__ */ jsx7("div", { className: "ost-version-preview-body", children: (versionPreview.layout || []).map((block) => {
2474
+ const definition = registry.get(block.type);
2475
+ const Preview = definition?.preview;
2476
+ return Preview ? /* @__PURE__ */ jsx7(Preview, { data: block.data }, block.id) : /* @__PURE__ */ jsx7("div", { className: "ost-block-fallback", children: block.type }, block.id);
2477
+ }) })
2478
+ ] }) }) : null,
2479
+ settingsOpen && page ? /* @__PURE__ */ jsx7(
2480
+ PageSettings,
2481
+ {
2482
+ api,
2483
+ canDelete,
2484
+ canPublish,
2485
+ canRename,
2486
+ media,
2487
+ mediaUrl,
2488
+ onBack,
2489
+ onClose: () => setSettingsOpen(false),
2490
+ onFlushDraft: save,
2491
+ onOpenPage,
2492
+ onPageChanged: (next) => setPage(next),
2493
+ onSeoChange: updateSeo,
2494
+ onUploadMedia,
2495
+ page,
2496
+ seo
2497
+ }
2498
+ ) : null
2499
+ ] });
2500
+ }
2501
+ function PageSettings({
2502
+ api,
2503
+ page,
2504
+ seo,
2505
+ canRename,
2506
+ canPublish,
2507
+ canDelete,
2508
+ media,
2509
+ mediaUrl,
2510
+ onUploadMedia,
2511
+ onSeoChange,
2512
+ onPageChanged,
2513
+ onFlushDraft,
2514
+ onOpenPage,
2515
+ onClose,
2516
+ onBack
2517
+ }) {
2518
+ const [slug, setSlug] = useState7(page.slug);
2519
+ const [path, setPath] = useState7(page.path);
2520
+ const [publishAt, setPublishAt] = useState7(
2521
+ page.publish_at ? page.publish_at.slice(0, 16) : ""
2522
+ );
2523
+ const [error, setError] = useState7("");
2524
+ const [notice, setNotice] = useState7("");
2525
+ const metaTitle = typeof seo.metaTitle === "string" ? seo.metaTitle : "";
2526
+ const metaDescription = typeof seo.metaDescription === "string" ? seo.metaDescription : "";
2527
+ const saveIdentity = async () => {
2528
+ setError("");
2529
+ setNotice("");
2530
+ try {
2531
+ const { page: updated } = await api.saveDraft(page.id, { slug, path });
2532
+ onPageChanged(updated);
2533
+ setNotice(
2534
+ page.path !== updated.path && page.status === "published" ? "Saved \u2014 a redirect from the old address was added automatically." : "Saved."
2535
+ );
2536
+ } catch (saveError) {
2537
+ setError(saveError instanceof Error ? saveError.message : "Could not rename.");
2538
+ }
2539
+ };
2540
+ const schedule = async (value) => {
2541
+ setError("");
2542
+ setNotice("");
2543
+ if (value && !await onFlushDraft()) {
2544
+ setError("Could not save the current draft before scheduling.");
2545
+ return;
2546
+ }
2547
+ try {
2548
+ const { page: updated } = await api.saveDraft(page.id, {
2549
+ publishAt: value ? new Date(value).toISOString() : null
2550
+ });
2551
+ onPageChanged(updated);
2552
+ setPublishAt(updated.publish_at ? updated.publish_at.slice(0, 16) : "");
2553
+ setNotice(value ? "Publish scheduled." : "Schedule cleared.");
2554
+ } catch (scheduleError) {
2555
+ setError(scheduleError instanceof Error ? scheduleError.message : "Could not schedule.");
2556
+ }
2557
+ };
2558
+ return /* @__PURE__ */ jsx7("div", { className: "ost-palette", onClick: onClose, children: /* @__PURE__ */ jsxs7("div", { className: "ost-settings-card", onClick: (event) => event.stopPropagation(), children: [
2559
+ /* @__PURE__ */ jsxs7("header", { className: "ost-view-header", children: [
2560
+ /* @__PURE__ */ jsx7("h3", { children: "Page settings" }),
2561
+ /* @__PURE__ */ jsx7("button", { className: "ost-btn", onClick: onClose, type: "button", children: "Close" })
2562
+ ] }),
2563
+ error ? /* @__PURE__ */ jsx7("div", { className: "ost-error", children: error }) : null,
2564
+ notice ? /* @__PURE__ */ jsx7("div", { className: "ost-muted", children: notice }) : null,
2565
+ /* @__PURE__ */ jsxs7("section", { className: "ost-settings-section", children: [
2566
+ /* @__PURE__ */ jsx7("h4", { children: "Search & sharing" }),
2567
+ /* @__PURE__ */ jsxs7("label", { className: "ost-label", children: [
2568
+ "Meta title",
2569
+ /* @__PURE__ */ jsx7(
2570
+ "input",
2571
+ {
2572
+ className: "ost-input",
2573
+ onChange: (event) => onSeoChange({ ...seo, metaTitle: event.target.value }),
2574
+ placeholder: page.title,
2575
+ value: metaTitle
2576
+ }
2577
+ ),
2578
+ /* @__PURE__ */ jsxs7("span", { className: `ost-muted${metaTitle.length > 60 ? " ost-warn" : ""}`, children: [
2579
+ metaTitle.length,
2580
+ "/60 characters"
2581
+ ] })
2582
+ ] }),
2583
+ /* @__PURE__ */ jsxs7("label", { className: "ost-label", children: [
2584
+ "Meta description",
2585
+ /* @__PURE__ */ jsx7(
2586
+ "textarea",
2587
+ {
2588
+ className: "ost-input ost-textarea",
2589
+ onChange: (event) => onSeoChange({ ...seo, metaDescription: event.target.value }),
2590
+ rows: 3,
2591
+ value: metaDescription
2592
+ }
2593
+ ),
2594
+ /* @__PURE__ */ jsxs7("span", { className: `ost-muted${metaDescription.length > 160 ? " ost-warn" : ""}`, children: [
2595
+ metaDescription.length,
2596
+ "/160 characters"
2597
+ ] })
2598
+ ] }),
2599
+ /* @__PURE__ */ jsx7(
2600
+ Inspector,
2601
+ {
2602
+ canChangeStructure: true,
2603
+ data: seo,
2604
+ fields: [
2605
+ { key: "ogImage", label: "Social share image", input: "media" },
2606
+ { key: "noIndex", label: "Hide from search engines (noindex)", input: "checkbox" }
2607
+ ],
2608
+ media,
2609
+ mediaUrl,
2610
+ onChange: onSeoChange,
2611
+ onUploadMedia
2612
+ }
2613
+ ),
2614
+ /* @__PURE__ */ jsx7("p", { className: "ost-muted", children: "SEO changes apply when you save and publish the page." })
2615
+ ] }),
2616
+ canRename ? /* @__PURE__ */ jsxs7("section", { className: "ost-settings-section", children: [
2617
+ /* @__PURE__ */ jsx7("h4", { children: "Address" }),
2618
+ /* @__PURE__ */ jsxs7("label", { className: "ost-label", children: [
2619
+ "Slug",
2620
+ /* @__PURE__ */ jsx7("input", { className: "ost-input", onChange: (event) => setSlug(event.target.value), value: slug })
2621
+ ] }),
2622
+ /* @__PURE__ */ jsxs7("label", { className: "ost-label", children: [
2623
+ "Path",
2624
+ /* @__PURE__ */ jsx7("input", { className: "ost-input", onChange: (event) => setPath(event.target.value), value: path })
2625
+ ] }),
2626
+ /* @__PURE__ */ jsx7(
2627
+ "button",
2628
+ {
2629
+ className: "ost-btn",
2630
+ disabled: slug === page.slug && path === page.path,
2631
+ onClick: saveIdentity,
2632
+ type: "button",
2633
+ children: "Save address"
2634
+ }
2635
+ ),
2636
+ /* @__PURE__ */ jsx7("p", { className: "ost-muted", children: "Renaming a published page automatically redirects the old address." })
2637
+ ] }) : null,
2638
+ canPublish ? /* @__PURE__ */ jsxs7("section", { className: "ost-settings-section", children: [
2639
+ /* @__PURE__ */ jsx7("h4", { children: "Scheduled publish" }),
2640
+ /* @__PURE__ */ jsxs7("div", { className: "ost-row", children: [
2641
+ /* @__PURE__ */ jsx7(
2642
+ "input",
2643
+ {
2644
+ className: "ost-input",
2645
+ onChange: (event) => setPublishAt(event.target.value),
2646
+ type: "datetime-local",
2647
+ value: publishAt
2648
+ }
2649
+ ),
2650
+ /* @__PURE__ */ jsx7("button", { className: "ost-btn", disabled: !publishAt, onClick: () => schedule(publishAt), type: "button", children: "Schedule" }),
2651
+ page.publish_at ? /* @__PURE__ */ jsx7("button", { className: "ost-btn", onClick: () => schedule(null), type: "button", children: "Clear" }) : null
2652
+ ] }),
2653
+ /* @__PURE__ */ jsx7("p", { className: "ost-muted", children: "The current draft goes live automatically at the chosen time." })
2654
+ ] }) : null,
2655
+ /* @__PURE__ */ jsxs7("section", { className: "ost-settings-section", children: [
2656
+ /* @__PURE__ */ jsx7("h4", { children: "Actions" }),
2657
+ /* @__PURE__ */ jsxs7("div", { className: "ost-row", children: [
2658
+ canRename ? /* @__PURE__ */ jsx7(
2659
+ "button",
2660
+ {
2661
+ className: "ost-btn",
2662
+ onClick: async () => {
2663
+ try {
2664
+ const { page: copy } = await api.duplicatePage(page.id);
2665
+ if (onOpenPage) onOpenPage(copy.id);
2666
+ onClose();
2667
+ } catch (duplicateError) {
2668
+ setError(duplicateError instanceof Error ? duplicateError.message : "Duplicate failed.");
2669
+ }
2670
+ },
2671
+ type: "button",
2672
+ children: "Duplicate page"
2673
+ }
2674
+ ) : null,
2675
+ canPublish && page.status === "published" ? /* @__PURE__ */ jsx7(
2676
+ "button",
2677
+ {
2678
+ className: "ost-btn",
2679
+ onClick: async () => {
2680
+ if (!window.confirm("Take this page off the live site?")) return;
2681
+ try {
2682
+ const { page: updated } = await api.unpublish(page.id);
2683
+ onPageChanged(updated);
2684
+ setNotice("Page is no longer live.");
2685
+ } catch (unpublishError) {
2686
+ setError(unpublishError instanceof Error ? unpublishError.message : "Unpublish failed.");
2687
+ }
2688
+ },
2689
+ type: "button",
2690
+ children: "Unpublish"
2691
+ }
2692
+ ) : null,
2693
+ canDelete ? /* @__PURE__ */ jsx7(
2694
+ "button",
2695
+ {
2696
+ className: "ost-btn ost-btn-danger",
2697
+ onClick: async () => {
2698
+ if (!window.confirm(`Delete "${page.title}" permanently? This cannot be undone.`)) return;
2699
+ try {
2700
+ await api.deletePage(page.id);
2701
+ onClose();
2702
+ onBack();
2703
+ } catch (deleteError) {
2704
+ setError(deleteError instanceof Error ? deleteError.message : "Delete failed.");
2705
+ }
2706
+ },
2707
+ type: "button",
2708
+ children: "Delete page"
2709
+ }
2710
+ ) : null
2711
+ ] })
2712
+ ] })
2713
+ ] }) });
2714
+ }
2715
+
2716
+ // src/studio/views/RedirectsView.tsx
2717
+ import { useEffect as useEffect7, useState as useState8 } from "react";
2718
+ import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
2719
+ function RedirectsView({ api }) {
2720
+ const [redirects, setRedirects] = useState8(null);
2721
+ const [fromPath, setFromPath] = useState8("");
2722
+ const [toPath, setToPath] = useState8("");
2723
+ const [error, setError] = useState8("");
2724
+ const load = () => api.listRedirects().then(({ redirects: list }) => setRedirects(list), (e) => setError(e.message));
2725
+ useEffect7(() => {
2726
+ load();
2727
+ }, []);
2728
+ const create = async (event) => {
2729
+ event.preventDefault();
2730
+ setError("");
2731
+ try {
2732
+ await api.createRedirect({ fromPath: fromPath.trim(), toPath: toPath.trim() });
2733
+ setFromPath("");
2734
+ setToPath("");
2735
+ load();
2736
+ } catch (createError) {
2737
+ setError(createError instanceof Error ? createError.message : "Could not save redirect.");
2738
+ }
2739
+ };
2740
+ return /* @__PURE__ */ jsxs8("div", { className: "ost-view", children: [
2741
+ /* @__PURE__ */ jsx8("header", { className: "ost-view-header", children: /* @__PURE__ */ jsxs8("div", { children: [
2742
+ /* @__PURE__ */ jsx8("h2", { children: "Redirects" }),
2743
+ /* @__PURE__ */ jsx8("p", { className: "ost-muted", children: "Old addresses that forward visitors to a new destination." })
2744
+ ] }) }),
2745
+ /* @__PURE__ */ jsxs8("form", { className: "ost-card ost-redirect-form", onSubmit: create, children: [
2746
+ /* @__PURE__ */ jsxs8("label", { className: "ost-label ost-grow", children: [
2747
+ "From",
2748
+ /* @__PURE__ */ jsx8(
2749
+ "input",
2750
+ {
2751
+ className: "ost-input",
2752
+ onChange: (event) => setFromPath(event.target.value),
2753
+ placeholder: "/old-page",
2754
+ value: fromPath
2755
+ }
2756
+ )
2757
+ ] }),
2758
+ /* @__PURE__ */ jsx8("span", { className: "ost-redirect-arrow", children: "\u2192" }),
2759
+ /* @__PURE__ */ jsxs8("label", { className: "ost-label ost-grow", children: [
2760
+ "To",
2761
+ /* @__PURE__ */ jsx8(
2762
+ "input",
2763
+ {
2764
+ className: "ost-input",
2765
+ onChange: (event) => setToPath(event.target.value),
2766
+ placeholder: "/new-page",
2767
+ value: toPath
2768
+ }
2769
+ )
2770
+ ] }),
2771
+ /* @__PURE__ */ jsx8("button", { className: "ost-btn ost-btn-primary", disabled: !fromPath || !toPath, type: "submit", children: "Add" })
2772
+ ] }),
2773
+ error ? /* @__PURE__ */ jsx8("div", { className: "ost-error", children: error }) : null,
2774
+ !redirects ? /* @__PURE__ */ jsx8("div", { className: "ost-loading", children: "Loading\u2026" }) : null,
2775
+ redirects && redirects.length === 0 ? /* @__PURE__ */ jsx8("div", { className: "ost-card", children: "No redirects yet." }) : null,
2776
+ /* @__PURE__ */ jsx8("div", { className: "ost-list", children: (redirects || []).map((redirect) => /* @__PURE__ */ jsxs8("div", { className: "ost-list-item ost-redirect-row", children: [
2777
+ /* @__PURE__ */ jsxs8("span", { children: [
2778
+ /* @__PURE__ */ jsx8("code", { children: redirect.from_path }),
2779
+ " \u2192 ",
2780
+ /* @__PURE__ */ jsx8("code", { children: redirect.to_path })
2781
+ ] }),
2782
+ /* @__PURE__ */ jsx8(
2783
+ "button",
2784
+ {
2785
+ className: "ost-btn ost-btn-danger",
2786
+ onClick: async () => {
2787
+ await api.deleteRedirect(redirect.id).catch(() => void 0);
2788
+ load();
2789
+ },
2790
+ type: "button",
2791
+ children: "Remove"
2792
+ }
2793
+ )
2794
+ ] }, redirect.id)) })
2795
+ ] });
2796
+ }
2797
+
2798
+ // src/studio/views/SubmissionsView.tsx
2799
+ import { useCallback as useCallback3, useEffect as useEffect8, useState as useState9 } from "react";
2800
+ import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
2801
+ function SubmissionsView({
2802
+ api,
2803
+ canManage,
2804
+ initialFormId,
2805
+ onUnreadChanged
2806
+ }) {
2807
+ const [forms, setForms] = useState9([]);
2808
+ const [formFilter, setFormFilter] = useState9(initialFormId || "");
2809
+ const [unreadOnly, setUnreadOnly] = useState9(false);
2810
+ const [submissions, setSubmissions] = useState9(null);
2811
+ const [hasMore, setHasMore] = useState9(false);
2812
+ const [loadingMore, setLoadingMore] = useState9(false);
2813
+ const [error, setError] = useState9("");
2814
+ useEffect8(() => {
2815
+ api.listForms().then(({ forms: list }) => setForms(list), () => void 0);
2816
+ }, [api]);
2817
+ const load = useCallback3(() => {
2818
+ setSubmissions(null);
2819
+ api.listSubmissions({ form: formFilter || void 0, unread: unreadOnly || void 0 }).then(({ submissions: list, hasMore: more }) => {
2820
+ setSubmissions(list);
2821
+ setHasMore(more);
2822
+ }, (e) => setError(e.message));
2823
+ }, [api, formFilter, unreadOnly]);
2824
+ useEffect8(() => {
2825
+ load();
2826
+ }, [load]);
2827
+ const loadMore = async () => {
2828
+ if (!submissions || submissions.length === 0) return;
2829
+ setLoadingMore(true);
2830
+ try {
2831
+ const beforeId = submissions[submissions.length - 1].id;
2832
+ const { submissions: list, hasMore: more } = await api.listSubmissions({
2833
+ form: formFilter || void 0,
2834
+ unread: unreadOnly || void 0,
2835
+ beforeId
2836
+ });
2837
+ setSubmissions([...submissions, ...list]);
2838
+ setHasMore(more);
2839
+ } finally {
2840
+ setLoadingMore(false);
2841
+ }
2842
+ };
2843
+ const formTitle = (formId) => forms.find((form) => form.id === formId)?.title || "Form";
2844
+ const markRead = async (submission, read) => {
2845
+ await api.markSubmission(submission.id, read).catch(() => void 0);
2846
+ setSubmissions(
2847
+ (current) => current?.map(
2848
+ (entry) => entry.id === submission.id ? { ...entry, read_at: read ? (/* @__PURE__ */ new Date()).toISOString() : null } : entry
2849
+ ) ?? null
2850
+ );
2851
+ onUnreadChanged?.();
2852
+ };
2853
+ const exportCsv = async () => {
2854
+ try {
2855
+ const csv = await api.exportSubmissionsCsv(formFilter || void 0);
2856
+ const blob = new Blob([csv], { type: "text/csv" });
2857
+ const url = URL.createObjectURL(blob);
2858
+ const anchor = document.createElement("a");
2859
+ anchor.href = url;
2860
+ anchor.download = `submissions-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
2861
+ anchor.click();
2862
+ URL.revokeObjectURL(url);
2863
+ } catch (exportError) {
2864
+ setError(exportError instanceof Error ? exportError.message : "Export failed.");
2865
+ }
2866
+ };
2867
+ return /* @__PURE__ */ jsxs9("div", { className: "ost-view", children: [
2868
+ /* @__PURE__ */ jsxs9("header", { className: "ost-view-header", children: [
2869
+ /* @__PURE__ */ jsxs9("div", { children: [
2870
+ /* @__PURE__ */ jsx9("h2", { children: "Submissions" }),
2871
+ /* @__PURE__ */ jsx9("p", { className: "ost-muted", children: "Newest first. Open one to mark it read." })
2872
+ ] }),
2873
+ /* @__PURE__ */ jsxs9("div", { className: "ost-row", children: [
2874
+ /* @__PURE__ */ jsxs9(
2875
+ "select",
2876
+ {
2877
+ className: "ost-input ost-role-select",
2878
+ onChange: (event) => setFormFilter(event.target.value),
2879
+ value: formFilter,
2880
+ children: [
2881
+ /* @__PURE__ */ jsx9("option", { value: "", children: "All forms" }),
2882
+ forms.map((form) => /* @__PURE__ */ jsx9("option", { value: form.id, children: form.title }, form.id))
2883
+ ]
2884
+ }
2885
+ ),
2886
+ /* @__PURE__ */ jsxs9("label", { className: "ost-label ost-checkbox ost-inline-check", children: [
2887
+ /* @__PURE__ */ jsx9(
2888
+ "input",
2889
+ {
2890
+ checked: unreadOnly,
2891
+ onChange: (event) => setUnreadOnly(event.target.checked),
2892
+ type: "checkbox"
2893
+ }
2894
+ ),
2895
+ "Unread only"
2896
+ ] }),
2897
+ /* @__PURE__ */ jsx9("button", { className: "ost-btn", onClick: exportCsv, type: "button", children: "Export CSV" })
2898
+ ] })
2899
+ ] }),
2900
+ error ? /* @__PURE__ */ jsx9("div", { className: "ost-error", children: error }) : null,
2901
+ !submissions ? /* @__PURE__ */ jsx9("div", { className: "ost-loading", children: "Loading\u2026" }) : null,
2902
+ submissions && submissions.length === 0 ? /* @__PURE__ */ jsxs9("div", { className: "ost-card", children: [
2903
+ "No submissions",
2904
+ unreadOnly ? " to read" : " yet",
2905
+ "."
2906
+ ] }) : null,
2907
+ /* @__PURE__ */ jsx9("div", { className: "ost-list", children: (submissions || []).map((submission) => /* @__PURE__ */ jsxs9(
2908
+ "details",
2909
+ {
2910
+ className: `ost-card ost-submission${submission.read_at ? "" : " is-unread"}`,
2911
+ onToggle: (event) => {
2912
+ if (event.target.open && !submission.read_at) {
2913
+ void markRead(submission, true);
2914
+ }
2915
+ },
2916
+ children: [
2917
+ /* @__PURE__ */ jsxs9("summary", { children: [
2918
+ /* @__PURE__ */ jsxs9("strong", { children: [
2919
+ !submission.read_at ? /* @__PURE__ */ jsx9("span", { className: "ost-unread-dot" }) : null,
2920
+ String(submission.data.name || submission.data.email || `Submission #${submission.id}`)
2921
+ ] }),
2922
+ /* @__PURE__ */ jsxs9("span", { className: "ost-muted", children: [
2923
+ formTitle(submission.form_id),
2924
+ " \xB7 ",
2925
+ new Date(submission.created_at).toLocaleString()
2926
+ ] })
2927
+ ] }),
2928
+ /* @__PURE__ */ jsx9("dl", { className: "ost-submission-data", children: Object.entries(submission.data).map(([key, value]) => /* @__PURE__ */ jsxs9("div", { children: [
2929
+ /* @__PURE__ */ jsx9("dt", { children: key }),
2930
+ /* @__PURE__ */ jsx9("dd", { children: Array.isArray(value) ? value.join(", ") : typeof value === "string" ? value : JSON.stringify(value) })
2931
+ ] }, key)) }),
2932
+ /* @__PURE__ */ jsxs9("div", { className: "ost-row", children: [
2933
+ /* @__PURE__ */ jsxs9(
2934
+ "button",
2935
+ {
2936
+ className: "ost-btn",
2937
+ onClick: () => markRead(submission, !submission.read_at),
2938
+ type: "button",
2939
+ children: [
2940
+ "Mark ",
2941
+ submission.read_at ? "unread" : "read"
2942
+ ]
2943
+ }
2944
+ ),
2945
+ canManage ? /* @__PURE__ */ jsx9(
2946
+ "button",
2947
+ {
2948
+ className: "ost-btn ost-btn-danger",
2949
+ onClick: async () => {
2950
+ if (!window.confirm("Delete this submission permanently?")) return;
2951
+ await api.deleteSubmission(submission.id).catch(() => void 0);
2952
+ setSubmissions((current) => current?.filter((entry) => entry.id !== submission.id) ?? null);
2953
+ onUnreadChanged?.();
2954
+ },
2955
+ type: "button",
2956
+ children: "Delete"
2957
+ }
2958
+ ) : null
2959
+ ] })
2960
+ ]
2961
+ },
2962
+ submission.id
2963
+ )) }),
2964
+ hasMore ? /* @__PURE__ */ jsx9("div", { className: "ost-row ost-load-more", children: /* @__PURE__ */ jsx9("button", { className: "ost-btn", disabled: loadingMore, onClick: loadMore, type: "button", children: loadingMore ? "Loading\u2026" : "Load older submissions" }) }) : null
2965
+ ] });
2966
+ }
2967
+
2968
+ // src/studio/views/UsersView.tsx
2969
+ import { useCallback as useCallback4, useEffect as useEffect9, useState as useState10 } from "react";
2970
+ import { jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
2971
+ var ROLE_INFO = {
2972
+ content: { label: "Content", blurb: "Edit text and images on existing sections." },
2973
+ editor: { label: "Editor", blurb: "Add/remove sections, create pages, publish." },
2974
+ admin: { label: "Admin", blurb: "Everything, plus managing users." },
2975
+ developer: { label: "Developer", blurb: "Full access, including developer tools." }
2976
+ };
2977
+ function UsersView({ api, meId }) {
2978
+ const [users, setUsers] = useState10(null);
2979
+ const [roles, setRoles] = useState10([]);
2980
+ const [error, setError] = useState10("");
2981
+ const [notice, setNotice] = useState10("");
2982
+ const [adding, setAdding] = useState10(false);
2983
+ const [newEmail, setNewEmail] = useState10("");
2984
+ const [newName, setNewName] = useState10("");
2985
+ const [newPassword, setNewPassword] = useState10("");
2986
+ const [newRole, setNewRole] = useState10("content");
2987
+ const [busy, setBusy] = useState10(false);
2988
+ const load = useCallback4(() => {
2989
+ api.listUsers().then(
2990
+ ({ users: list, assignableRoles }) => {
2991
+ setUsers(list);
2992
+ setRoles(assignableRoles);
2993
+ },
2994
+ (loadError) => setError(loadError.message)
2995
+ );
2996
+ }, [api]);
2997
+ useEffect9(() => {
2998
+ load();
2999
+ }, [load]);
3000
+ const flash = (message) => {
3001
+ setNotice(message);
3002
+ setError("");
3003
+ window.setTimeout(() => setNotice(""), 4e3);
3004
+ };
3005
+ const create = async (event) => {
3006
+ event.preventDefault();
3007
+ setBusy(true);
3008
+ setError("");
3009
+ try {
3010
+ await api.createUser({ email: newEmail, password: newPassword, role: newRole, name: newName });
3011
+ setAdding(false);
3012
+ setNewEmail("");
3013
+ setNewName("");
3014
+ setNewPassword("");
3015
+ setNewRole("content");
3016
+ flash("User added. Share their email and password with them.");
3017
+ load();
3018
+ } catch (createError) {
3019
+ setError(createError instanceof Error ? createError.message : "Could not add the user.");
3020
+ }
3021
+ setBusy(false);
3022
+ };
3023
+ const changeRole = async (user, role) => {
3024
+ setError("");
3025
+ try {
3026
+ await api.updateUser(user.id, { role });
3027
+ flash(`${user.email || "User"} is now ${ROLE_INFO[role].label}.`);
3028
+ load();
3029
+ } catch (updateError) {
3030
+ setError(updateError instanceof Error ? updateError.message : "Could not update the role.");
3031
+ load();
3032
+ }
3033
+ };
3034
+ const resetPassword = async (user) => {
3035
+ const password = window.prompt(`New password for ${user.email || "this user"} (min 8 characters):`);
3036
+ if (!password) return;
3037
+ setError("");
3038
+ try {
3039
+ await api.updateUser(user.id, { password });
3040
+ flash("Password updated.");
3041
+ } catch (updateError) {
3042
+ setError(updateError instanceof Error ? updateError.message : "Could not update the password.");
3043
+ }
3044
+ };
3045
+ const remove = async (user) => {
3046
+ if (!window.confirm(`Remove ${user.email || "this user"}? They will lose Studio access immediately.`)) {
3047
+ return;
3048
+ }
3049
+ setError("");
3050
+ try {
3051
+ await api.deleteUser(user.id);
3052
+ flash("User removed.");
3053
+ load();
3054
+ } catch (deleteError) {
3055
+ setError(deleteError instanceof Error ? deleteError.message : "Could not remove the user.");
3056
+ }
3057
+ };
3058
+ const canManage = (user) => user.id !== meId && (user.role === null || roles.includes(user.role));
3059
+ return /* @__PURE__ */ jsxs10("div", { className: "ost-view", children: [
3060
+ /* @__PURE__ */ jsxs10("header", { className: "ost-view-header", children: [
3061
+ /* @__PURE__ */ jsxs10("div", { children: [
3062
+ /* @__PURE__ */ jsx10("h2", { children: "Users" }),
3063
+ /* @__PURE__ */ jsx10("p", { className: "ost-muted", children: "Who can sign in to the Studio, and what they can do." })
3064
+ ] }),
3065
+ /* @__PURE__ */ jsx10("button", { className: "ost-btn ost-btn-primary", onClick: () => setAdding(!adding), type: "button", children: "Add user" })
3066
+ ] }),
3067
+ adding ? /* @__PURE__ */ jsxs10("form", { className: "ost-card ost-create-form", onSubmit: create, children: [
3068
+ /* @__PURE__ */ jsxs10("label", { className: "ost-label", children: [
3069
+ "Email",
3070
+ /* @__PURE__ */ jsx10(
3071
+ "input",
3072
+ {
3073
+ autoComplete: "off",
3074
+ className: "ost-input",
3075
+ onChange: (event) => setNewEmail(event.target.value),
3076
+ placeholder: "person@company.com",
3077
+ type: "email",
3078
+ value: newEmail
3079
+ }
3080
+ )
3081
+ ] }),
3082
+ /* @__PURE__ */ jsxs10("label", { className: "ost-label", children: [
3083
+ "Name (optional)",
3084
+ /* @__PURE__ */ jsx10(
3085
+ "input",
3086
+ {
3087
+ autoComplete: "off",
3088
+ className: "ost-input",
3089
+ onChange: (event) => setNewName(event.target.value),
3090
+ value: newName
3091
+ }
3092
+ )
3093
+ ] }),
3094
+ /* @__PURE__ */ jsxs10("label", { className: "ost-label", children: [
3095
+ "Temporary password",
3096
+ /* @__PURE__ */ jsx10(
3097
+ PasswordInput,
3098
+ {
3099
+ autoComplete: "new-password",
3100
+ onChange: (event) => setNewPassword(event.target.value),
3101
+ placeholder: "At least 8 characters",
3102
+ value: newPassword
3103
+ }
3104
+ )
3105
+ ] }),
3106
+ /* @__PURE__ */ jsxs10("label", { className: "ost-label", children: [
3107
+ "Role",
3108
+ /* @__PURE__ */ jsx10(
3109
+ "select",
3110
+ {
3111
+ className: "ost-input",
3112
+ onChange: (event) => setNewRole(event.target.value),
3113
+ value: newRole,
3114
+ children: roles.map((role) => /* @__PURE__ */ jsxs10("option", { value: role, children: [
3115
+ ROLE_INFO[role].label,
3116
+ " \u2014 ",
3117
+ ROLE_INFO[role].blurb
3118
+ ] }, role))
3119
+ }
3120
+ )
3121
+ ] }),
3122
+ /* @__PURE__ */ jsx10(
3123
+ "button",
3124
+ {
3125
+ className: "ost-btn ost-btn-primary",
3126
+ disabled: busy || !newEmail || newPassword.length < 8,
3127
+ type: "submit",
3128
+ children: busy ? "Adding\u2026" : "Add user"
3129
+ }
3130
+ )
3131
+ ] }) : null,
3132
+ error ? /* @__PURE__ */ jsx10("div", { className: "ost-error", children: error }) : null,
3133
+ notice ? /* @__PURE__ */ jsx10("div", { className: "ost-notice", children: notice }) : null,
3134
+ !users ? /* @__PURE__ */ jsx10("div", { className: "ost-loading", children: "Loading\u2026" }) : null,
3135
+ /* @__PURE__ */ jsx10("div", { className: "ost-list", children: (users || []).map((user) => /* @__PURE__ */ jsxs10("div", { className: "ost-list-item ost-user-row", children: [
3136
+ /* @__PURE__ */ jsxs10("div", { className: "ost-user-id", children: [
3137
+ /* @__PURE__ */ jsx10("strong", { children: user.email || "(no email)" }),
3138
+ /* @__PURE__ */ jsxs10("span", { className: "ost-muted", children: [
3139
+ user.name ? `${user.name} \xB7 ` : "",
3140
+ user.id === meId ? "you" : user.last_sign_in_at ? `last sign-in ${new Date(user.last_sign_in_at).toLocaleDateString()}` : "never signed in"
3141
+ ] })
3142
+ ] }),
3143
+ canManage(user) ? /* @__PURE__ */ jsxs10("div", { className: "ost-user-actions", children: [
3144
+ /* @__PURE__ */ jsxs10(
3145
+ "select",
3146
+ {
3147
+ className: "ost-input ost-role-select",
3148
+ onChange: (event) => changeRole(user, event.target.value),
3149
+ title: user.role ? ROLE_INFO[user.role].blurb : "No Studio access yet",
3150
+ value: user.role ?? "",
3151
+ children: [
3152
+ user.role === null ? /* @__PURE__ */ jsx10("option", { value: "", children: "No access" }) : null,
3153
+ roles.map((role) => /* @__PURE__ */ jsx10("option", { value: role, children: ROLE_INFO[role].label }, role))
3154
+ ]
3155
+ }
3156
+ ),
3157
+ /* @__PURE__ */ jsx10("button", { className: "ost-btn", onClick: () => resetPassword(user), type: "button", children: "Reset password" }),
3158
+ /* @__PURE__ */ jsx10("button", { className: "ost-btn ost-btn-danger", onClick: () => remove(user), type: "button", children: "Remove" })
3159
+ ] }) : /* @__PURE__ */ jsx10("span", { className: "ost-pill", children: user.role ? ROLE_INFO[user.role].label : "No access" })
3160
+ ] }, user.id)) }),
3161
+ /* @__PURE__ */ jsxs10("div", { className: "ost-card ost-role-legend", children: [
3162
+ /* @__PURE__ */ jsx10("strong", { children: "Roles" }),
3163
+ Object.keys(ROLE_INFO).map((role) => /* @__PURE__ */ jsxs10("p", { className: "ost-muted", children: [
3164
+ /* @__PURE__ */ jsx10("b", { children: ROLE_INFO[role].label }),
3165
+ " \u2014 ",
3166
+ ROLE_INFO[role].blurb
3167
+ ] }, role))
3168
+ ] })
3169
+ ] });
3170
+ }
3171
+
3172
+ // src/studio/views/views.tsx
3173
+ import { useEffect as useEffect10, useMemo as useMemo4, useState as useState11 } from "react";
3174
+ import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
3175
+ var pageStatus = (page) => {
3176
+ if (page.publish_at) return { label: "scheduled", live: false };
3177
+ if (page.status !== "published") return { label: "draft", live: false };
3178
+ if (page.has_draft_changes) return { label: "live \xB7 edited", live: true };
3179
+ return { label: "live", live: true };
3180
+ };
3181
+ function PagesView({
3182
+ api,
3183
+ canCreate,
3184
+ onOpen
3185
+ }) {
3186
+ const [pages, setPages] = useState11(null);
3187
+ const [creating, setCreating] = useState11(false);
3188
+ const [newTitle, setNewTitle] = useState11("");
3189
+ const [newSlug, setNewSlug] = useState11("");
3190
+ const [error, setError] = useState11("");
3191
+ const load = () => api.listPages().then(({ pages: list }) => setPages(list), (e) => setError(e.message));
3192
+ useEffect10(() => {
3193
+ load();
3194
+ }, []);
3195
+ const create = async (event) => {
3196
+ event.preventDefault();
3197
+ setError("");
3198
+ try {
3199
+ const { page } = await api.createPage({
3200
+ title: newTitle,
3201
+ slug: newSlug || newTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
3202
+ });
3203
+ setCreating(false);
3204
+ setNewTitle("");
3205
+ setNewSlug("");
3206
+ onOpen(page.id);
3207
+ } catch (createError) {
3208
+ setError(createError instanceof Error ? createError.message : "Could not create page.");
3209
+ }
3210
+ };
3211
+ return /* @__PURE__ */ jsxs11("div", { className: "ost-view", children: [
3212
+ /* @__PURE__ */ jsxs11("header", { className: "ost-view-header", children: [
3213
+ /* @__PURE__ */ jsxs11("div", { children: [
3214
+ /* @__PURE__ */ jsx11("h2", { children: "Pages" }),
3215
+ /* @__PURE__ */ jsx11("p", { className: "ost-muted", children: "Open a page to edit it in the builder." })
3216
+ ] }),
3217
+ canCreate ? /* @__PURE__ */ jsx11("button", { className: "ost-btn ost-btn-primary", onClick: () => setCreating(!creating), type: "button", children: "New page" }) : null
3218
+ ] }),
3219
+ creating ? /* @__PURE__ */ jsxs11("form", { className: "ost-card ost-create-form", onSubmit: create, children: [
3220
+ /* @__PURE__ */ jsxs11("label", { className: "ost-label", children: [
3221
+ "Title",
3222
+ /* @__PURE__ */ jsx11("input", { className: "ost-input", onChange: (e) => setNewTitle(e.target.value), value: newTitle })
3223
+ ] }),
3224
+ /* @__PURE__ */ jsxs11("label", { className: "ost-label", children: [
3225
+ "Slug (optional)",
3226
+ /* @__PURE__ */ jsx11("input", { className: "ost-input", onChange: (e) => setNewSlug(e.target.value), value: newSlug })
3227
+ ] }),
3228
+ /* @__PURE__ */ jsx11("button", { className: "ost-btn ost-btn-primary", disabled: !newTitle, type: "submit", children: "Create" })
3229
+ ] }) : null,
3230
+ error ? /* @__PURE__ */ jsx11("div", { className: "ost-error", children: error }) : null,
3231
+ !pages ? /* @__PURE__ */ jsx11("div", { className: "ost-loading", children: "Loading\u2026" }) : null,
3232
+ /* @__PURE__ */ jsx11("div", { className: "ost-list", children: (pages || []).map((page) => {
3233
+ const status = pageStatus(page);
3234
+ return /* @__PURE__ */ jsxs11("button", { className: "ost-list-item", onClick: () => onOpen(page.id), type: "button", children: [
3235
+ /* @__PURE__ */ jsxs11("div", { children: [
3236
+ /* @__PURE__ */ jsx11("strong", { children: page.path === "/" ? "Home" : page.title }),
3237
+ /* @__PURE__ */ jsxs11("span", { className: "ost-muted", children: [
3238
+ " ",
3239
+ page.path
3240
+ ] })
3241
+ ] }),
3242
+ /* @__PURE__ */ jsx11("span", { className: `ost-pill${status.live ? " is-live" : ""}`, children: status.label })
3243
+ ] }, page.id);
3244
+ }) })
3245
+ ] });
3246
+ }
3247
+ function MediaView({
3248
+ api,
3249
+ media,
3250
+ mediaUrl,
3251
+ canDelete,
3252
+ onUpload,
3253
+ onChanged
3254
+ }) {
3255
+ const [uploading, setUploading] = useState11(false);
3256
+ const [selected, setSelected] = useState11(null);
3257
+ const [search, setSearch] = useState11("");
3258
+ const [error, setError] = useState11("");
3259
+ const visible = useMemo4(() => {
3260
+ const term = search.trim().toLowerCase();
3261
+ if (!term) return media;
3262
+ return media.filter(
3263
+ (item) => item.filename.toLowerCase().includes(term) || item.alt.toLowerCase().includes(term)
3264
+ );
3265
+ }, [media, search]);
3266
+ return /* @__PURE__ */ jsxs11("div", { className: "ost-view", children: [
3267
+ /* @__PURE__ */ jsxs11("header", { className: "ost-view-header", children: [
3268
+ /* @__PURE__ */ jsxs11("div", { children: [
3269
+ /* @__PURE__ */ jsx11("h2", { children: "Media" }),
3270
+ /* @__PURE__ */ jsxs11("p", { className: "ost-muted", children: [
3271
+ media.length,
3272
+ " item",
3273
+ media.length === 1 ? "" : "s",
3274
+ " in the library."
3275
+ ] })
3276
+ ] }),
3277
+ /* @__PURE__ */ jsxs11("div", { className: "ost-row", children: [
3278
+ /* @__PURE__ */ jsx11(
3279
+ "input",
3280
+ {
3281
+ className: "ost-input",
3282
+ onChange: (event) => setSearch(event.target.value),
3283
+ placeholder: "Search files\u2026",
3284
+ type: "search",
3285
+ value: search
3286
+ }
3287
+ ),
3288
+ /* @__PURE__ */ jsxs11("label", { className: "ost-btn ost-btn-primary", children: [
3289
+ uploading ? "Uploading\u2026" : "Upload",
3290
+ /* @__PURE__ */ jsx11(
3291
+ "input",
3292
+ {
3293
+ accept: "image/*,application/pdf",
3294
+ hidden: true,
3295
+ onChange: async (event) => {
3296
+ const file = event.currentTarget.files?.[0];
3297
+ event.currentTarget.value = "";
3298
+ if (!file) return;
3299
+ setUploading(true);
3300
+ await onUpload(file);
3301
+ setUploading(false);
3302
+ },
3303
+ type: "file"
3304
+ }
3305
+ )
3306
+ ] })
3307
+ ] })
3308
+ ] }),
3309
+ error ? /* @__PURE__ */ jsx11("div", { className: "ost-error", children: error }) : null,
3310
+ /* @__PURE__ */ jsx11("div", { className: "ost-media-grid ost-media-grid-lg", children: visible.map((item) => /* @__PURE__ */ jsxs11(
3311
+ "button",
3312
+ {
3313
+ className: `ost-media-cell${selected?.id === item.id ? " is-selected" : ""}`,
3314
+ onClick: () => {
3315
+ setError("");
3316
+ setSelected(selected?.id === item.id ? null : item);
3317
+ },
3318
+ type: "button",
3319
+ children: [
3320
+ item.mime_type.startsWith("image/") ? (
3321
+ // eslint-disable-next-line @next/next/no-img-element
3322
+ /* @__PURE__ */ jsx11("img", { alt: item.alt, loading: "lazy", src: mediaUrl(item.storage_path, { width: 320 }) })
3323
+ ) : /* @__PURE__ */ jsx11("span", { className: "ost-media-doc", children: "\u{1F4C4}" }),
3324
+ /* @__PURE__ */ jsx11("span", { className: "ost-media-name", children: item.filename })
3325
+ ]
3326
+ },
3327
+ item.id
3328
+ )) }),
3329
+ selected ? /* @__PURE__ */ jsx11(
3330
+ MediaDetail,
3331
+ {
3332
+ api,
3333
+ canDelete,
3334
+ media: selected,
3335
+ mediaUrl,
3336
+ onChanged: () => {
3337
+ onChanged();
3338
+ },
3339
+ onClose: () => setSelected(null),
3340
+ onError: setError
3341
+ },
3342
+ selected.id
3343
+ ) : null
3344
+ ] });
3345
+ }
3346
+ function MediaDetail({
3347
+ api,
3348
+ media,
3349
+ mediaUrl,
3350
+ canDelete,
3351
+ onChanged,
3352
+ onClose,
3353
+ onError
3354
+ }) {
3355
+ const [alt, setAlt] = useState11(media.alt);
3356
+ const [caption, setCaption] = useState11(media.caption);
3357
+ const [usage, setUsage] = useState11(null);
3358
+ const [saving, setSaving] = useState11(false);
3359
+ const [replacing, setReplacing] = useState11(false);
3360
+ useEffect10(() => {
3361
+ api.mediaUsage(media.id).then(({ usage: list }) => setUsage(list), () => setUsage([]));
3362
+ }, [api, media.id]);
3363
+ const dirty = alt !== media.alt || caption !== media.caption;
3364
+ return /* @__PURE__ */ jsxs11("div", { className: "ost-card ost-media-detail", children: [
3365
+ /* @__PURE__ */ jsxs11("div", { className: "ost-media-detail-head", children: [
3366
+ /* @__PURE__ */ jsx11("strong", { children: media.filename }),
3367
+ /* @__PURE__ */ jsxs11("span", { className: "ost-muted", children: [
3368
+ media.width && media.height ? `${media.width}\xD7${media.height} \xB7 ` : "",
3369
+ media.mime_type
3370
+ ] }),
3371
+ usage === null ? /* @__PURE__ */ jsx11("span", { className: "ost-muted", children: "Checking usage\u2026" }) : usage.length === 0 ? /* @__PURE__ */ jsx11("span", { className: "ost-muted", children: "Not used on any page." }) : /* @__PURE__ */ jsxs11("span", { className: "ost-muted", children: [
3372
+ "Used on: ",
3373
+ usage.map((page) => page.title || page.path).join(", ")
3374
+ ] })
3375
+ ] }),
3376
+ /* @__PURE__ */ jsxs11("label", { className: "ost-label", children: [
3377
+ "Alt text",
3378
+ /* @__PURE__ */ jsx11("input", { className: "ost-input", onChange: (event) => setAlt(event.target.value), value: alt })
3379
+ ] }),
3380
+ /* @__PURE__ */ jsxs11("label", { className: "ost-label", children: [
3381
+ "Caption",
3382
+ /* @__PURE__ */ jsx11("input", { className: "ost-input", onChange: (event) => setCaption(event.target.value), value: caption })
3383
+ ] }),
3384
+ /* @__PURE__ */ jsxs11("div", { className: "ost-row", children: [
3385
+ /* @__PURE__ */ jsx11(
3386
+ "button",
3387
+ {
3388
+ className: "ost-btn ost-btn-primary",
3389
+ disabled: !dirty || saving,
3390
+ onClick: async () => {
3391
+ setSaving(true);
3392
+ try {
3393
+ await api.updateMedia(media.id, { alt, caption });
3394
+ onChanged();
3395
+ } catch (updateError) {
3396
+ onError(updateError instanceof Error ? updateError.message : "Save failed.");
3397
+ } finally {
3398
+ setSaving(false);
3399
+ }
3400
+ },
3401
+ type: "button",
3402
+ children: saving ? "Saving\u2026" : "Save details"
3403
+ }
3404
+ ),
3405
+ /* @__PURE__ */ jsxs11("label", { className: "ost-btn", title: "The new file keeps every existing reference. CDN caching can take up to an hour to show the new version everywhere.", children: [
3406
+ replacing ? "Replacing\u2026" : "Replace file",
3407
+ /* @__PURE__ */ jsx11(
3408
+ "input",
3409
+ {
3410
+ accept: media.mime_type.startsWith("image/") ? "image/*" : "application/pdf",
3411
+ hidden: true,
3412
+ onChange: async (event) => {
3413
+ const file = event.currentTarget.files?.[0];
3414
+ event.currentTarget.value = "";
3415
+ if (!file) return;
3416
+ setReplacing(true);
3417
+ try {
3418
+ const form = new FormData();
3419
+ form.set("file", file);
3420
+ await api.replaceMedia(media.id, form);
3421
+ onChanged();
3422
+ } catch (replaceError) {
3423
+ onError(replaceError instanceof Error ? replaceError.message : "Replace failed.");
3424
+ } finally {
3425
+ setReplacing(false);
3426
+ }
3427
+ },
3428
+ type: "file"
3429
+ }
3430
+ )
3431
+ ] }),
3432
+ canDelete ? /* @__PURE__ */ jsx11(
3433
+ "button",
3434
+ {
3435
+ className: "ost-btn ost-btn-danger",
3436
+ onClick: async () => {
3437
+ const used = usage && usage.length > 0;
3438
+ const prompt = used ? `"${media.filename}" is used on ${usage.length} page${usage.length === 1 ? "" : "s"} (${usage.map((page) => page.title || page.path).join(", ")}). Deleting it will leave broken images. Delete anyway?` : `Delete "${media.filename}" permanently?`;
3439
+ if (!window.confirm(prompt)) return;
3440
+ try {
3441
+ await api.deleteMedia(media.id, Boolean(used));
3442
+ onClose();
3443
+ onChanged();
3444
+ } catch (deleteError) {
3445
+ if (deleteError instanceof StudioApiError && deleteError.usage) {
3446
+ onError("This file is still used on pages \u2014 remove it from them first.");
3447
+ } else {
3448
+ onError(deleteError instanceof Error ? deleteError.message : "Delete failed.");
3449
+ }
3450
+ }
3451
+ },
3452
+ type: "button",
3453
+ children: "Delete"
3454
+ }
3455
+ ) : null,
3456
+ /* @__PURE__ */ jsx11("button", { className: "ost-btn", onClick: onClose, type: "button", children: "Close" })
3457
+ ] }),
3458
+ media.mime_type.startsWith("image/") ? (
3459
+ // eslint-disable-next-line @next/next/no-img-element
3460
+ /* @__PURE__ */ jsx11("img", { alt, className: "ost-media-preview", src: mediaUrl(media.storage_path, { width: 480 }) })
3461
+ ) : null
3462
+ ] });
3463
+ }
3464
+ function GlobalsView({
3465
+ api,
3466
+ globals,
3467
+ media,
3468
+ mediaUrl,
3469
+ onUploadMedia,
3470
+ pages
3471
+ }) {
3472
+ const definitions = globals.list();
3473
+ const [activeKey, setActiveKey] = useState11(definitions[0]?.key || "");
3474
+ const [data, setData] = useState11(null);
3475
+ const [dirty, setDirty] = useState11(false);
3476
+ const [message, setMessage] = useState11("");
3477
+ const [history, setHistory] = useState11(null);
3478
+ const active = globals.get(activeKey);
3479
+ useEffect10(() => {
3480
+ if (!activeKey) return;
3481
+ let cancelled = false;
3482
+ setData(null);
3483
+ setDirty(false);
3484
+ setMessage("");
3485
+ setHistory(null);
3486
+ api.getGlobal(activeKey).then(
3487
+ ({ global }) => {
3488
+ if (!cancelled) setData(globals.normalize(activeKey, global.data));
3489
+ },
3490
+ () => void 0
3491
+ );
3492
+ return () => {
3493
+ cancelled = true;
3494
+ };
3495
+ }, [api, activeKey, globals]);
3496
+ const toggleHistory = async () => {
3497
+ if (history) {
3498
+ setHistory(null);
3499
+ return;
3500
+ }
3501
+ const { versions } = await api.listGlobalVersions(activeKey);
3502
+ setHistory(versions);
3503
+ };
3504
+ return /* @__PURE__ */ jsxs11("div", { className: "ost-view", children: [
3505
+ /* @__PURE__ */ jsx11("header", { className: "ost-view-header", children: /* @__PURE__ */ jsxs11("div", { children: [
3506
+ /* @__PURE__ */ jsx11("h2", { children: "Globals" }),
3507
+ /* @__PURE__ */ jsx11("p", { className: "ost-muted", children: "Site-wide settings shared by every page." })
3508
+ ] }) }),
3509
+ /* @__PURE__ */ jsxs11("div", { className: "ost-globals", children: [
3510
+ /* @__PURE__ */ jsx11("nav", { className: "ost-globals-nav", children: definitions.map((definition) => /* @__PURE__ */ jsx11(
3511
+ "button",
3512
+ {
3513
+ className: `ost-list-item${definition.key === activeKey ? " is-active" : ""}`,
3514
+ onClick: () => setActiveKey(definition.key),
3515
+ type: "button",
3516
+ children: definition.label
3517
+ },
3518
+ definition.key
3519
+ )) }),
3520
+ /* @__PURE__ */ jsx11("div", { className: "ost-globals-editor", children: active && data ? /* @__PURE__ */ jsxs11(Fragment3, { children: [
3521
+ /* @__PURE__ */ jsx11(
3522
+ Inspector,
3523
+ {
3524
+ canChangeStructure: true,
3525
+ data,
3526
+ fields: active.editor.fields,
3527
+ media,
3528
+ mediaUrl,
3529
+ onChange: (next) => {
3530
+ setData(next);
3531
+ setDirty(true);
3532
+ setMessage("");
3533
+ },
3534
+ onUploadMedia,
3535
+ pages
3536
+ }
3537
+ ),
3538
+ /* @__PURE__ */ jsxs11("div", { className: "ost-row", children: [
3539
+ /* @__PURE__ */ jsx11(
3540
+ "button",
3541
+ {
3542
+ className: "ost-btn ost-btn-primary",
3543
+ disabled: !dirty,
3544
+ onClick: async () => {
3545
+ await api.updateGlobal(activeKey, data);
3546
+ setDirty(false);
3547
+ setMessage("Saved \u2014 live on the site.");
3548
+ setHistory(null);
3549
+ },
3550
+ type: "button",
3551
+ children: "Save"
3552
+ }
3553
+ ),
3554
+ /* @__PURE__ */ jsx11("button", { className: "ost-btn", onClick: toggleHistory, type: "button", children: history ? "Hide history" : "History" }),
3555
+ message ? /* @__PURE__ */ jsx11("span", { className: "ost-muted", children: message }) : null
3556
+ ] }),
3557
+ history ? /* @__PURE__ */ jsxs11("div", { className: "ost-versions ost-global-versions", children: [
3558
+ history.length === 0 ? /* @__PURE__ */ jsx11("span", { className: "ost-muted", children: "No saved versions yet." }) : null,
3559
+ history.map((version) => /* @__PURE__ */ jsxs11("div", { className: "ost-version-row", children: [
3560
+ /* @__PURE__ */ jsx11("span", { className: "ost-muted", children: new Date(version.created_at).toLocaleString() }),
3561
+ /* @__PURE__ */ jsx11(
3562
+ "button",
3563
+ {
3564
+ className: "ost-btn",
3565
+ onClick: async () => {
3566
+ const { global } = await api.restoreGlobalVersion(version.id);
3567
+ setData(globals.normalize(activeKey, global.data));
3568
+ setDirty(false);
3569
+ setHistory(null);
3570
+ setMessage("Version restored \u2014 live on the site.");
3571
+ },
3572
+ type: "button",
3573
+ children: "Restore"
3574
+ }
3575
+ )
3576
+ ] }, version.id))
3577
+ ] }) : null
3578
+ ] }) : /* @__PURE__ */ jsx11("div", { className: "ost-loading", children: "Loading\u2026" }) })
3579
+ ] })
3580
+ ] });
3581
+ }
3582
+
3583
+ // src/studio/Studio.tsx
3584
+ import { jsx as jsx12, jsxs as jsxs12 } from "react/jsx-runtime";
3585
+ var NAV = [
3586
+ { key: "dashboard", label: "Dashboard" },
3587
+ { key: "pages", label: "Pages" },
3588
+ { key: "media", label: "Media" },
3589
+ { key: "forms", label: "Forms" },
3590
+ { key: "submissions", label: "Submissions" },
3591
+ { key: "analytics", label: "Analytics" },
3592
+ { key: "globals", label: "Globals" },
3593
+ { key: "redirects", label: "Redirects", minRole: "editor" },
3594
+ { key: "users", label: "Users", minRole: "admin" }
3595
+ ];
3596
+ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3597
+ const { session, loading, getToken, signOut } = useStudioSession();
3598
+ const api = useMemo5(() => createStudioApi({ getToken }), [getToken]);
3599
+ useEffect11(() => {
3600
+ try {
3601
+ localStorage.setItem("orion-analytics-exclude", "1");
3602
+ } catch {
3603
+ }
3604
+ }, []);
3605
+ const [role, setRole] = useState12(null);
3606
+ const [roleError, setRoleError] = useState12("");
3607
+ const [section, setSection] = useState12("dashboard");
3608
+ const [openPageId, setOpenPageId] = useState12(null);
3609
+ const [submissionsFormId, setSubmissionsFormId] = useState12(null);
3610
+ const [media, setMedia] = useState12([]);
3611
+ const [pages, setPages] = useState12([]);
3612
+ const [unreadCount, setUnreadCount] = useState12(0);
3613
+ const baseUrl = supabaseUrl || process.env.NEXT_PUBLIC_SUPABASE_URL || "";
3614
+ const mediaUrl = useCallback5(
3615
+ (storagePath, options) => {
3616
+ if (storagePath.startsWith("data:") || storagePath.startsWith("/")) return storagePath;
3617
+ const base = `${baseUrl}/storage/v1`;
3618
+ if (!options?.width) return `${base}/object/public/media/${storagePath}`;
3619
+ return `${base}/render/image/public/media/${storagePath}?width=${options.width}&quality=80`;
3620
+ },
3621
+ [baseUrl]
3622
+ );
3623
+ const refreshMedia = useCallback5(() => {
3624
+ api.listMedia().then(({ media: list }) => setMedia(list), () => void 0);
3625
+ }, [api]);
3626
+ const refreshUnread = useCallback5(() => {
3627
+ api.listForms().then(
3628
+ ({ forms }) => setUnreadCount(forms.reduce((sum, form) => sum + (form.unreadCount ?? 0), 0)),
3629
+ () => void 0
3630
+ );
3631
+ }, [api]);
3632
+ const refreshPages = useCallback5(() => {
3633
+ api.listPages().then(
3634
+ ({ pages: list }) => setPages(list.map((page) => ({ path: page.path, title: page.title }))),
3635
+ () => void 0
3636
+ );
3637
+ }, [api]);
3638
+ useEffect11(() => {
3639
+ if (!session) {
3640
+ setRole(null);
3641
+ return;
3642
+ }
3643
+ api.me().then(
3644
+ ({ user }) => {
3645
+ setRole(user.role);
3646
+ refreshMedia();
3647
+ refreshUnread();
3648
+ refreshPages();
3649
+ },
3650
+ () => setRoleError("Your account has no Studio access on this site.")
3651
+ );
3652
+ }, [api, session, refreshMedia, refreshUnread, refreshPages]);
3653
+ const uploadMedia = useCallback5(
3654
+ async (file) => {
3655
+ const dimensions = file.type.startsWith("image/") ? await new Promise((resolve) => {
3656
+ const url = URL.createObjectURL(file);
3657
+ const image = new Image();
3658
+ image.onload = () => {
3659
+ URL.revokeObjectURL(url);
3660
+ resolve({ width: image.naturalWidth, height: image.naturalHeight });
3661
+ };
3662
+ image.onerror = () => {
3663
+ URL.revokeObjectURL(url);
3664
+ resolve(null);
3665
+ };
3666
+ image.src = url;
3667
+ }) : null;
3668
+ const form = new FormData();
3669
+ form.set("file", file);
3670
+ form.set("alt", file.name.replace(/\.[a-z0-9]+$/i, "").replace(/[-_]+/g, " "));
3671
+ if (dimensions) {
3672
+ form.set("width", String(dimensions.width));
3673
+ form.set("height", String(dimensions.height));
3674
+ }
3675
+ try {
3676
+ const { media: uploaded } = await api.uploadMedia(form);
3677
+ refreshMedia();
3678
+ return uploaded;
3679
+ } catch {
3680
+ return null;
3681
+ }
3682
+ },
3683
+ [api, refreshMedia]
3684
+ );
3685
+ if (loading) return /* @__PURE__ */ jsx12("div", { className: "ost-root ost-loading", children: "Loading Studio\u2026" });
3686
+ if (!session) return /* @__PURE__ */ jsx12(LoginView, { logoUrl, siteName });
3687
+ if (roleError) {
3688
+ return /* @__PURE__ */ jsx12("div", { className: "ost-root ost-login", children: /* @__PURE__ */ jsxs12("div", { className: "ost-login-card", children: [
3689
+ /* @__PURE__ */ jsx12("h1", { children: siteName }),
3690
+ /* @__PURE__ */ jsx12("div", { className: "ost-error", children: roleError }),
3691
+ /* @__PURE__ */ jsx12("button", { className: "ost-btn", onClick: () => signOut(), type: "button", children: "Sign out" })
3692
+ ] }) });
3693
+ }
3694
+ if (!role) return /* @__PURE__ */ jsx12("div", { className: "ost-root ost-loading", children: "Loading Studio\u2026" });
3695
+ const canChangeStructure = role !== "content";
3696
+ const canPublish = role !== "content";
3697
+ const canCreatePages = role !== "content";
3698
+ const canDeleteMedia = role !== "content";
3699
+ const canWriteForms = role !== "content";
3700
+ const canManageSubmissions = role !== "content";
3701
+ const canManageUsers = role === "admin" || role === "developer";
3702
+ const canManageRedirects = role !== "content";
3703
+ const canDeleteForms = role === "admin" || role === "developer";
3704
+ const nav = NAV.filter((item) => {
3705
+ if (item.minRole === "admin") return canManageUsers;
3706
+ if (item.minRole === "editor") return role !== "content";
3707
+ return true;
3708
+ });
3709
+ const goTo = (key) => {
3710
+ setSection(key);
3711
+ setOpenPageId(null);
3712
+ if (key !== "submissions") setSubmissionsFormId(null);
3713
+ if (key === "submissions" || key === "dashboard") refreshUnread();
3714
+ if (key === "pages" || key === "dashboard") refreshPages();
3715
+ };
3716
+ const openPage = (id) => {
3717
+ setOpenPageId(id);
3718
+ };
3719
+ return /* @__PURE__ */ jsxs12("div", { className: "ost-root", children: [
3720
+ /* @__PURE__ */ jsxs12("aside", { className: "ost-nav", children: [
3721
+ /* @__PURE__ */ jsxs12("div", { className: "ost-brand", children: [
3722
+ logoUrl ? /* @__PURE__ */ jsx12("img", { alt: "", src: logoUrl }) : null,
3723
+ /* @__PURE__ */ jsxs12("div", { children: [
3724
+ /* @__PURE__ */ jsx12("strong", { children: siteName }),
3725
+ /* @__PURE__ */ jsx12("span", { className: "ost-muted", children: "Studio" })
3726
+ ] })
3727
+ ] }),
3728
+ nav.map((item) => /* @__PURE__ */ jsxs12(
3729
+ "button",
3730
+ {
3731
+ className: `ost-nav-item${section === item.key && !openPageId ? " is-active" : ""}`,
3732
+ onClick: () => goTo(item.key),
3733
+ type: "button",
3734
+ children: [
3735
+ item.label,
3736
+ item.key === "submissions" && unreadCount > 0 ? /* @__PURE__ */ jsx12("span", { className: "ost-nav-badge", children: unreadCount }) : null
3737
+ ]
3738
+ },
3739
+ item.key
3740
+ )),
3741
+ /* @__PURE__ */ jsxs12("div", { className: "ost-nav-footer", children: [
3742
+ /* @__PURE__ */ jsx12("span", { className: "ost-muted", children: session.user.email }),
3743
+ /* @__PURE__ */ jsx12("span", { className: "ost-pill", children: role }),
3744
+ /* @__PURE__ */ jsx12("button", { className: "ost-btn", onClick: () => signOut(), type: "button", children: "Log out" })
3745
+ ] })
3746
+ ] }),
3747
+ /* @__PURE__ */ jsx12("main", { className: "ost-main", children: openPageId ? /* @__PURE__ */ jsx12(
3748
+ PageEditor,
3749
+ {
3750
+ api,
3751
+ canChangeStructure,
3752
+ canPublish,
3753
+ media,
3754
+ mediaUrl,
3755
+ onBack: () => {
3756
+ setOpenPageId(null);
3757
+ refreshPages();
3758
+ },
3759
+ onOpenPage: openPage,
3760
+ onUploadMedia: uploadMedia,
3761
+ pageId: openPageId,
3762
+ registry,
3763
+ role
3764
+ },
3765
+ openPageId
3766
+ ) : section === "pages" ? /* @__PURE__ */ jsx12(PagesView, { api, canCreate: canCreatePages, onOpen: openPage }) : section === "media" ? /* @__PURE__ */ jsx12(
3767
+ MediaView,
3768
+ {
3769
+ api,
3770
+ canDelete: canDeleteMedia,
3771
+ media,
3772
+ mediaUrl,
3773
+ onChanged: refreshMedia,
3774
+ onUpload: uploadMedia
3775
+ }
3776
+ ) : section === "forms" ? /* @__PURE__ */ jsx12(
3777
+ FormsView,
3778
+ {
3779
+ api,
3780
+ canDelete: canDeleteForms,
3781
+ canWrite: canWriteForms,
3782
+ onOpenSubmissions: (formId) => {
3783
+ setSubmissionsFormId(formId);
3784
+ setSection("submissions");
3785
+ }
3786
+ }
3787
+ ) : section === "submissions" ? /* @__PURE__ */ jsx12(
3788
+ SubmissionsView,
3789
+ {
3790
+ api,
3791
+ canManage: canManageSubmissions,
3792
+ initialFormId: submissionsFormId,
3793
+ onUnreadChanged: refreshUnread
3794
+ },
3795
+ submissionsFormId || "all"
3796
+ ) : section === "analytics" ? /* @__PURE__ */ jsx12(AnalyticsView, { api }) : section === "globals" ? /* @__PURE__ */ jsx12(
3797
+ GlobalsView,
3798
+ {
3799
+ api,
3800
+ globals,
3801
+ media,
3802
+ mediaUrl,
3803
+ onUploadMedia: uploadMedia,
3804
+ pages
3805
+ }
3806
+ ) : section === "redirects" && canManageRedirects ? /* @__PURE__ */ jsx12(RedirectsView, { api }) : section === "users" && canManageUsers ? /* @__PURE__ */ jsx12(UsersView, { api, meId: session.user.id }) : /* @__PURE__ */ jsx12(
3807
+ DashboardView,
3808
+ {
3809
+ api,
3810
+ onOpenPage: openPage,
3811
+ onOpenSubmissions: () => goTo("submissions"),
3812
+ siteName
3813
+ }
3814
+ ) })
3815
+ ] });
3816
+ }
3817
+ export {
3818
+ PasswordInput,
3819
+ Studio,
3820
+ StudioApiError,
3821
+ createStudioApi,
3822
+ getBrowserSupabase,
3823
+ useStudioSession
3824
+ };