@acms-subir/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -369
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +182 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +27 -5
  19. package/src/actions/index.ts +575 -47
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +418 -46
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +114 -57
  30. package/src/components/NavManager.tsx +42 -43
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +605 -223
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/events.tsx +153 -0
  45. package/src/components/editor/extensions/slash.tsx +7 -1
  46. package/src/components/site/Pager.astro +24 -0
  47. package/src/components/site/PostCard.astro +64 -0
  48. package/src/components/site/TagLabel.astro +11 -0
  49. package/src/layouts/Admin.astro +289 -49
  50. package/src/layouts/Site.astro +324 -59
  51. package/src/lib/agent-api.ts +13 -0
  52. package/src/lib/agent-discovery.ts +40 -0
  53. package/src/lib/agent.ts +114 -0
  54. package/src/lib/ai-config.ts +58 -0
  55. package/src/lib/ai-schema.ts +22 -0
  56. package/src/lib/ai.ts +90 -0
  57. package/src/lib/api-keys.ts +65 -0
  58. package/src/lib/api.ts +52 -0
  59. package/src/lib/auth.ts +25 -5
  60. package/src/lib/backup/content.ts +36 -0
  61. package/src/lib/backup/unzip.ts +12 -1
  62. package/src/lib/backup/zip.ts +1 -1
  63. package/src/lib/booking/agent.ts +108 -0
  64. package/src/lib/booking/availability.ts +221 -0
  65. package/src/lib/booking/emails.ts +115 -0
  66. package/src/lib/booking/forms.ts +77 -0
  67. package/src/lib/booking/service.ts +277 -0
  68. package/src/lib/booking/view.ts +54 -0
  69. package/src/lib/calendar/agent-schema.ts +48 -0
  70. package/src/lib/calendar/agent.ts +136 -0
  71. package/src/lib/calendar/api.ts +81 -0
  72. package/src/lib/calendar/cards.ts +102 -0
  73. package/src/lib/calendar/config.ts +52 -0
  74. package/src/lib/calendar/data.ts +130 -0
  75. package/src/lib/calendar/events.ts +261 -0
  76. package/src/lib/calendar/feeds.ts +43 -0
  77. package/src/lib/calendar/hooks.ts +38 -0
  78. package/src/lib/calendar/http.ts +30 -0
  79. package/src/lib/calendar/ics-write.ts +120 -0
  80. package/src/lib/calendar/ics.ts +15 -5
  81. package/src/lib/calendar/import.ts +203 -0
  82. package/src/lib/calendar/render.ts +120 -37
  83. package/src/lib/calendar/rrule.ts +149 -0
  84. package/src/lib/calendar/seo.ts +48 -0
  85. package/src/lib/design-service.ts +58 -0
  86. package/src/lib/designDiff.ts +145 -0
  87. package/src/lib/designPreview.ts +110 -12
  88. package/src/lib/documents/cleanup.ts +31 -0
  89. package/src/lib/documents/extract.ts +154 -0
  90. package/src/lib/documents/vendor.d.ts +4 -0
  91. package/src/lib/drafts.ts +60 -6
  92. package/src/lib/dynamicBlocks.ts +40 -8
  93. package/src/lib/email/accounts.ts +55 -0
  94. package/src/lib/email/config.ts +86 -0
  95. package/src/lib/email/migration.ts +24 -0
  96. package/src/lib/email/providers.ts +89 -0
  97. package/src/lib/email/service.ts +78 -0
  98. package/src/lib/email/store.ts +167 -0
  99. package/src/lib/email/templates.ts +18 -0
  100. package/src/lib/email/types.ts +32 -0
  101. package/src/lib/email/webhooks.ts +87 -0
  102. package/src/lib/email.ts +15 -0
  103. package/src/lib/env.ts +1 -1
  104. package/src/lib/legal/index.ts +25 -0
  105. package/src/lib/mobile/bookings.ts +73 -0
  106. package/src/lib/mobile/calendar.ts +85 -0
  107. package/src/lib/mobile/content.ts +85 -0
  108. package/src/lib/mobile/http.ts +63 -0
  109. package/src/lib/mobile/route.ts +20 -0
  110. package/src/lib/ops.ts +8 -0
  111. package/src/lib/preview.ts +21 -1
  112. package/src/lib/repo/migrations.ts +217 -0
  113. package/src/lib/repo/sql.ts +853 -60
  114. package/src/lib/repo/types.ts +122 -5
  115. package/src/lib/scheduler.ts +25 -0
  116. package/src/lib/schema.ts +309 -0
  117. package/src/lib/search-i18n.ts +3 -0
  118. package/src/lib/socialIcons.ts +32 -0
  119. package/src/lib/tagColor.ts +11 -0
  120. package/src/lib/theme.ts +59 -40
  121. package/src/lib/themeTokens.ts +174 -0
  122. package/src/lib/umami.ts +108 -0
  123. package/src/lib/webhooks.ts +139 -0
  124. package/src/middleware.ts +36 -3
  125. package/src/pages/404.astro +23 -0
  126. package/src/pages/[...slug].astro +8 -5
  127. package/src/pages/account/[action].astro +53 -0
  128. package/src/pages/admin/ai.astro +12 -0
  129. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  130. package/src/pages/admin/c/[collection]/index.astro +127 -92
  131. package/src/pages/admin/calendar/bookings.astro +400 -0
  132. package/src/pages/admin/calendar/calendars.astro +265 -0
  133. package/src/pages/admin/calendar/events/[id].astro +56 -0
  134. package/src/pages/admin/calendar/events/index.astro +408 -0
  135. package/src/pages/admin/calendar/index.ts +4 -0
  136. package/src/pages/admin/calendar/integrations.astro +221 -0
  137. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  138. package/src/pages/admin/calendar/resources/index.astro +106 -0
  139. package/src/pages/admin/calendar/resources/link.astro +196 -0
  140. package/src/pages/admin/design.astro +10 -3
  141. package/src/pages/admin/email.astro +13 -0
  142. package/src/pages/admin/index.astro +232 -85
  143. package/src/pages/admin/links.astro +3 -3
  144. package/src/pages/admin/login.astro +14 -10
  145. package/src/pages/admin/navigation.astro +8 -2
  146. package/src/pages/admin/pages/[id].astro +4 -1
  147. package/src/pages/admin/pages/index.astro +41 -48
  148. package/src/pages/admin/settings.astro +418 -367
  149. package/src/pages/admin/tags.astro +4 -4
  150. package/src/pages/admin/users.astro +38 -28
  151. package/src/pages/api/agent/docs.ts +33 -0
  152. package/src/pages/api/agent/index.ts +21 -0
  153. package/src/pages/api/ai/clean.ts +13 -0
  154. package/src/pages/api/ai/draft.ts +14 -0
  155. package/src/pages/api/ai/settings.ts +53 -0
  156. package/src/pages/api/ai/test.ts +12 -0
  157. package/src/pages/api/backup.ts +8 -21
  158. package/src/pages/api/calendar/availability.ts +35 -0
  159. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  160. package/src/pages/api/calendar/bookings/index.ts +73 -0
  161. package/src/pages/api/calendar/calendars.ts +35 -0
  162. package/src/pages/api/calendar/docs.ts +64 -0
  163. package/src/pages/api/calendar/events/[id].ts +63 -0
  164. package/src/pages/api/calendar/events/index.ts +112 -0
  165. package/src/pages/api/calendar/resources.ts +34 -0
  166. package/src/pages/api/design/capabilities.ts +10 -0
  167. package/src/pages/api/design/index.ts +58 -0
  168. package/src/pages/api/design/keys.ts +24 -0
  169. package/src/pages/api/design/media.ts +32 -0
  170. package/src/pages/api/email/run.ts +11 -0
  171. package/src/pages/api/email/settings.ts +61 -0
  172. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  173. package/src/pages/api/mcp.ts +59 -0
  174. package/src/pages/api/media/upload.ts +2 -2
  175. package/src/pages/api/ops/status.ts +39 -0
  176. package/src/pages/api/restore.ts +27 -37
  177. package/src/pages/api/search.ts +18 -4
  178. package/src/pages/api/v1/availability.ts +7 -0
  179. package/src/pages/api/v1/bookings.ts +17 -0
  180. package/src/pages/api/v1/calendars.ts +7 -0
  181. package/src/pages/api/v1/content/[id].ts +7 -0
  182. package/src/pages/api/v1/content/index.ts +7 -0
  183. package/src/pages/api/v1/events.ts +7 -0
  184. package/src/pages/api/v1/resources.ts +7 -0
  185. package/src/pages/author/[slug].astro +43 -35
  186. package/src/pages/blog/[slug].astro +137 -66
  187. package/src/pages/blog/index.astro +95 -30
  188. package/src/pages/blog/tag/[slug].astro +45 -40
  189. package/src/pages/book/[resource]/[link].astro +70 -0
  190. package/src/pages/book/[resource]/index.astro +63 -0
  191. package/src/pages/book/[resource].ics.ts +38 -0
  192. package/src/pages/book/index.astro +47 -0
  193. package/src/pages/book/manage/[token].astro +108 -0
  194. package/src/pages/calendar/[slug].ics.ts +27 -0
  195. package/src/pages/calendar/feed.ics.ts +15 -0
  196. package/src/pages/calendar/index.astro +191 -0
  197. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  198. package/src/pages/embed/calendar/[slug].astro +43 -0
  199. package/src/pages/events/[slug].astro +74 -0
  200. package/src/pages/events/[slug].ics.ts +25 -0
  201. package/src/pages/index.astro +6 -3
  202. package/src/pages/legal/privacy.astro +4 -0
  203. package/src/pages/legal/terms.astro +4 -0
  204. package/src/pages/links.astro +23 -3
  205. package/src/pages/llms.txt.ts +4 -0
  206. package/src/pages/preview/[type]/[id].astro +27 -6
  207. package/src/pages/search.astro +40 -20
  208. package/src/pages/sitemap.xml.ts +16 -1
  209. package/src/styles/admin.css +924 -105
  210. package/src/styles/cards.css +92 -74
  211. package/src/styles/editor.css +492 -114
  212. package/src/styles/email-settings.css +117 -0
  213. package/src/styles/sections.css +12 -0
  214. package/src/styles/site.css +938 -281
  215. package/src/styles/theme-editor.css +135 -0
  216. package/src/theme-sdk/contract.d.mts +619 -0
  217. package/src/theme-sdk/contract.mjs +80 -0
  218. package/src/theme-sdk/index.ts +15 -0
  219. package/src/theme-sdk/tokens.d.mts +148 -0
  220. package/src/theme-sdk/tokens.mjs +52 -0
  221. package/src/themes/editorial/templates/home.astro +4 -4
  222. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  223. package/src/themes/editorial/theme.css +22 -37
  224. package/src/themes/editorial/theme.json +23 -3
  225. package/src/themes/field/theme.json +26 -9
  226. package/src/themes/ink/theme.json +30 -12
  227. package/src/themes/night/theme.css +4 -7
  228. package/src/themes/night/theme.json +27 -10
  229. package/src/virtual.d.ts +14 -0
  230. package/src/worker.ts +5 -1
@@ -0,0 +1,57 @@
1
+ ---
2
+ import type { Page, Settings } from '../lib/schema';
3
+ import { getTheme } from '../lib/theme';
4
+ import { getRepo } from '../lib/repo';
5
+ import { CONTENT, collectionUrl } from '../lib/content';
6
+ import { sectionIssues } from '../theme-sdk/contract.mjs';
7
+ import WidgetRenderer from './WidgetRenderer.astro';
8
+ import { expandDynamicBlocks } from '../lib/dynamicBlocks';
9
+ import { highlightCodeHtml } from '../lib/highlightCode';
10
+ import PostCard from './site/PostCard.astro';
11
+ import '../styles/sections.css';
12
+ interface Props { page: Page; settings: Settings }
13
+ const { page, settings } = Astro.props;
14
+ const theme = getTheme(settings.activeTheme);
15
+ const layout = settings.sectionPages[page.id];
16
+ const repo = await getRepo(Astro.locals);
17
+ const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
18
+ const safeUrl = (value: unknown) => {
19
+ const s = String(value ?? '').trim();
20
+ return /^(https?:\/\/|mailto:|tel:|\/(?!\/)|#)/i.test(s) ? s : '';
21
+ };
22
+ const sections = await Promise.all(layout.sections.map(async (section) => {
23
+ const definition = theme.sections[section.type];
24
+ const invalid = sectionIssues({ mode: 'sections', sections: [section] }, theme.sections).length > 0;
25
+ const data = Object.fromEntries(Object.entries(definition?.fields ?? {}).map(([key, field]) => [key, section.data[key] ?? (field as {default?: unknown}).default ?? '']));
26
+ const collection = String(data.collection || 'posts');
27
+ const posts = section.type === 'posts' && !section.hidden && !invalid && CONTENT.collections[collection]
28
+ ? (await repo.listPublishedPosts(collection)).slice(0, Math.max(1, Math.min(12, Number(data.limit) || 3))) : [];
29
+ return { ...section, data, invalid, posts, Template: theme.sectionTemplates[section.type] as any };
30
+ }));
31
+ ---
32
+ <div class="acms-sections">
33
+ {sections.filter(s => !s.hidden && !s.invalid).map(({ id, type, variant, data, Template, posts }) => (
34
+ <section id={`section-${id}`} class:list={['acms-section', `section-${type}`, `variant-${variant}`]}>
35
+ {type === 'page-content' ? (page.bodyHtml ? <article class="page-body" set:html={bodyHtml} /> : <WidgetRenderer pageId={page.id} blocks={page.blocks} />) : Template ? <Template data={data} variant={variant} settings={settings} page={page} /> : (
36
+ <>
37
+ <div class="section-copy">
38
+ {data.heading && <h2>{String(data.heading)}</h2>}
39
+ {data.text && <p class="section-text">{String(data.text)}</p>}
40
+ {data.label && safeUrl(data.href) && <a class="w-btn primary" href={safeUrl(data.href)}>{String(data.label)}</a>}
41
+ </div>
42
+ {type === 'hero' && safeUrl(data.image) && <img class="section-image" src={safeUrl(data.image)} alt={String(data.alt ?? '')} loading="eager" />}
43
+ {['features', 'testimonials', 'faq'].includes(type) && (
44
+ <div class="section-items">
45
+ {String(data.items ?? '').split('\n').filter(Boolean).map((row) => {
46
+ const [title, ...body] = row.split('|');
47
+ return type === 'faq' ? <details><summary>{title}</summary><p>{body.join('|')}</p></details>
48
+ : <article><h3>{title}</h3><p>{body.join('|')}</p></article>;
49
+ })}
50
+ </div>
51
+ )}
52
+ {type === 'posts' && <div class="section-items">{posts.map(post => <PostCard variant={variant === 'list' ? 'row' : 'card'} href={collectionUrl(post.collection, post.slug)} title={post.title} excerpt={post.excerpt} cover={post.cover} coverAlt={post.coverAlt} />)}</div>}
53
+ </>
54
+ )}
55
+ </section>
56
+ ))}
57
+ </div>
@@ -0,0 +1,513 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { ImagePicker } from './editor/ImagePicker';
3
+
4
+ // Pages tab of the Design screen: lay out a page from the active theme's
5
+ // section registry (builtins + the theme's own sections). Edits stay local
6
+ // until "Save section draft" stages them into the design draft through
7
+ // POST /api/design; nothing here publishes, and a page's own content is
8
+ // never touched (switching a page back to "Page content" keeps its layout).
9
+
10
+ type FieldType = 'text' | 'textarea' | 'image' | 'url' | 'select' | 'number';
11
+ interface FieldDef { label: string; type: FieldType; default?: string | number; options?: string[] }
12
+ interface SectionDef { name?: string; label?: string; version?: number; variants?: string[]; fields?: Record<string, FieldDef> }
13
+ type SectionValue = string | number;
14
+ export interface SectionInstance {
15
+ id: string;
16
+ type: string;
17
+ version: number;
18
+ variant: string;
19
+ hidden: boolean;
20
+ data: Record<string, SectionValue>;
21
+ }
22
+ export interface PageLayout { mode: 'sections' | 'content'; sections: SectionInstance[] }
23
+ export type SectionPages = Record<string, PageLayout>;
24
+ interface DesignSide { activeTheme?: string; sectionPages?: SectionPages; [k: string]: unknown }
25
+ export interface DesignApiState {
26
+ revision: number;
27
+ live: DesignSide;
28
+ draft: DesignSide | null;
29
+ changes: { key: string; label: string; from: string; to: string; kind: string }[];
30
+ themes: { slug: string; name: string; version: string | number; sections: Record<string, SectionDef> }[];
31
+ pages: { id: string; title: string; slug: string; isHome: boolean }[];
32
+ versions: { id: string; createdAt: number; author: string; summary: string }[];
33
+ scopes: string[];
34
+ }
35
+
36
+ const errorText = async (res: Response, fallback: string) => {
37
+ try {
38
+ const body = (await res.json()) as { error?: unknown };
39
+ if (typeof body?.error === 'string' && body.error) return body.error;
40
+ } catch { /* not JSON */ }
41
+ return fallback;
42
+ };
43
+
44
+ /** GET /api/design, shared by the Pages editor and the Review tab */
45
+ export function useDesignApi() {
46
+ const [data, setData] = useState<DesignApiState | null>(null);
47
+ const [error, setError] = useState('');
48
+ const [loading, setLoading] = useState(true);
49
+ const load = useCallback(async () => {
50
+ setLoading(true);
51
+ setError('');
52
+ try {
53
+ const res = await fetch('/api/design', { headers: { accept: 'application/json' } });
54
+ if (!res.ok) { setError(await errorText(res, `Couldn't load the design (${res.status}).`)); return; }
55
+ setData((await res.json()) as DesignApiState);
56
+ } catch {
57
+ setError("Couldn't reach the server.");
58
+ } finally {
59
+ setLoading(false);
60
+ }
61
+ }, []);
62
+ useEffect(() => { void load(); }, [load]);
63
+ return { data, error, loading, reload: load };
64
+ }
65
+
66
+ const stagedPages = (d: DesignApiState): SectionPages =>
67
+ (d.draft?.sectionPages ?? d.live.sectionPages ?? {}) as SectionPages;
68
+
69
+ const sectionName = (def: SectionDef | undefined, type: string) => def?.name || def?.label || type;
70
+
71
+ const newId = (type: string) => {
72
+ const rand = typeof crypto !== 'undefined' && 'randomUUID' in crypto
73
+ ? crypto.randomUUID().slice(0, 8)
74
+ : Math.random().toString(36).slice(2, 10);
75
+ return `${type}-${rand}`;
76
+ };
77
+
78
+ const defaultsFor = (def: SectionDef): Record<string, SectionValue> => {
79
+ const out: Record<string, SectionValue> = {};
80
+ for (const [key, f] of Object.entries(def.fields ?? {})) {
81
+ if (f.default !== undefined) out[key] = f.default;
82
+ }
83
+ return out;
84
+ };
85
+
86
+ const pagePath = (p: { slug: string; isHome: boolean }) => (p.isHome ? '/' : `/${p.slug}`);
87
+
88
+ interface EditorProps {
89
+ api: ReturnType<typeof useDesignApi>;
90
+ /** theme picked in the Theme tab (may be ahead of the staged draft) */
91
+ activeTheme: string;
92
+ unsplashEnabled: boolean;
93
+ }
94
+
95
+ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled }: EditorProps) {
96
+ const { data, error: loadError, loading, reload } = api;
97
+ const [pages, setPages] = useState<SectionPages>({});
98
+ const [pageId, setPageId] = useState('');
99
+ const [openId, setOpenId] = useState<string | null>(null);
100
+ const [addType, setAddType] = useState('');
101
+ const [saving, setSaving] = useState(false);
102
+ const [saveError, setSaveError] = useState('');
103
+ const [conflict, setConflict] = useState(false);
104
+ const [imageFor, setImageFor] = useState<{ sectionId: string; key: string } | null>(null);
105
+
106
+ // rehydrate local edits whenever a fresh server state arrives
107
+ const base = useMemo(() => (data ? stagedPages(data) : {}), [data]);
108
+ useEffect(() => {
109
+ if (!data) return;
110
+ setPages(structuredClone(base));
111
+ setConflict(false);
112
+ setSaveError('');
113
+ setPageId((cur) => (cur && data.pages.some((p) => p.id === cur) ? cur : (data.pages.find((p) => p.isHome) ?? data.pages[0])?.id ?? ''));
114
+ }, [data, base]);
115
+
116
+ const dirty = useMemo(() => JSON.stringify(pages) !== JSON.stringify(base), [pages, base]);
117
+ const dirtyRef = useRef(false);
118
+ dirtyRef.current = dirty;
119
+ useEffect(() => {
120
+ const warn = (e: BeforeUnloadEvent) => { if (dirtyRef.current) e.preventDefault(); };
121
+ addEventListener('beforeunload', warn);
122
+ return () => removeEventListener('beforeunload', warn);
123
+ }, []);
124
+
125
+ const theme = data?.themes.find((t) => t.slug === activeTheme)
126
+ ?? data?.themes.find((t) => t.slug === (data.draft?.activeTheme ?? data.live.activeTheme))
127
+ ?? data?.themes[0];
128
+ const defs: Record<string, SectionDef> = theme?.sections ?? {};
129
+ const defTypes = Object.keys(defs);
130
+ useEffect(() => {
131
+ if (!defTypes.includes(addType)) setAddType(defTypes[0] ?? '');
132
+ // eslint-disable-next-line react-hooks/exhaustive-deps
133
+ }, [defTypes.join('|')]);
134
+
135
+ if (loading && !data) return <p className="te-muted">Loading page layouts…</p>;
136
+ if (!data) {
137
+ return (
138
+ <div className="notice warn" role="alert">
139
+ <span>{loadError || "Couldn't load page layouts."}</span>
140
+ <button className="btn sm" type="button" onClick={() => void reload()}>Try again</button>
141
+ </div>
142
+ );
143
+ }
144
+ if (data.pages.length === 0) {
145
+ return (
146
+ <div className="empty-state">
147
+ <b>No pages yet</b>
148
+ <p>Create a page first, then give it a section layout here.</p>
149
+ <a className="btn" href="/admin/pages">Go to pages</a>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ const page = data.pages.find((p) => p.id === pageId) ?? data.pages[0];
155
+ const layout: PageLayout | undefined = pages[page.id];
156
+
157
+ const setLayout = (fn: (l: PageLayout) => PageLayout) =>
158
+ setPages((all) => ({ ...all, [page.id]: fn(all[page.id] ?? { mode: 'sections', sections: [] }) }));
159
+ const setSections = (fn: (s: SectionInstance[]) => SectionInstance[]) =>
160
+ setLayout((l) => ({ ...l, sections: fn(l.sections) }));
161
+ const patchSection = (id: string, fn: (s: SectionInstance) => SectionInstance) =>
162
+ setSections((list) => list.map((s) => (s.id === id ? fn(s) : s)));
163
+
164
+ const addSection = () => {
165
+ const def = defs[addType];
166
+ if (!def) return;
167
+ const s: SectionInstance = {
168
+ id: newId(addType),
169
+ type: addType,
170
+ version: def.version ?? 1,
171
+ variant: def.variants?.[0] ?? 'default',
172
+ hidden: false,
173
+ data: defaultsFor(def),
174
+ };
175
+ setLayout((l) => ({ ...l, mode: 'sections', sections: [...l.sections, s] }));
176
+ setOpenId(s.id);
177
+ };
178
+ const move = (i: number, dir: -1 | 1) =>
179
+ setSections((list) => {
180
+ const j = i + dir;
181
+ if (j < 0 || j >= list.length) return list;
182
+ const next = [...list];
183
+ [next[i], next[j]] = [next[j], next[i]];
184
+ return next;
185
+ });
186
+ const duplicate = (i: number) =>
187
+ setSections((list) => {
188
+ const copy = { ...structuredClone(list[i]), id: newId(list[i].type) };
189
+ return [...list.slice(0, i + 1), copy, ...list.slice(i + 1)];
190
+ });
191
+ const remove = (s: SectionInstance) => {
192
+ if (!window.confirm(`Delete the “${sectionName(defs[s.type], s.type)}” section from ${page.title}? This happens when you save the section draft.`)) return;
193
+ setSections((list) => list.filter((x) => x.id !== s.id));
194
+ };
195
+ const setField = (id: string, key: string, value: SectionValue | undefined) =>
196
+ patchSection(id, (s) => {
197
+ const d = { ...s.data };
198
+ if (value === undefined) delete d[key]; else d[key] = value;
199
+ return { ...s, data: d };
200
+ });
201
+
202
+ const save = async () => {
203
+ setSaving(true);
204
+ setSaveError('');
205
+ try {
206
+ const res = await fetch('/api/design', {
207
+ method: 'POST',
208
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
209
+ body: JSON.stringify({ operation: 'stage', revision: data.revision, patch: { sectionPages: { ...base, ...pages } } }),
210
+ });
211
+ if (res.status === 409) { setConflict(true); return; }
212
+ if (!res.ok) { setSaveError(await errorText(res, `Couldn't save the section draft (${res.status}).`)); return; }
213
+ dirtyRef.current = false;
214
+ location.reload(); // refresh the draft status, change list and preview
215
+ } catch {
216
+ setSaveError("Couldn't reach the server. Your edits are still here.");
217
+ } finally {
218
+ setSaving(false);
219
+ }
220
+ };
221
+ const reloadLatest = () => {
222
+ if (dirty && !window.confirm('Load the latest draft? Your unsaved section edits will be lost.')) return;
223
+ void reload();
224
+ };
225
+ const discardEdits = () => {
226
+ if (!window.confirm('Discard your unsaved section edits?')) return;
227
+ setPages(structuredClone(base));
228
+ };
229
+
230
+ const pageDirty = (id: string) => JSON.stringify(pages[id]) !== JSON.stringify(base[id]);
231
+ const editingSection = imageFor ? layout?.sections.find((s) => s.id === imageFor.sectionId) : undefined;
232
+
233
+ return (
234
+ <div className="te">
235
+ <p className="te-intro">
236
+ Build a page from your theme's sections. Edits stay on this screen until you save them to the private design
237
+ draft; visitors see them only after <b>Publish</b>. The page's own content is never changed.
238
+ </p>
239
+
240
+ <div className="te-pagebar">
241
+ <label className="field te-pagepick">
242
+ <span>Page</span>
243
+ <select aria-label="Page" value={page.id} onChange={(e) => { setPageId(e.target.value); setOpenId(null); }}>
244
+ {data.pages.map((p) => (
245
+ <option key={p.id} value={p.id}>
246
+ {p.title || p.slug}{p.isHome ? ' · home' : ''}{pages[p.id]?.mode === 'sections' ? ' · sections' : ''}{pageDirty(p.id) ? ' · edited' : ''}
247
+ </option>
248
+ ))}
249
+ </select>
250
+ </label>
251
+ <a className="btn sm ghost" href={`${pagePath(page)}?designPreview=1`} target="_blank" rel="noopener">
252
+ Preview page ↗
253
+ </a>
254
+ </div>
255
+
256
+ {!layout ? (
257
+ <div className="te-empty">
258
+ <p><b>{page.title || page.slug}</b> shows its page content.</p>
259
+ <p className="te-muted">Start a layout, then add Existing page content to keep your current text or widgets alongside new sections. You can switch back at any time.</p>
260
+ <button className="btn" type="button" onClick={() => setLayout(() => ({ mode: 'sections', sections: [{ id: newId('page-content'), type: 'page-content', version: 1, variant: 'default', hidden: false, data: {} }] }))}>Keep content and add sections</button>
261
+ </div>
262
+ ) : (
263
+ <>
264
+ <div className="te-mode">
265
+ <span className="te-mode-label" id="te-mode-label">Page shows</span>
266
+ <div className="seg" role="radiogroup" aria-labelledby="te-mode-label">
267
+ {(['sections', 'content'] as const).map((m) => (
268
+ <button
269
+ key={m}
270
+ type="button"
271
+ role="radio"
272
+ aria-checked={layout.mode === m}
273
+ className={layout.mode === m ? 'on' : ''}
274
+ onClick={() => setLayout((l) => ({ ...l, mode: m }))}
275
+ >
276
+ {m === 'sections' ? 'Sections' : 'Page content'}
277
+ </button>
278
+ ))}
279
+ </div>
280
+ </div>
281
+ {layout.mode === 'content' && (
282
+ <p className="notice te-note">
283
+ This page shows its own content. The {layout.sections.length} {layout.sections.length === 1 ? 'section is' : 'sections are'} kept, so you can switch back later.
284
+ </p>
285
+ )}
286
+
287
+ <ol className={`te-list ${layout.mode === 'content' ? 'is-inactive' : ''}`} aria-label={`Sections on ${page.title || page.slug}`}>
288
+ {layout.sections.length === 0 && <li className="te-muted te-none">No sections yet. Add one below.</li>}
289
+ {layout.sections.map((s, i) => {
290
+ const def = defs[s.type];
291
+ const name = sectionName(def, s.type);
292
+ const open = openId === s.id;
293
+ const panelId = `te-sec-${s.id}`;
294
+ return (
295
+ <li key={s.id} className={`te-sec ${s.hidden ? 'is-hidden' : ''} ${open ? 'is-open' : ''}`}>
296
+ <div className="te-sec-head">
297
+ <button
298
+ type="button"
299
+ className="te-sec-toggle"
300
+ aria-expanded={open}
301
+ aria-controls={panelId}
302
+ onClick={() => setOpenId(open ? null : s.id)}
303
+ >
304
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
305
+ <span className="te-sec-name">{name}</span>
306
+ <span className="te-sec-meta">
307
+ {s.variant}
308
+ {s.hidden && ' · hidden'}
309
+ {!def && ' · unavailable'}
310
+ </span>
311
+ </button>
312
+ <div className="te-sec-actions">
313
+ <button className="icon-btn" type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label={`Move ${name} up`} title="Move up">
314
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 15 6-6 6 6" /></svg>
315
+ </button>
316
+ <button className="icon-btn" type="button" onClick={() => move(i, 1)} disabled={i === layout.sections.length - 1} aria-label={`Move ${name} down`} title="Move down">
317
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
318
+ </button>
319
+ </div>
320
+ </div>
321
+
322
+ {open && (
323
+ <div className="te-sec-body" id={panelId}>
324
+ {!def ? (
325
+ <p className="notice warn te-note">
326
+ {theme?.name ?? 'This theme'} has no “{s.type}” section, so it won't render. Its {Object.keys(s.data).length} saved {Object.keys(s.data).length === 1 ? 'value is' : 'values are'} preserved and come back if you switch to a theme that has it.
327
+ </p>
328
+ ) : (
329
+ <>
330
+ {def.version !== undefined && s.version !== def.version && (
331
+ <p className="notice warn te-note">Saved with version {s.version} of this section; the theme ships version {def.version}.</p>
332
+ )}
333
+ {s.type === 'page-content' && <p className="te-muted">This section uses the page's published text and widgets. Edit them in the page editor; any pending content draft stays separate.</p>}
334
+ {(def.variants?.length ?? 0) > 0 && (
335
+ <label className="field">
336
+ <span>Variant</span>
337
+ <select value={s.variant} onChange={(e) => patchSection(s.id, (x) => ({ ...x, variant: e.target.value }))}>
338
+ {!def.variants!.includes(s.variant) && <option value={s.variant}>{s.variant} (not in this theme)</option>}
339
+ {def.variants!.map((v) => <option key={v} value={v}>{v}</option>)}
340
+ </select>
341
+ </label>
342
+ )}
343
+ {Object.entries(def.fields ?? {}).map(([key, f]) => (
344
+ <FieldInput
345
+ key={key}
346
+ id={`${panelId}-${key}`}
347
+ field={f}
348
+ value={s.data[key]}
349
+ onChange={(v) => setField(s.id, key, v)}
350
+ onPickImage={() => setImageFor({ sectionId: s.id, key })}
351
+ />
352
+ ))}
353
+ </>
354
+ )}
355
+ <div className="te-sec-foot">
356
+ <button className="btn sm" type="button" aria-pressed={s.hidden} onClick={() => patchSection(s.id, (x) => ({ ...x, hidden: !x.hidden }))}>
357
+ {s.hidden ? 'Show section' : 'Hide section'}
358
+ </button>
359
+ <button className="btn sm" type="button" onClick={() => duplicate(i)}>Duplicate</button>
360
+ <button className="btn sm ghost danger te-delete" type="button" onClick={() => remove(s)}>Delete section</button>
361
+ </div>
362
+ </div>
363
+ )}
364
+ </li>
365
+ );
366
+ })}
367
+ </ol>
368
+
369
+ <div className="te-add">
370
+ <label className="field">
371
+ <span>Add a section</span>
372
+ <select aria-label="Add a section" value={addType} onChange={(e) => setAddType(e.target.value)} disabled={defTypes.length === 0}>
373
+ {defTypes.length === 0 && <option value="">This theme has no sections</option>}
374
+ {defTypes.map((t) => <option key={t} value={t}>{sectionName(defs[t], t)}</option>)}
375
+ </select>
376
+ </label>
377
+ <button className="btn" type="button" onClick={addSection} disabled={!defs[addType]}>Add</button>
378
+ </div>
379
+ </>
380
+ )}
381
+
382
+ {conflict && (
383
+ <div className="notice warn te-alert" role="alert">
384
+ <span>The design draft changed somewhere else since this screen loaded. Your edits are still here and weren't saved.</span>
385
+ <button className="btn sm" type="button" onClick={reloadLatest}>Load latest draft</button>
386
+ </div>
387
+ )}
388
+ {saveError && <p className="flash error te-alert" role="alert">{saveError}</p>}
389
+
390
+ <div className="te-savebar">
391
+ <span className={`te-savestate ${dirty ? 'is-dirty' : ''}`} aria-live="polite">
392
+ {dirty ? 'Unsaved section edits' : 'No unsaved section edits'}
393
+ </span>
394
+ {dirty && <button className="btn sm ghost" type="button" onClick={discardEdits} disabled={saving}>Discard edits</button>}
395
+ <button className="btn sm" type="button" onClick={() => void save()} disabled={!dirty || saving || conflict}>
396
+ {saving ? 'Saving…' : 'Save section draft'}
397
+ </button>
398
+ </div>
399
+
400
+ <ImagePicker
401
+ open={Boolean(imageFor && editingSection)}
402
+ unsplashEnabled={unsplashEnabled}
403
+ onClose={() => setImageFor(null)}
404
+ onSelect={(img) => {
405
+ if (imageFor) setField(imageFor.sectionId, imageFor.key, img.src);
406
+ setImageFor(null);
407
+ }}
408
+ />
409
+ </div>
410
+ );
411
+ }
412
+
413
+ function FieldInput({ id, field, value, onChange, onPickImage }: {
414
+ id: string;
415
+ field: FieldDef;
416
+ value: SectionValue | undefined;
417
+ onChange: (v: SectionValue | undefined) => void;
418
+ onPickImage: () => void;
419
+ }) {
420
+ const shown = value ?? field.default ?? '';
421
+ switch (field.type) {
422
+ case 'textarea':
423
+ return (
424
+ <label className="field" htmlFor={id}>
425
+ <span>{field.label}</span>
426
+ <textarea id={id} rows={4} value={String(shown)} onChange={(e) => onChange(e.target.value)} />
427
+ </label>
428
+ );
429
+ case 'number':
430
+ return (
431
+ <label className="field" htmlFor={id}>
432
+ <span>{field.label}</span>
433
+ <input
434
+ id={id}
435
+ type="number"
436
+ value={shown === '' ? '' : String(shown)}
437
+ onChange={(e) => {
438
+ const n = e.target.valueAsNumber;
439
+ onChange(e.target.value === '' || !Number.isFinite(n) ? undefined : n);
440
+ }}
441
+ />
442
+ </label>
443
+ );
444
+ case 'select': {
445
+ const opts = field.options ?? [];
446
+ return (
447
+ <label className="field" htmlFor={id}>
448
+ <span>{field.label}</span>
449
+ <select id={id} value={String(shown)} onChange={(e) => onChange(e.target.value)}>
450
+ {!opts.includes(String(shown)) && <option value={String(shown)}>{shown === '' ? 'Choose…' : `${shown} (not an option)`}</option>}
451
+ {opts.map((o) => <option key={o} value={o}>{o}</option>)}
452
+ </select>
453
+ </label>
454
+ );
455
+ }
456
+ case 'image':
457
+ return (
458
+ <div className="field te-image">
459
+ <label htmlFor={id}><span>{field.label}</span></label>
460
+ <div className="te-image-row">
461
+ {shown !== '' && <img className="te-thumb" src={String(shown)} alt="" />}
462
+ <input id={id} className="mono" type="url" placeholder="https://" value={String(shown)} onChange={(e) => onChange(e.target.value)} />
463
+ <button className="btn sm" type="button" onClick={onPickImage}>Choose…</button>
464
+ </div>
465
+ </div>
466
+ );
467
+ case 'url':
468
+ return (
469
+ <label className="field" htmlFor={id}>
470
+ <span>{field.label}</span>
471
+ <input id={id} className="mono" type="url" placeholder="https:// or /path" value={String(shown)} onChange={(e) => onChange(e.target.value)} />
472
+ </label>
473
+ );
474
+ default:
475
+ return (
476
+ <label className="field" htmlFor={id}>
477
+ <span>{field.label}</span>
478
+ <input id={id} type="text" value={String(shown)} onChange={(e) => onChange(e.target.value)} />
479
+ </label>
480
+ );
481
+ }
482
+ }
483
+
484
+ /** Review tab: which pages the staged draft re-lays out, vs the live site */
485
+ export function SectionsReview({ api }: { api: ReturnType<typeof useDesignApi> }) {
486
+ const { data, error } = api;
487
+ if (error && !data) return <p className="te-muted">Page layouts couldn't be loaded: {error}</p>;
488
+ if (!data) return <p className="te-muted">Loading page layouts…</p>;
489
+ const live = (data.live.sectionPages ?? {}) as SectionPages;
490
+ const staged = data.draft?.sectionPages as SectionPages | undefined;
491
+ if (!staged) return <p className="te-muted">No page layout changes are staged.</p>;
492
+ const ids = [...new Set([...Object.keys(live), ...Object.keys(staged)])]
493
+ .filter((id) => JSON.stringify(live[id]) !== JSON.stringify(staged[id]));
494
+ if (ids.length === 0) return <p className="te-muted">No page layout changes are staged.</p>;
495
+ return (
496
+ <ul className="te-review">
497
+ {ids.map((id) => {
498
+ const p = data.pages.find((x) => x.id === id);
499
+ const s = staged[id];
500
+ const shown = s ? s.sections.filter((x) => !x.hidden).length : 0;
501
+ return (
502
+ <li key={id}>
503
+ <span className="te-review-page">{p ? p.title || p.slug : 'Deleted page'}{p?.isHome ? ' · home' : ''}</span>
504
+ <span className="te-review-what">
505
+ {!s ? 'Layout removed' : s.mode === 'content' ? 'Shows page content' : `${shown} visible ${shown === 1 ? 'section' : 'sections'}`}
506
+ </span>
507
+ {p && <a className="btn sm ghost" href={`${pagePath(p)}?designPreview=1`} target="_blank" rel="noopener">Preview ↗</a>}
508
+ </li>
509
+ );
510
+ })}
511
+ </ul>
512
+ );
513
+ }