@acms-subir/core 0.3.3-next.20260929113209 → 0.3.3

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 (251) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/sections.mjs +186 -0
  9. package/cli/themes.mjs +52 -8
  10. package/docs/mobile.md +527 -0
  11. package/docs/sections.md +203 -0
  12. package/docs/theme-authoring.md +1 -1
  13. package/integration.mjs +55 -1
  14. package/mobile-template/client/AGENTS.md +97 -0
  15. package/mobile-template/client/README.md +65 -0
  16. package/mobile-template/client/app/_layout.tsx +56 -0
  17. package/mobile-template/client/app/index.tsx +21 -0
  18. package/mobile-template/client/app.json +21 -0
  19. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  20. package/mobile-template/client/eas.json +21 -0
  21. package/mobile-template/client/env.example +3 -0
  22. package/mobile-template/client/gitignore +42 -0
  23. package/mobile-template/client/package.json +38 -0
  24. package/mobile-template/client/sections/index.ts +11 -0
  25. package/mobile-template/client/tsconfig.json +7 -0
  26. package/mobile-template/editor/AGENTS.md +66 -0
  27. package/mobile-template/editor/README.md +73 -0
  28. package/mobile-template/editor/app/_layout.tsx +76 -0
  29. package/mobile-template/editor/app/auth.tsx +13 -0
  30. package/mobile-template/editor/app/index.tsx +55 -0
  31. package/mobile-template/editor/app.json +21 -0
  32. package/mobile-template/editor/components/Screen.tsx +51 -0
  33. package/mobile-template/editor/components/SignIn.tsx +36 -0
  34. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  35. package/mobile-template/editor/eas.json +21 -0
  36. package/mobile-template/editor/env.example +5 -0
  37. package/mobile-template/editor/gitignore +42 -0
  38. package/mobile-template/editor/lib/appearance.tsx +12 -0
  39. package/mobile-template/editor/lib/config.ts +17 -0
  40. package/mobile-template/editor/lib/format.ts +24 -0
  41. package/mobile-template/editor/lib/tools.ts +16 -0
  42. package/mobile-template/editor/package.json +40 -0
  43. package/mobile-template/editor/tools/README.md +2 -0
  44. package/mobile-template/editor/tsconfig.json +7 -0
  45. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  46. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  47. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  48. package/mobile-template/recipes/booking/recipe.json +17 -0
  49. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  50. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  51. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  52. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  54. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  55. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  56. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  57. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  58. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  59. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  60. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  62. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  63. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  64. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  65. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  67. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  68. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  69. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  70. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  72. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  73. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  74. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  75. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  76. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  77. package/mobile-template/recipes/events/recipe.json +15 -0
  78. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  79. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  80. package/mobile-template/recipes/news/recipe.json +13 -0
  81. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  82. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  83. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  84. package/mobile-template/recipes/push/recipe.json +20 -0
  85. package/package.json +4 -2
  86. package/section-library/README.md +16 -0
  87. package/section-library/contact/Contact.astro +49 -0
  88. package/section-library/contact/section.json +106 -0
  89. package/section-library/cta-band/CtaBand.astro +28 -0
  90. package/section-library/cta-band/section.json +51 -0
  91. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  92. package/section-library/feature-grid/section.json +107 -0
  93. package/section-library/gallery/Gallery.astro +29 -0
  94. package/section-library/gallery/section.json +51 -0
  95. package/section-library/hero-banner/HeroBanner.astro +42 -0
  96. package/section-library/hero-banner/section.json +81 -0
  97. package/section-library/latest-posts/LatestPosts.astro +22 -0
  98. package/section-library/latest-posts/section.json +37 -0
  99. package/section-library/pricing/Pricing.astro +40 -0
  100. package/section-library/pricing/section.json +117 -0
  101. package/section-library/questions/Questions.astro +38 -0
  102. package/section-library/questions/section.json +69 -0
  103. package/section-library/service-times/ServiceTimes.astro +42 -0
  104. package/section-library/service-times/section.json +127 -0
  105. package/section-library/team/Team.astro +45 -0
  106. package/section-library/team/section.json +88 -0
  107. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  108. package/section-library/upcoming-events/section.json +51 -0
  109. package/src/actions/index.ts +19 -171
  110. package/src/components/AiSettings.tsx +45 -5
  111. package/src/components/DesignManager.tsx +112 -16
  112. package/src/components/MobileApp.tsx +392 -0
  113. package/src/components/ThemeSections.astro +40 -21
  114. package/src/components/ThemeSectionsEditor.tsx +757 -229
  115. package/src/components/editor/PostEditor.tsx +174 -10
  116. package/src/components/import/ImportExport.tsx +4 -1
  117. package/src/components/import/runner.ts +1 -0
  118. package/src/components/sections/SectionFields.tsx +560 -0
  119. package/src/components/sections/SectionPicker.tsx +120 -0
  120. package/src/components/sections/TranslationPanel.tsx +143 -0
  121. package/src/components/sections/checks.ts +64 -0
  122. package/src/layouts/Admin.astro +6 -1
  123. package/src/layouts/Site.astro +107 -19
  124. package/src/lib/agent-discovery.ts +11 -7
  125. package/src/lib/agent.ts +35 -2
  126. package/src/lib/ai-schema.ts +5 -0
  127. package/src/lib/ai.ts +5 -0
  128. package/src/lib/api.ts +12 -0
  129. package/src/lib/content-service.ts +198 -0
  130. package/src/lib/design-service.ts +153 -5
  131. package/src/lib/drafts.ts +3 -3
  132. package/src/lib/editor/auth.ts +264 -0
  133. package/src/lib/editor/bookings.ts +60 -0
  134. package/src/lib/editor/config.ts +25 -0
  135. package/src/lib/editor/documents.ts +227 -0
  136. package/src/lib/editor/http.ts +48 -0
  137. package/src/lib/editor/me.ts +30 -0
  138. package/src/lib/editor/migration.ts +57 -0
  139. package/src/lib/editor/next.ts +11 -0
  140. package/src/lib/editor/openapi.ts +124 -0
  141. package/src/lib/editor/schemas.ts +91 -0
  142. package/src/lib/editor/sections.ts +96 -0
  143. package/src/lib/editor/store.ts +184 -0
  144. package/src/lib/editor/token-route.ts +74 -0
  145. package/src/lib/editor/translations.ts +163 -0
  146. package/src/lib/email/accounts.ts +2 -1
  147. package/src/lib/email/types.ts +2 -1
  148. package/src/lib/home-source.ts +34 -0
  149. package/src/lib/i18n.ts +34 -0
  150. package/src/lib/import/apply.ts +31 -2
  151. package/src/lib/import/bundle.ts +4 -0
  152. package/src/lib/import/export.ts +1 -0
  153. package/src/lib/import/ghost.ts +1 -0
  154. package/src/lib/media/upload.ts +83 -0
  155. package/src/lib/mobile/associations.ts +25 -0
  156. package/src/lib/mobile/calendar.ts +19 -11
  157. package/src/lib/mobile/config.mjs +59 -0
  158. package/src/lib/mobile/content.ts +18 -13
  159. package/src/lib/mobile/embed.ts +83 -0
  160. package/src/lib/mobile/http.ts +8 -6
  161. package/src/lib/mobile/locale.ts +18 -0
  162. package/src/lib/mobile/openapi.ts +111 -0
  163. package/src/lib/mobile/pages.ts +71 -0
  164. package/src/lib/mobile/route.ts +3 -3
  165. package/src/lib/mobile/schemas.ts +60 -0
  166. package/src/lib/mobile/site.ts +41 -0
  167. package/src/lib/push/api.ts +158 -0
  168. package/src/lib/push/config.ts +20 -0
  169. package/src/lib/push/expo.ts +76 -0
  170. package/src/lib/push/migration.ts +60 -0
  171. package/src/lib/push/service.ts +302 -0
  172. package/src/lib/push/store.ts +144 -0
  173. package/src/lib/push/types.ts +43 -0
  174. package/src/lib/repo/migrations.ts +11 -0
  175. package/src/lib/repo/sql.ts +40 -7
  176. package/src/lib/repo/types.ts +9 -3
  177. package/src/lib/roles.ts +5 -0
  178. package/src/lib/scheduler.ts +3 -0
  179. package/src/lib/schema.ts +27 -1
  180. package/src/lib/section-ai.ts +55 -0
  181. package/src/lib/section-targets.ts +62 -0
  182. package/src/lib/sections-agent.ts +146 -0
  183. package/src/lib/sections.ts +217 -0
  184. package/src/lib/theme.ts +16 -1
  185. package/src/lib/translate.ts +97 -0
  186. package/src/lib/translations.ts +39 -0
  187. package/src/middleware.ts +32 -0
  188. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  189. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  190. package/src/pages/[...slug].astro +8 -3
  191. package/src/pages/admin/app-auth.astro +159 -0
  192. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  193. package/src/pages/admin/calendar/events/[id].astro +9 -0
  194. package/src/pages/admin/login.astro +6 -2
  195. package/src/pages/admin/logout.ts +5 -2
  196. package/src/pages/admin/mobile.astro +18 -0
  197. package/src/pages/admin/pages/[id].astro +62 -0
  198. package/src/pages/admin/section-preview.astro +61 -0
  199. package/src/pages/admin/users.astro +107 -11
  200. package/src/pages/api/agent/docs.ts +6 -2
  201. package/src/pages/api/agent/index.ts +1 -1
  202. package/src/pages/api/ai/settings.ts +7 -4
  203. package/src/pages/api/design/ai.ts +15 -0
  204. package/src/pages/api/design/index.ts +12 -42
  205. package/src/pages/api/design/translate.ts +15 -0
  206. package/src/pages/api/live/thumb.ts +1 -1
  207. package/src/pages/api/mcp.ts +2 -2
  208. package/src/pages/api/media/upload.ts +10 -78
  209. package/src/pages/api/push/admin.ts +27 -0
  210. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  211. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  212. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  213. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  214. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  215. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  216. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  217. package/src/pages/api/v1/admin/me.ts +6 -0
  218. package/src/pages/api/v1/admin/media/index.ts +32 -0
  219. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  220. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  221. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  222. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  223. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  224. package/src/pages/api/v1/auth/revoke.ts +5 -0
  225. package/src/pages/api/v1/auth/token.ts +5 -0
  226. package/src/pages/api/v1/devices/[token].ts +20 -0
  227. package/src/pages/api/v1/devices/index.ts +17 -0
  228. package/src/pages/api/v1/openapi.json.ts +7 -0
  229. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  230. package/src/pages/api/v1/pages/home.ts +7 -0
  231. package/src/pages/api/v1/push/topics.ts +7 -0
  232. package/src/pages/api/v1/site.ts +7 -0
  233. package/src/pages/api/v1/theme.ts +7 -0
  234. package/src/pages/blog/[slug].astro +8 -2
  235. package/src/pages/blog/index.astro +11 -4
  236. package/src/pages/embed/section/[...path].astro +42 -0
  237. package/src/pages/events/[slug].astro +7 -2
  238. package/src/pages/index.astro +11 -5
  239. package/src/pages/sitemap.xml.ts +8 -0
  240. package/src/styles/admin.css +69 -0
  241. package/src/styles/editor.css +21 -0
  242. package/src/styles/mobile-app.css +151 -0
  243. package/src/styles/sections.css +7 -1
  244. package/src/styles/theme-editor.css +255 -0
  245. package/src/theme-sdk/contract.d.mts +2193 -89
  246. package/src/theme-sdk/contract.mjs +77 -20
  247. package/src/theme-sdk/fields.d.mts +88 -0
  248. package/src/theme-sdk/fields.mjs +326 -0
  249. package/src/theme-sdk/index.ts +10 -2
  250. package/src/theme-sdk/tokens.d.mts +11 -0
  251. package/src/virtual.d.ts +1 -0
@@ -7,7 +7,7 @@ import type { ActionAPIContext } from 'astro:actions';
7
7
  import { getRepo } from '../lib/repo';
8
8
  import { getMediaStore } from '../lib/media/store';
9
9
  import { validateSession, SESSION_COOKIE } from '../lib/auth';
10
- import { LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
10
+ import { DocTranslations, LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
11
11
  import { env } from '../lib/env';
12
12
  import { CONTENT, collectionUrl } from '../lib/content';
13
13
  import { invalidateRedirectCache } from '../middleware';
@@ -30,6 +30,7 @@ import { diffDesign, revertDesignChange, summarizeChanges } from '../lib/designD
30
30
  import { shareEpoch } from '../lib/drafts';
31
31
  import { createDesignToken } from '../lib/preview';
32
32
  import { getTheme } from '../lib/theme';
33
+ import { savePost, applyPostUpdate, savePage, applyPageUpdate, saveEvent, applyEventUpdate, type SaveHooks } from '../lib/content-service';
33
34
 
34
35
  // "What changes" for the Design screen: the staged draft vs the live design
35
36
  async function designChanges(repo: Awaited<ReturnType<typeof getRepo>>) {
@@ -65,79 +66,16 @@ const BlockInput = z.object({
65
66
 
66
67
  // ---- calendar helpers ------------------------------------------------------
67
68
 
68
- type EventSaveInput = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt'>>;
69
-
70
- // Validate + normalize an event save. Times are checked as a PAIR against the
71
- // merged (staged) state so a half-edited range can't slip through.
72
- async function cleanEventInput(repo: Awaited<ReturnType<typeof getRepo>>, data: EventSaveInput, before: CalEvent): Promise<EventSaveInput> {
73
- const out: EventSaveInput = { ...data };
74
- if (out.slug !== undefined) {
75
- const base = slugify(out.slug || out.title || before.title);
76
- let slug = base;
77
- for (let n = 2; ; n++) {
78
- const clash = await repo.getEventBySlug(slug);
79
- if (!clash || clash.id === before.id) break;
80
- slug = `${base}-${n}`;
81
- }
82
- out.slug = slug;
83
- }
84
- if (out.timezone !== undefined && out.timezone !== '' && !isValidTimeZone(out.timezone)) {
85
- throw new ActionError({ code: 'BAD_REQUEST', message: `“${out.timezone}” isn’t a timezone we recognize.` });
86
- }
87
- if (out.start !== undefined || out.end !== undefined || out.allDay !== undefined) {
88
- const merged = before.draft ? { ...before, ...(before.draft as Partial<CalEvent>) } : before;
89
- try {
90
- const t = normalizeEventTimes({ start: out.start ?? merged.start, end: out.end ?? merged.end, allDay: out.allDay ?? merged.allDay });
91
- out.start = t.start;
92
- out.end = t.end;
93
- } catch (err) {
94
- throw new ActionError({ code: 'BAD_REQUEST', message: err instanceof Error ? err.message : 'Invalid time.' });
95
- }
96
- }
97
- if (out.calendarIds !== undefined) {
98
- const known = new Set((await repo.listCalendars()).filter((c) => c.kind === 'native').map((c) => c.id));
99
- out.calendarIds = [...new Set(out.calendarIds.filter((c) => known.has(c)))];
100
- }
101
- if (out.fields !== undefined) {
102
- const allowed = new Map(CALENDAR.eventFields.map((f) => [f.key, f]));
103
- out.fields = Object.fromEntries(
104
- Object.entries(out.fields)
105
- .filter(([k, v]) => allowed.has(k) && v !== '')
106
- .filter(([k, v]) => allowed.get(k)!.type !== 'select' || allowed.get(k)!.options.includes(v))
107
- );
108
- }
109
- if (out.cta !== undefined) {
110
- const href = out.cta.href.trim();
111
- if (href && !/^(https?:\/\/|\/(?!\/)|mailto:|tel:|#)/i.test(href)) {
112
- throw new ActionError({ code: 'BAD_REQUEST', message: 'The button link must start with https://, /, mailto:, or tel:.' });
113
- }
114
- out.cta = { ...out.cta, href };
115
- }
116
- if (out.location?.url) {
117
- if (!/^https?:\/\//i.test(out.location.url.trim())) {
118
- throw new ActionError({ code: 'BAD_REQUEST', message: 'The map or meeting link must start with https://.' });
119
- }
69
+ // Save/Update logic is shared with the editor-app API (lib/content-service).
70
+ // Its ApiErrors become ActionErrors with the same message.
71
+ function asActionError(error: unknown): never {
72
+ if (error instanceof ApiError) {
73
+ const code = error.status === 404 ? 'NOT_FOUND' : error.status === 403 ? 'FORBIDDEN' : error.status === 409 ? 'CONFLICT' : error.status === 401 ? 'UNAUTHORIZED' : 'BAD_REQUEST';
74
+ throw new ActionError({ code, message: error.message });
120
75
  }
121
- if (out.tags !== undefined) out.tags = [...new Set(out.tags.map((t) => t.trim()).filter(Boolean))];
122
- if (out.status === 'scheduled' && !(out.scheduledAt && out.scheduledAt > Date.now())) {
123
- throw new ActionError({ code: 'BAD_REQUEST', message: 'Pick a future publish time to schedule.' });
124
- }
125
- if (out.status === 'published') out.scheduledAt = null;
126
- return out;
127
- }
128
-
129
- async function eventSideEffects(
130
- repo: Awaited<ReturnType<typeof getRepo>>,
131
- ctx: ActionAPIContext,
132
- before: CalEvent,
133
- after: CalEvent,
134
- applied = false
135
- ) {
136
- const settings = await repo.getSettings();
137
- await eventChanged(repo, before, after, {
138
- origin: siteOrigin(settings, ctx.url), waitUntil: waitUntilFrom(ctx.locals), applied, onRedirect: invalidateRedirectCache,
139
- });
76
+ throw error;
140
77
  }
78
+ const hooksFor = (ctx: ActionAPIContext): SaveHooks => ({ url: ctx.url, locals: ctx.locals, onRedirect: invalidateRedirectCache });
141
79
 
142
80
  async function importEvents(
143
81
  repo: Awaited<ReturnType<typeof getRepo>>,
@@ -178,6 +116,7 @@ export const server = {
178
116
  coverAlt: z.string().optional(),
179
117
  seoTitle: z.string().optional(),
180
118
  seoDesc: z.string().optional(),
119
+ translations: DocTranslations.optional(),
181
120
  status: StatusInput.optional(),
182
121
  featured: z.boolean().optional(),
183
122
  authorId: z.string().nullable().optional(),
@@ -189,38 +128,7 @@ export const server = {
189
128
  handler: async ({ id, ...data }, ctx) => {
190
129
  await requireAdmin(ctx);
191
130
  const repo = await getRepo(ctx.locals);
192
- if (data.slug !== undefined) data.slug = slugify(data.slug);
193
- if (data.collection !== undefined && !CONTENT.collections[data.collection]) {
194
- throw new ActionError({ code: 'BAD_REQUEST', message: `Unknown collection "${data.collection}".` });
195
- }
196
- const before = await repo.getPost(id);
197
- if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
198
- // A live (published/scheduled) post never takes content edits
199
- // directly: they stage into the pending draft until the user hits
200
- // Update. Status transitions stay deliberate, immediate writes.
201
- const transitions = data.status !== undefined && data.status !== before.status;
202
- if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
203
- const { status: _status, scheduledAt: _scheduledAt, authorId: _authorId, ...staged } = data;
204
- await repo.savePostDraft(id, staged);
205
- return { post: (await repo.getPost(id))! };
206
- }
207
- await repo.updatePost(id, data);
208
- // any new tag names become registry entries with slugs for archives
209
- if (data.tags !== undefined) await repo.ensureTags(data.tags);
210
- const post = await repo.getPost(id);
211
- if (!post) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
212
- // a published post whose slug or collection changed leaves a 301
213
- // behind so old links keep working
214
- if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
215
- await repo
216
- .createRedirect({
217
- fromPath: collectionUrl(before.collection, before.slug),
218
- toPath: collectionUrl(post.collection, post.slug),
219
- })
220
- .catch(() => {});
221
- invalidateRedirectCache();
222
- }
223
- return { post };
131
+ try { return { post: await savePost(repo, id, data, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
224
132
  },
225
133
  }),
226
134
  // Update button: copy the pending draft onto the live columns. The
@@ -231,21 +139,7 @@ export const server = {
231
139
  handler: async ({ id }, ctx) => {
232
140
  await requireAdmin(ctx);
233
141
  const repo = await getRepo(ctx.locals);
234
- const before = await repo.getPost(id);
235
- if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
236
- await repo.applyPostDraft(id);
237
- const post = (await repo.getPost(id))!;
238
- if (post.tags.length) await repo.ensureTags(post.tags);
239
- if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
240
- await repo
241
- .createRedirect({
242
- fromPath: collectionUrl(before.collection, before.slug),
243
- toPath: collectionUrl(post.collection, post.slug),
244
- })
245
- .catch(() => {});
246
- invalidateRedirectCache();
247
- }
248
- return { post };
142
+ try { return { post: await applyPostUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
249
143
  },
250
144
  }),
251
145
  discardChanges: defineAction({
@@ -281,6 +175,7 @@ export const server = {
281
175
  locale: z.string().optional(),
282
176
  seoTitle: z.string().optional(),
283
177
  seoDesc: z.string().optional(),
178
+ translations: DocTranslations.optional(),
284
179
  template: z.string().optional(),
285
180
  bodyJson: z.string().optional(),
286
181
  bodyHtml: z.string().optional(),
@@ -289,33 +184,13 @@ export const server = {
289
184
  handler: async ({ id, blocks, ...data }, ctx) => {
290
185
  await requireAdmin(ctx);
291
186
  const repo = await getRepo(ctx.locals);
292
- if (data.slug !== undefined) data.slug = slugify(data.slug);
293
- // default locale stores as '', unknown codes fall back to it
294
- if (data.locale !== undefined) data.locale = normalizePageLocale(data.locale);
295
- const before = await repo.getPage(id);
296
- if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
297
187
  const cleanBlocks =
298
188
  blocks === undefined
299
189
  ? undefined
300
190
  : blocks
301
191
  .filter((b) => widgetTypes.includes(b.type as WidgetType))
302
192
  .map((b) => ({ ...b, type: b.type as WidgetType, config: parseConfig(b.type as WidgetType, b.config) as Record<string, unknown> }));
303
- // live pages stage their edits until Update, same as posts
304
- const transitions = data.status !== undefined && data.status !== before.status;
305
- if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
306
- const { status: _status, ...staged } = data;
307
- await repo.savePageDraft(id, { ...staged, blocks: cleanBlocks });
308
- return { page: (await repo.getPage(id))! };
309
- }
310
- await repo.updatePage(id, { ...data, blocks: cleanBlocks });
311
- const page = await repo.getPage(id);
312
- if (!page) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
313
- // renaming a published slug leaves a 301 behind so old links keep working
314
- if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
315
- await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
316
- invalidateRedirectCache();
317
- }
318
- return { page };
193
+ try { return { page: await savePage(repo, id, data, cleanBlocks, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
319
194
  },
320
195
  }),
321
196
  applyUpdate: defineAction({
@@ -323,15 +198,7 @@ export const server = {
323
198
  handler: async ({ id }, ctx) => {
324
199
  await requireAdmin(ctx);
325
200
  const repo = await getRepo(ctx.locals);
326
- const before = await repo.getPage(id);
327
- if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
328
- await repo.applyPageDraft(id);
329
- const page = (await repo.getPage(id))!;
330
- if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
331
- await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
332
- invalidateRedirectCache();
333
- }
334
- return { page };
201
+ try { return { page: await applyPageUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
335
202
  },
336
203
  }),
337
204
  discardChanges: defineAction({
@@ -946,6 +813,7 @@ export const server = {
946
813
  coverAlt: z.string().optional(),
947
814
  seoTitle: z.string().optional(),
948
815
  seoDesc: z.string().optional(),
816
+ translations: DocTranslations.optional(),
949
817
  status: StatusInput.optional(),
950
818
  featured: z.boolean().optional(),
951
819
  template: z.string().optional(),
@@ -966,21 +834,7 @@ export const server = {
966
834
  handler: async ({ id, ...data }, ctx) => {
967
835
  await requireAdmin(ctx);
968
836
  const repo = await getRepo(ctx.locals);
969
- const before = await repo.getEvent(id);
970
- if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Event not found.' });
971
- const clean = await cleanEventInput(repo, data, before);
972
- // live events stage edits until Update, exactly like posts
973
- const transitions = clean.status !== undefined && clean.status !== before.status;
974
- if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
975
- const { status: _s, scheduledAt: _sa, ...staged } = clean;
976
- await repo.saveEventDraft(id, staged);
977
- return { event: (await repo.getEvent(id))! };
978
- }
979
- await repo.updateEvent(id, clean);
980
- if (clean.tags !== undefined) await repo.ensureTags(clean.tags);
981
- const event = (await repo.getEvent(id))!;
982
- await eventSideEffects(repo, ctx, before, event);
983
- return { event };
837
+ try { return { event: await saveEvent(repo, id, data, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
984
838
  },
985
839
  }),
986
840
  applyUpdate: defineAction({
@@ -988,13 +842,7 @@ export const server = {
988
842
  handler: async ({ id }, ctx) => {
989
843
  await requireAdmin(ctx);
990
844
  const repo = await getRepo(ctx.locals);
991
- const before = await repo.getEvent(id);
992
- if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Event not found.' });
993
- await repo.applyEventDraft(id);
994
- const event = (await repo.getEvent(id))!;
995
- if (event.tags.length) await repo.ensureTags(event.tags);
996
- await eventSideEffects(repo, ctx, before, event, true);
997
- return { event };
845
+ try { return { event: await applyEventUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
998
846
  },
999
847
  }),
1000
848
  discardChanges: defineAction({
@@ -16,8 +16,8 @@ import { useEffect, useId, useRef, useState } from 'react';
16
16
  type Provider = 'openai' | 'custom';
17
17
 
18
18
  interface PublicSettings {
19
- ai: { enabled: boolean; provider: Provider; baseUrl: string; model: string; hasApiKey: boolean };
20
- agent: { enabled: boolean; hasKey: boolean; keyPrefix: string; allowPublish: boolean };
19
+ ai: { enabled: boolean; provider: Provider; baseUrl: string; model: string; hasApiKey: boolean; glossary?: string };
20
+ agent: { enabled: boolean; hasKey: boolean; keyPrefix: string; allowPublish: boolean; allowSections?: boolean };
21
21
  agentUrl: string;
22
22
  docsUrl: string;
23
23
  }
@@ -58,7 +58,7 @@ const sameUrl = (a: string, b: string) => {
58
58
  // only link to same-site paths or http(s) URLs from the server response
59
59
  const safeHref = (u: string) => (u.startsWith('/') && !u.startsWith('//')) || /^https?:\/\//i.test(u) ? u : '';
60
60
 
61
- type Busy = 'save' | 'test' | 'generate' | 'revoke' | 'saveAgent';
61
+ type Busy = 'glossary' | 'save' | 'test' | 'generate' | 'revoke' | 'saveAgent';
62
62
 
63
63
  export default function AiSettings() {
64
64
  const uid = useId();
@@ -77,6 +77,9 @@ export default function AiSettings() {
77
77
  // agent form
78
78
  const [agentEnabled, setAgentEnabled] = useState(false);
79
79
  const [allowPublish, setAllowPublish] = useState(false);
80
+ const [allowSections, setAllowSections] = useState(false);
81
+ const [glossary, setGlossary] = useState('');
82
+ const [glossaryMsg, setGlossaryMsg] = useState('');
80
83
  const [token, setToken] = useState('');
81
84
  const [copied, setCopied] = useState('');
82
85
 
@@ -96,15 +99,27 @@ export default function AiSettings() {
96
99
  setProvider(s.ai.provider);
97
100
  setBaseUrl(s.ai.provider === 'openai' ? OPENAI_URL : s.ai.baseUrl);
98
101
  setModel(s.ai.model);
102
+ setGlossary(s.ai.glossary ?? '');
99
103
  setApiKey('');
100
104
  setClearApiKey(false);
101
105
  }
102
106
  if (part !== 'ai') {
103
107
  setAgentEnabled(s.agent.enabled);
104
108
  setAllowPublish(s.agent.allowPublish);
109
+ setAllowSections(Boolean(s.agent.allowSections));
105
110
  }
106
111
  }
107
112
 
113
+ async function saveGlossary() {
114
+ setBusy('glossary');
115
+ setGlossaryMsg('');
116
+ try {
117
+ adopt(await api<PublicSettings>('/api/ai/settings', { action: 'saveGlossary', glossary }), 'ai');
118
+ setGlossaryMsg('Saved. New AI translations follow it.');
119
+ } catch (e) { setGlossaryMsg(e instanceof Error ? e.message : 'Couldn’t save.'); }
120
+ finally { setBusy(null); }
121
+ }
122
+
108
123
  async function load() {
109
124
  setLoadError('');
110
125
  try {
@@ -150,7 +165,7 @@ export default function AiSettings() {
150
165
  const keyWillDrop = data.ai.hasApiKey && destinationChanged && !apiKey.trim() && !clearApiKey;
151
166
  const formDirty =
152
167
  enabled !== data.ai.enabled || destinationChanged || model.trim() !== data.ai.model || !!apiKey || clearApiKey;
153
- const agentDirty = agentEnabled !== data.agent.enabled || allowPublish !== data.agent.allowPublish;
168
+ const agentDirty = agentEnabled !== data.agent.enabled || allowPublish !== data.agent.allowPublish || allowSections !== Boolean(data.agent.allowSections);
154
169
  const isCustom = provider !== 'openai';
155
170
 
156
171
  async function save() {
@@ -238,7 +253,7 @@ export default function AiSettings() {
238
253
  setAgentMsg({ ok: '', err: '' });
239
254
  await run('saveAgent', async () => {
240
255
  try {
241
- adopt(await api<PublicSettings>('/api/ai/settings', { action: 'saveAgent', enabled: agentEnabled, allowPublish }), 'agent');
256
+ adopt(await api<PublicSettings>('/api/ai/settings', { action: 'saveAgent', enabled: agentEnabled, allowPublish, allowSections }), 'agent');
242
257
  setAgentMsg({ ok: 'Agent access saved.', err: '' });
243
258
  } catch (e) {
244
259
  setAgentMsg({ ok: '', err: errText(e) });
@@ -412,6 +427,26 @@ export default function AiSettings() {
412
427
  </div>
413
428
  </section>
414
429
 
430
+ {/* ---- translation glossary ---- */}
431
+ <section className="ais-card" aria-labelledby={id('gl-h')}>
432
+ <header className="ais-head">
433
+ <div className="ais-grow">
434
+ <h2 id={id('gl-h')}>Translation glossary</h2>
435
+ <p>When AI drafts another language, these words always come out your way. One per line: <code>Youth Night = Noche de Jóvenes</code>, or just a name to keep as is (<code>Riverside</code>).</p>
436
+ </div>
437
+ </header>
438
+ <label className="field" htmlFor={id('gl')}>
439
+ <span>Terms</span>
440
+ <textarea id={id('gl')} rows={6} className="mono" value={glossary} onChange={(e) => setGlossary(e.target.value)} placeholder={'Youth Night = Noche de Jóvenes\nSunday Worship = Culto dominical\nRiverside'} />
441
+ </label>
442
+ <div className="ais-actions">
443
+ <button type="button" className="btn primary" disabled={busy !== null || glossary.trim() === (data?.ai.glossary ?? '').trim()} onClick={() => void saveGlossary()}>
444
+ {busy === 'glossary' ? 'Saving…' : 'Save glossary'}
445
+ </button>
446
+ {glossaryMsg && <span className="hint" aria-live="polite">{glossaryMsg}</span>}
447
+ </div>
448
+ </section>
449
+
415
450
  {/* ---- agent ---- */}
416
451
  <section className="ais-card" aria-labelledby={id('ag-h')}>
417
452
  <header className="ais-head">
@@ -427,6 +462,7 @@ export default function AiSettings() {
427
462
  <ul className="ais-perms" aria-label="What the agent key can do">
428
463
  <li className="yes">Read all posts and pages, including unpublished drafts</li>
429
464
  <li className="yes">Create drafts and edit drafts of posts and pages</li>
465
+ <li className={allowSections ? 'yes' : 'no'}>Page sections — {allowSections ? 'can stage layout and content changes for your review' : 'read only unless you turn it on below'}</li>
430
466
  <li className={allowPublish ? 'yes' : 'no'}>Publish — {allowPublish ? 'allowed' : 'off unless you turn it on below'}</li>
431
467
  <li className="no">Never delete anything or change settings</li>
432
468
  </ul>
@@ -486,6 +522,10 @@ export default function AiSettings() {
486
522
  <input type="checkbox" checked={allowPublish} onChange={(e) => setAllowPublish(e.target.checked)} />
487
523
  <span>Let the agent publish <span className="hint">(off by default, so drafts wait for you to review)</span></span>
488
524
  </label>
525
+ <label className="check-row">
526
+ <input type="checkbox" checked={allowSections} onChange={(e) => setAllowSections(e.target.checked)} />
527
+ <span>Let the agent edit page sections <span className="hint">(homepage layouts and section content; changes stay in the design draft)</span></span>
528
+ </label>
489
529
  <div className="ais-actions">
490
530
  <button type="button" className="btn primary" onClick={() => void saveAgent()} disabled={busy !== null || !agentDirty}>
491
531
  {busy === 'saveAgent' ? 'Saving…' : 'Save agent access'}
@@ -1,9 +1,11 @@
1
- import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
2
2
  import { actions } from 'astro:actions';
3
3
  import type { Settings } from '../lib/schema';
4
4
  import type { DesignChange } from '../lib/designDiff';
5
5
  import { ImagePicker } from './editor/ImagePicker';
6
- import ThemeSectionsEditor, { SectionsReview, useDesignApi } from './ThemeSectionsEditor';
6
+ import ThemeSectionsEditor, { SectionsReview, useDesignApi, type SectionFocus } from './ThemeSectionsEditor';
7
+ import { checkLayouts, type Check } from './sections/checks';
8
+ import { EMPTY_TARGETS } from './sections/SectionFields';
7
9
  import DesignAccess from './DesignAccess';
8
10
  import '../styles/theme-editor.css';
9
11
 
@@ -145,7 +147,9 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
145
147
  const [copied, setCopied] = useState(false);
146
148
  const [tick, setTick] = useState(0);
147
149
  const [previewPage, setPreviewPage] = useState<'home' | 'post'>('home');
148
- const [previewDevice, setPreviewDevice] = useState<DeviceKey>('mobile');
150
+ // open on the device you're most likely designing for: desktop on a wide
151
+ // admin window (scaled to fit), phone otherwise; both are one click away
152
+ const [previewDevice, setPreviewDevice] = useState<DeviceKey>(() => (typeof window !== 'undefined' && window.innerWidth >= 1200 ? 'desktop' : 'mobile'));
149
153
  const [previewScheme, setPreviewScheme] = useState<'light' | 'dark'>('light');
150
154
  // "What changes" + preview compare / share / history
151
155
  const [changes, setChanges] = useState<DesignChange[]>(initialChanges);
@@ -186,16 +190,45 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
186
190
  const sectionsStagedRef = useRef(false);
187
191
  sectionsStagedRef.current = sectionsStaged;
188
192
 
193
+ // pre-publish checklist (section layouts)
194
+ const [checks, setChecks] = useState<Check[]>([]);
195
+ const checksRef = useRef<HTMLDialogElement>(null);
196
+ const flushSections = useRef<() => Promise<boolean>>(async () => true);
197
+ // click-to-edit: the preview asks for a section's form; the open form
198
+ // highlights its section in the preview
199
+ const [sectionFocus, setSectionFocus] = useState<SectionFocus | null>(null);
200
+ const openSection = useRef<string | null>(null);
201
+ const frameEl = useRef<HTMLIFrameElement>(null);
202
+ // unsaved section edits, rendered by the server: swap them into the preview
203
+ // (only while it shows the draft; the Live compare stays the live site)
204
+ const compareRef = useRef<'draft' | 'live'>('draft');
205
+ compareRef.current = compare;
206
+ const liveHtml = useCallback((html: string) => {
207
+ if (compareRef.current !== 'draft') return;
208
+ try { frameEl.current?.contentWindow?.postMessage({ type: 'acms-section-html', html }, location.origin); } catch { /* frame navigating */ }
209
+ }, []);
210
+ const focusPreview = (sectionId: string | null) => {
211
+ try { frameEl.current?.contentWindow?.postMessage({ type: 'acms-section-focus', sectionId }, location.origin); } catch { /* frame navigating */ }
212
+ };
213
+
189
214
  // the preview iframe reports where it navigated (preview-mode links keep the
190
215
  // draft), so reloads after a change land on the same page
191
216
  useEffect(() => {
192
217
  const onMsg = (e: MessageEvent) => {
193
- if (e.origin !== location.origin || e.data?.type !== 'acms-preview-nav') return;
194
- pathRef.current = String(e.data.path || '/');
195
- setPreviewPath(pathRef.current);
218
+ if (e.origin !== location.origin) return;
219
+ if (e.data?.type === 'acms-preview-nav') {
220
+ pathRef.current = String(e.data.path || '/');
221
+ setPreviewPath(pathRef.current);
222
+ } else if (e.data?.type === 'acms-section-edit') {
223
+ setTab('pages');
224
+ setSectionFocus((f) => ({ pageId: String(e.data.pageId), sectionId: String(e.data.sectionId), n: (f?.n ?? 0) + 1 }));
225
+ } else if (e.data?.type === 'acms-sections-ready' && openSection.current) {
226
+ focusPreview(openSection.current);
227
+ }
196
228
  };
197
229
  addEventListener('message', onMsg);
198
230
  return () => removeEventListener('message', onMsg);
231
+ // eslint-disable-next-line react-hooks/exhaustive-deps
199
232
  }, []);
200
233
  const frameRef = useRef<HTMLDivElement>(null);
201
234
  const [frameBox, setFrameBox] = useState({ w: 0, h: 0 });
@@ -243,13 +276,23 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
243
276
  };
244
277
 
245
278
  // Publish design: flush any queued save into the staged blob, then apply it
246
- const publishDesign = async () => {
279
+ const publishDesign = async (checked = false) => {
247
280
  if (timer.current) { clearTimeout(timer.current); timer.current = null; }
248
281
  if (!(await doSave())) return;
282
+ if (!(await flushSections.current())) return;
249
283
  const n = latestChanges.current.length;
250
284
  if (!n && !sectionsStagedRef.current) return;
285
+ // pre-publish checklist over the staged page layouts
286
+ if (!checked) {
287
+ const d = designApi.data;
288
+ const themeDefs = d?.themes.find((t) => t.slug === activeTheme)?.sections ?? {};
289
+ const staged = (d?.draft?.sectionPages ?? d?.live.sectionPages ?? {}) as Parameters<typeof checkLayouts>[0];
290
+ const found = d ? checkLayouts(staged, themeDefs as Parameters<typeof checkLayouts>[1], d.targets ?? EMPTY_TARGETS) : [];
291
+ if (found.length) { setChecks(found); checksRef.current?.showModal(); return; }
292
+ }
251
293
  const what = n ? `${n} ${n === 1 ? 'change' : 'changes'}` : 'the staged page layouts';
252
- if (!window.confirm(`Publish ${what}? Visitors see them right away. The current design is saved to History.`)) return;
294
+ if (!checked && !window.confirm(`Publish ${what}? Visitors see them right away. The current design is saved to History.`)) return;
295
+ checksRef.current?.close();
253
296
  const { error } = await actions.settings.applyDesign({});
254
297
  if (error) { setSaveMessage(error.message); setSaveState('error'); return; }
255
298
  setPending(false);
@@ -370,6 +413,9 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
370
413
  );
371
414
 
372
415
  const hasDraft = (pending && changes.length > 0) || sectionsStaged;
416
+ // editors change section content only: Pages tab, per-section publish
417
+ const editorOnly = (designApi.data as { role?: string } | null)?.role === 'editor';
418
+ useEffect(() => { if (editorOnly && tab !== 'pages') setTab('pages'); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [editorOnly]);
373
419
 
374
420
  return (
375
421
  <div className="dm-layout">
@@ -384,7 +430,7 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
384
430
  <span className={`lm-savestate ${saveState}`}>
385
431
  {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? (saveMessage || 'Save failed') : null}
386
432
  </span>
387
- <button className="btn sm ghost" type="button" onClick={() => void openHistory()}>History</button>
433
+ {!editorOnly && <button className="btn sm ghost" type="button" onClick={() => void openHistory()}>History</button>}
388
434
  </div>
389
435
  </div>
390
436
 
@@ -396,10 +442,10 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
396
442
  <><span className="dm-st is-live">Live</span><span className="dm-st-sub">· no unpublished changes</span></>
397
443
  )}
398
444
  <span className="dm-status-actions">
399
- {overrideCount > 0 && (
445
+ {overrideCount > 0 && !editorOnly && (
400
446
  <button className="btn sm ghost" type="button" onClick={() => { setOverrides({}); scheduleSave(); }}>Reset all</button>
401
447
  )}
402
- {hasDraft && (
448
+ {hasDraft && !editorOnly && (
403
449
  <>
404
450
  <button className="btn sm ghost" type="button" onClick={discardDesign}>Discard</button>
405
451
  <button className="btn sm primary" type="button" onClick={() => void publishDesign()}>Publish…</button>
@@ -408,22 +454,22 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
408
454
  </span>
409
455
  </div>
410
456
  <div className="dm-status-links">
411
- {hasDraft && tab !== 'review' && (
457
+ {hasDraft && tab !== 'review' && !editorOnly && (
412
458
  <button className="btn sm" type="button" onClick={() => setTab('review')}>Review changes</button>
413
459
  )}
414
460
  <a className="btn sm" href="/?designPreview=1" target="_blank" rel="noopener">
415
461
  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" /></svg>
416
462
  Preview site
417
463
  </a>
418
- <button className="btn sm" type="button" onClick={openShare} disabled={!hasDraft} title={hasDraft ? undefined : 'Make a change first'}>
464
+ {!editorOnly && <button className="btn sm" type="button" onClick={openShare} disabled={!hasDraft} title={hasDraft ? undefined : 'Make a change first'}>
419
465
  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1.5 1.5M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1.5-1.5" /></svg>
420
466
  Share preview
421
- </button>
467
+ </button>}
422
468
  </div>
423
469
  </div>
424
470
 
425
471
  <div className="seg te-tabs" role="tablist" aria-label="Design settings" onKeyDown={onTabKey}>
426
- {TABS.map((t) => (
472
+ {TABS.filter((t) => !editorOnly || t.key === 'pages').map((t) => (
427
473
  <button
428
474
  key={t.key}
429
475
  ref={(el) => { tabRefs.current[t.key] = el; }}
@@ -734,7 +780,24 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
734
780
  </div>
735
781
 
736
782
  <div className="te-panel" role="tabpanel" id="dm-panel-pages" aria-labelledby="dm-tab-pages" hidden={tab !== 'pages'}>
737
- <ThemeSectionsEditor api={designApi} activeTheme={activeTheme} unsplashEnabled={unsplashEnabled} />
783
+ <ThemeSectionsEditor
784
+ api={designApi}
785
+ activeTheme={activeTheme}
786
+ unsplashEnabled={unsplashEnabled}
787
+ focus={sectionFocus}
788
+ onOpenSection={(_pageId, sectionId, path) => {
789
+ openSection.current = sectionId;
790
+ // show the page being edited: follow it in the preview
791
+ if (sectionId && path && pathRef.current.split('?')[0] !== path) {
792
+ pathRef.current = path;
793
+ setPreviewPath(path);
794
+ setTick((t) => t + 1);
795
+ } else focusPreview(sectionId);
796
+ }}
797
+ onSaved={() => setTick((t) => t + 1)}
798
+ onLiveHtml={liveHtml}
799
+ flushRef={flushSections}
800
+ />
738
801
  </div>
739
802
 
740
803
  <div className="te-panel" role="tabpanel" id="dm-panel-review" aria-labelledby="dm-tab-review" hidden={tab !== 'review'}>
@@ -840,6 +903,7 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
840
903
  return (
841
904
  <div className="dm-frame" ref={frameRef}>
842
905
  <iframe
906
+ ref={frameEl}
843
907
  key={iframeSrc}
844
908
  src={iframeSrc}
845
909
  title="Site preview"
@@ -854,6 +918,38 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
854
918
  })()}
855
919
  </div>
856
920
 
921
+ <dialog className="modal dm-dialog dm-checks" ref={checksRef} aria-labelledby="dm-checks-title" onClick={(e) => { if (e.target === e.currentTarget) checksRef.current?.close(); }}>
922
+ <div className="modal-head">
923
+ <span id="dm-checks-title">Before you publish</span>
924
+ <button className="modal-x" type="button" onClick={() => checksRef.current?.close()} aria-label="Close">✕</button>
925
+ </div>
926
+ <div className="modal-body">
927
+ {checks.some((c) => c.level === 'error')
928
+ ? <p className="dm-dialog-sub">Some fields need fixing before this can go live.</p>
929
+ : <p className="dm-dialog-sub">A few things you may want to fix first. You can also publish as is.</p>}
930
+ <ul className="dm-checklist">
931
+ {checks.slice(0, 12).map((c, i) => (
932
+ <li key={i} className={`is-${c.level}`}>
933
+ <span className="dm-check-dot" aria-hidden="true" />
934
+ <span><b>{c.section}</b>: {c.message}</span>
935
+ <button className="btn sm ghost" type="button" onClick={() => {
936
+ checksRef.current?.close();
937
+ setTab('pages');
938
+ setSectionFocus((f) => ({ pageId: c.pageId, sectionId: c.sectionId, n: (f?.n ?? 0) + 1 }));
939
+ }}>Fix</button>
940
+ </li>
941
+ ))}
942
+ {checks.length > 12 && <li className="te-muted">…and {checks.length - 12} more</li>}
943
+ </ul>
944
+ <div className="modal-foot">
945
+ <button className="btn ghost" type="button" onClick={() => checksRef.current?.close()}>Keep editing</button>
946
+ {!checks.some((c) => c.level === 'error') && (
947
+ <button className="btn primary" type="button" onClick={() => void publishDesign(true)}>Publish anyway</button>
948
+ )}
949
+ </div>
950
+ </div>
951
+ </dialog>
952
+
857
953
  <dialog className="modal dm-dialog" ref={shareRef} onClick={(e) => { if (e.target === e.currentTarget) shareRef.current?.close(); }}>
858
954
  <div className="modal-head">
859
955
  <span>Share a preview</span>