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

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 (289) hide show
  1. package/CHANGELOG.md +105 -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/preview.mjs +8 -4
  9. package/cli/sections.mjs +188 -0
  10. package/cli/themes.mjs +52 -8
  11. package/docs/mobile.md +527 -0
  12. package/docs/sections.md +203 -0
  13. package/docs/theme-authoring.md +19 -3
  14. package/integration.mjs +76 -5
  15. package/mobile-template/client/AGENTS.md +97 -0
  16. package/mobile-template/client/README.md +65 -0
  17. package/mobile-template/client/app/_layout.tsx +56 -0
  18. package/mobile-template/client/app/index.tsx +21 -0
  19. package/mobile-template/client/app.json +21 -0
  20. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  21. package/mobile-template/client/eas.json +21 -0
  22. package/mobile-template/client/env.example +3 -0
  23. package/mobile-template/client/gitignore +42 -0
  24. package/mobile-template/client/package.json +38 -0
  25. package/mobile-template/client/sections/index.ts +11 -0
  26. package/mobile-template/client/tsconfig.json +7 -0
  27. package/mobile-template/editor/AGENTS.md +66 -0
  28. package/mobile-template/editor/README.md +73 -0
  29. package/mobile-template/editor/app/_layout.tsx +76 -0
  30. package/mobile-template/editor/app/auth.tsx +13 -0
  31. package/mobile-template/editor/app/index.tsx +55 -0
  32. package/mobile-template/editor/app.json +21 -0
  33. package/mobile-template/editor/components/Screen.tsx +51 -0
  34. package/mobile-template/editor/components/SignIn.tsx +36 -0
  35. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  36. package/mobile-template/editor/eas.json +21 -0
  37. package/mobile-template/editor/env.example +5 -0
  38. package/mobile-template/editor/gitignore +42 -0
  39. package/mobile-template/editor/lib/appearance.tsx +12 -0
  40. package/mobile-template/editor/lib/config.ts +17 -0
  41. package/mobile-template/editor/lib/format.ts +24 -0
  42. package/mobile-template/editor/lib/tools.ts +16 -0
  43. package/mobile-template/editor/package.json +40 -0
  44. package/mobile-template/editor/tools/README.md +2 -0
  45. package/mobile-template/editor/tsconfig.json +7 -0
  46. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  47. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  48. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  49. package/mobile-template/recipes/booking/recipe.json +17 -0
  50. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  51. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  52. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  54. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  55. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  56. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  57. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  58. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  59. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  60. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  62. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  63. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  64. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  65. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  67. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  68. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  69. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  70. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  72. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  73. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  74. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  75. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  76. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  77. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  78. package/mobile-template/recipes/events/recipe.json +15 -0
  79. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  80. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  81. package/mobile-template/recipes/news/recipe.json +13 -0
  82. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  83. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  84. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  85. package/mobile-template/recipes/push/recipe.json +20 -0
  86. package/package.json +17 -13
  87. package/section-library/README.md +16 -0
  88. package/section-library/contact/Contact.astro +49 -0
  89. package/section-library/contact/section.json +106 -0
  90. package/section-library/cta-band/CtaBand.astro +28 -0
  91. package/section-library/cta-band/section.json +51 -0
  92. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  93. package/section-library/feature-grid/section.json +107 -0
  94. package/section-library/gallery/Gallery.astro +29 -0
  95. package/section-library/gallery/section.json +51 -0
  96. package/section-library/hero-banner/HeroBanner.astro +42 -0
  97. package/section-library/hero-banner/section.json +81 -0
  98. package/section-library/latest-posts/LatestPosts.astro +22 -0
  99. package/section-library/latest-posts/section.json +37 -0
  100. package/section-library/pricing/Pricing.astro +40 -0
  101. package/section-library/pricing/section.json +117 -0
  102. package/section-library/questions/Questions.astro +38 -0
  103. package/section-library/questions/section.json +69 -0
  104. package/section-library/service-times/ServiceTimes.astro +42 -0
  105. package/section-library/service-times/section.json +127 -0
  106. package/section-library/team/Team.astro +45 -0
  107. package/section-library/team/section.json +88 -0
  108. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  109. package/section-library/upcoming-events/section.json +51 -0
  110. package/src/actions/index.ts +205 -174
  111. package/src/components/AiSettings.tsx +45 -5
  112. package/src/components/DesignManager.tsx +112 -16
  113. package/src/components/MobileApp.tsx +392 -0
  114. package/src/components/ThemeSections.astro +40 -21
  115. package/src/components/ThemeSectionsEditor.tsx +757 -229
  116. package/src/components/booking/BookingFlow.astro +6 -3
  117. package/src/components/calendar/BookingsHead.astro +51 -0
  118. package/src/components/calendar/CalendarBoard.tsx +618 -0
  119. package/src/components/calendar/MiniMonth.tsx +54 -0
  120. package/src/components/calendar/NewEventDialog.tsx +44 -0
  121. package/src/components/calendar/QuickAdd.tsx +263 -0
  122. package/src/components/calendar/RoomHolds.tsx +203 -0
  123. package/src/components/calendar/WhereItLands.tsx +51 -0
  124. package/src/components/editor/EventPanel.tsx +23 -2
  125. package/src/components/editor/PostEditor.tsx +206 -14
  126. package/src/components/import/ImportExport.tsx +4 -1
  127. package/src/components/import/runner.ts +1 -0
  128. package/src/components/sections/SectionFields.tsx +560 -0
  129. package/src/components/sections/SectionPicker.tsx +120 -0
  130. package/src/components/sections/TranslationPanel.tsx +143 -0
  131. package/src/components/sections/checks.ts +64 -0
  132. package/src/layouts/Admin.astro +11 -7
  133. package/src/layouts/Site.astro +136 -21
  134. package/src/lib/agent-discovery.ts +11 -7
  135. package/src/lib/agent.ts +35 -2
  136. package/src/lib/ai-schema.ts +5 -0
  137. package/src/lib/ai.ts +5 -0
  138. package/src/lib/api.ts +12 -0
  139. package/src/lib/backup/content.ts +6 -1
  140. package/src/lib/booking/agent.ts +3 -2
  141. package/src/lib/booking/approval.ts +70 -0
  142. package/src/lib/booking/holds.ts +120 -0
  143. package/src/lib/booking/service.ts +37 -3
  144. package/src/lib/calendar/agent-schema.ts +9 -1
  145. package/src/lib/calendar/agent.ts +16 -1
  146. package/src/lib/calendar/board.ts +171 -0
  147. package/src/lib/calendar/config.ts +3 -0
  148. package/src/lib/calendar/grid.ts +169 -0
  149. package/src/lib/calendar/migration.ts +16 -0
  150. package/src/lib/calendar/render.ts +9 -7
  151. package/src/lib/calendar/schedule.ts +166 -0
  152. package/src/lib/content-service.ts +203 -0
  153. package/src/lib/design-service.ts +153 -5
  154. package/src/lib/drafts.ts +3 -3
  155. package/src/lib/editor/auth.ts +264 -0
  156. package/src/lib/editor/bookings.ts +60 -0
  157. package/src/lib/editor/config.ts +25 -0
  158. package/src/lib/editor/documents.ts +227 -0
  159. package/src/lib/editor/http.ts +48 -0
  160. package/src/lib/editor/me.ts +30 -0
  161. package/src/lib/editor/migration.ts +57 -0
  162. package/src/lib/editor/next.ts +11 -0
  163. package/src/lib/editor/openapi.ts +124 -0
  164. package/src/lib/editor/schemas.ts +91 -0
  165. package/src/lib/editor/sections.ts +96 -0
  166. package/src/lib/editor/store.ts +184 -0
  167. package/src/lib/editor/token-route.ts +74 -0
  168. package/src/lib/editor/translations.ts +163 -0
  169. package/src/lib/email/accounts.ts +2 -1
  170. package/src/lib/email/types.ts +2 -1
  171. package/src/lib/home-source.ts +34 -0
  172. package/src/lib/i18n.ts +34 -0
  173. package/src/lib/import/apply.ts +31 -2
  174. package/src/lib/import/bundle.ts +4 -0
  175. package/src/lib/import/export.ts +1 -0
  176. package/src/lib/import/ghost.ts +1 -0
  177. package/src/lib/media/upload.ts +83 -0
  178. package/src/lib/mobile/associations.ts +25 -0
  179. package/src/lib/mobile/calendar.ts +26 -14
  180. package/src/lib/mobile/config.mjs +59 -0
  181. package/src/lib/mobile/content.ts +18 -13
  182. package/src/lib/mobile/embed.ts +83 -0
  183. package/src/lib/mobile/http.ts +14 -6
  184. package/src/lib/mobile/locale.ts +18 -0
  185. package/src/lib/mobile/openapi.ts +111 -0
  186. package/src/lib/mobile/pages.ts +71 -0
  187. package/src/lib/mobile/route.ts +3 -3
  188. package/src/lib/mobile/schemas.ts +60 -0
  189. package/src/lib/mobile/site.ts +41 -0
  190. package/src/lib/push/api.ts +158 -0
  191. package/src/lib/push/config.ts +20 -0
  192. package/src/lib/push/expo.ts +76 -0
  193. package/src/lib/push/migration.ts +60 -0
  194. package/src/lib/push/service.ts +302 -0
  195. package/src/lib/push/store.ts +144 -0
  196. package/src/lib/push/types.ts +43 -0
  197. package/src/lib/repo/migrations.ts +14 -0
  198. package/src/lib/repo/sql.ts +65 -20
  199. package/src/lib/repo/types.ts +10 -4
  200. package/src/lib/roles.ts +5 -0
  201. package/src/lib/scheduler.ts +6 -1
  202. package/src/lib/schema.ts +76 -1
  203. package/src/lib/section-ai.ts +55 -0
  204. package/src/lib/section-targets.ts +62 -0
  205. package/src/lib/sections-agent.ts +146 -0
  206. package/src/lib/sections.ts +242 -0
  207. package/src/lib/site-chrome.ts +34 -0
  208. package/src/lib/theme-assets.ts +16 -0
  209. package/src/lib/theme.ts +33 -4
  210. package/src/lib/translate.ts +97 -0
  211. package/src/lib/translations.ts +39 -0
  212. package/src/middleware.ts +38 -2
  213. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  214. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  215. package/src/pages/[...slug].astro +8 -3
  216. package/src/pages/admin/app-auth.astro +159 -0
  217. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  218. package/src/pages/admin/calendar/bookings.astro +32 -26
  219. package/src/pages/admin/calendar/calendars.astro +4 -263
  220. package/src/pages/admin/calendar/events/[id].astro +9 -0
  221. package/src/pages/admin/calendar/events/index.astro +41 -83
  222. package/src/pages/admin/calendar/index.astro +43 -0
  223. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  224. package/src/pages/admin/calendar/resources/index.astro +16 -17
  225. package/src/pages/admin/calendar/resources/link.astro +52 -12
  226. package/src/pages/admin/calendar/settings.astro +458 -0
  227. package/src/pages/admin/integrations.astro +5 -11
  228. package/src/pages/admin/login.astro +6 -2
  229. package/src/pages/admin/logout.ts +5 -2
  230. package/src/pages/admin/mobile.astro +18 -0
  231. package/src/pages/admin/pages/[id].astro +62 -0
  232. package/src/pages/admin/section-preview.astro +61 -0
  233. package/src/pages/admin/users.astro +107 -11
  234. package/src/pages/api/agent/docs.ts +6 -2
  235. package/src/pages/api/agent/index.ts +1 -1
  236. package/src/pages/api/ai/settings.ts +7 -4
  237. package/src/pages/api/design/ai.ts +15 -0
  238. package/src/pages/api/design/index.ts +12 -42
  239. package/src/pages/api/design/translate.ts +15 -0
  240. package/src/pages/api/live/thumb.ts +1 -1
  241. package/src/pages/api/mcp.ts +2 -2
  242. package/src/pages/api/media/upload.ts +10 -78
  243. package/src/pages/api/push/admin.ts +27 -0
  244. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  245. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  246. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  247. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  248. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  249. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  250. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  251. package/src/pages/api/v1/admin/me.ts +6 -0
  252. package/src/pages/api/v1/admin/media/index.ts +32 -0
  253. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  254. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  255. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  256. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  257. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  258. package/src/pages/api/v1/auth/revoke.ts +5 -0
  259. package/src/pages/api/v1/auth/token.ts +5 -0
  260. package/src/pages/api/v1/devices/[token].ts +20 -0
  261. package/src/pages/api/v1/devices/index.ts +17 -0
  262. package/src/pages/api/v1/openapi.json.ts +7 -0
  263. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  264. package/src/pages/api/v1/pages/home.ts +7 -0
  265. package/src/pages/api/v1/push/topics.ts +7 -0
  266. package/src/pages/api/v1/site.ts +7 -0
  267. package/src/pages/api/v1/theme.ts +7 -0
  268. package/src/pages/blog/[slug].astro +8 -2
  269. package/src/pages/blog/index.astro +11 -4
  270. package/src/pages/book/[resource].ics.ts +7 -1
  271. package/src/pages/embed/section/[...path].astro +42 -0
  272. package/src/pages/events/[slug].astro +7 -2
  273. package/src/pages/index.astro +11 -5
  274. package/src/pages/sitemap.xml.ts +8 -0
  275. package/src/styles/admin.css +117 -0
  276. package/src/styles/cards.css +5 -1
  277. package/src/styles/editor.css +57 -0
  278. package/src/styles/mobile-app.css +151 -0
  279. package/src/styles/schedule.css +325 -0
  280. package/src/styles/sections.css +21 -12
  281. package/src/styles/theme-editor.css +255 -0
  282. package/src/theme-sdk/contract.d.mts +2193 -89
  283. package/src/theme-sdk/contract.mjs +77 -20
  284. package/src/theme-sdk/fields.d.mts +88 -0
  285. package/src/theme-sdk/fields.mjs +330 -0
  286. package/src/theme-sdk/index.ts +14 -2
  287. package/src/theme-sdk/tokens.d.mts +11 -0
  288. package/src/virtual.d.ts +4 -0
  289. package/src/pages/admin/calendar/index.ts +0 -4
@@ -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';
@@ -16,20 +16,26 @@ import { normalizeCalendarSrc } from '../lib/calendar/src';
16
16
  import { getCalendarEvents } from '../lib/calendar/ics';
17
17
  import { siteLocale } from '../lib/dates';
18
18
  import { normalizePageLocale, localeHomeUrl } from '../lib/i18n';
19
- import { CALENDAR } from '../lib/calendar/config';
19
+ import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
20
+ import { holdConflicts } from '../lib/booking/holds';
21
+ import { setBookingStatus } from '../lib/booking/service';
20
22
  import { eventTimeZone, featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes, safeColor } from '../lib/calendar/events';
21
23
  import { eventsFromCsv, eventsFromIcs, eventsFromVEvents } from '../lib/calendar/import';
22
24
  import { loadFeed } from '../lib/calendar/ics';
23
25
  import { NATIVE_ALL } from '../lib/calendar/render';
24
26
  import { collectViewEvents } from '../lib/calendar/data';
27
+ import { loadBoard } from '../lib/calendar/board';
28
+ import { duplicateEvent, moveOccurrence, moveSeries, quickCreate, undoMove } from '../lib/calendar/schedule';
25
29
  import { waitUntilFrom } from '../lib/webhooks';
26
30
  import { eventChanged, eventDeleted } from '../lib/calendar/hooks';
27
31
  import { siteOrigin } from '../lib/seo';
28
32
  import type { CalEvent } from '../lib/schema';
33
+ import { CalEvent as CalEventSchema } from '../lib/schema';
29
34
  import { diffDesign, revertDesignChange, summarizeChanges } from '../lib/designDiff';
30
35
  import { shareEpoch } from '../lib/drafts';
31
36
  import { createDesignToken } from '../lib/preview';
32
37
  import { getTheme } from '../lib/theme';
38
+ import { savePost, applyPostUpdate, savePage, applyPageUpdate, saveEvent, applyEventUpdate, type SaveHooks } from '../lib/content-service';
33
39
 
34
40
  // "What changes" for the Design screen: the staged draft vs the live design
35
41
  async function designChanges(repo: Awaited<ReturnType<typeof getRepo>>) {
@@ -65,79 +71,16 @@ const BlockInput = z.object({
65
71
 
66
72
  // ---- calendar helpers ------------------------------------------------------
67
73
 
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
- );
74
+ // Save/Update logic is shared with the editor-app API (lib/content-service).
75
+ // Its ApiErrors become ActionErrors with the same message.
76
+ function asActionError(error: unknown): never {
77
+ if (error instanceof ApiError) {
78
+ const code = error.status === 404 ? 'NOT_FOUND' : error.status === 403 ? 'FORBIDDEN' : error.status === 409 ? 'CONFLICT' : error.status === 401 ? 'UNAUTHORIZED' : 'BAD_REQUEST';
79
+ throw new ActionError({ code, message: error.message });
108
80
  }
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
- }
120
- }
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
- });
81
+ throw error;
140
82
  }
83
+ const hooksFor = (ctx: ActionAPIContext): SaveHooks => ({ url: ctx.url, locals: ctx.locals, onRedirect: invalidateRedirectCache });
141
84
 
142
85
  async function importEvents(
143
86
  repo: Awaited<ReturnType<typeof getRepo>>,
@@ -178,6 +121,7 @@ export const server = {
178
121
  coverAlt: z.string().optional(),
179
122
  seoTitle: z.string().optional(),
180
123
  seoDesc: z.string().optional(),
124
+ translations: DocTranslations.optional(),
181
125
  status: StatusInput.optional(),
182
126
  featured: z.boolean().optional(),
183
127
  authorId: z.string().nullable().optional(),
@@ -189,38 +133,7 @@ export const server = {
189
133
  handler: async ({ id, ...data }, ctx) => {
190
134
  await requireAdmin(ctx);
191
135
  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 };
136
+ try { return { post: await savePost(repo, id, data, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
224
137
  },
225
138
  }),
226
139
  // Update button: copy the pending draft onto the live columns. The
@@ -231,21 +144,7 @@ export const server = {
231
144
  handler: async ({ id }, ctx) => {
232
145
  await requireAdmin(ctx);
233
146
  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 };
147
+ try { return { post: await applyPostUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
249
148
  },
250
149
  }),
251
150
  discardChanges: defineAction({
@@ -281,6 +180,7 @@ export const server = {
281
180
  locale: z.string().optional(),
282
181
  seoTitle: z.string().optional(),
283
182
  seoDesc: z.string().optional(),
183
+ translations: DocTranslations.optional(),
284
184
  template: z.string().optional(),
285
185
  bodyJson: z.string().optional(),
286
186
  bodyHtml: z.string().optional(),
@@ -289,33 +189,13 @@ export const server = {
289
189
  handler: async ({ id, blocks, ...data }, ctx) => {
290
190
  await requireAdmin(ctx);
291
191
  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
192
  const cleanBlocks =
298
193
  blocks === undefined
299
194
  ? undefined
300
195
  : blocks
301
196
  .filter((b) => widgetTypes.includes(b.type as WidgetType))
302
197
  .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 };
198
+ try { return { page: await savePage(repo, id, data, cleanBlocks, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
319
199
  },
320
200
  }),
321
201
  applyUpdate: defineAction({
@@ -323,15 +203,7 @@ export const server = {
323
203
  handler: async ({ id }, ctx) => {
324
204
  await requireAdmin(ctx);
325
205
  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 };
206
+ try { return { page: await applyPageUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
335
207
  },
336
208
  }),
337
209
  discardChanges: defineAction({
@@ -524,11 +396,15 @@ export const server = {
524
396
  siteLogo: z.string().optional(),
525
397
  siteFavicon: z.string().optional(),
526
398
  siteCover: z.string().optional(),
527
- }),
399
+ // IANA zone ('' = UTC) and BCP 47 locale ('' = en-US), same rules as Settings → General
400
+ timezone: z.string().max(100).refine((tz) => tz === '' || isValidTimeZone(tz), { message: 'Use an IANA timezone like America/New_York.' }).optional(),
401
+ locale: z.string().max(40).refine((l) => { if (l === '') return true; try { return Intl.getCanonicalLocales(l).length === 1; } catch { return false; } }, { message: 'Use a language tag like en-US or es.' }).optional(),
402
+ // unknown keys are an error, not silently dropped
403
+ }).strict(),
528
404
  handler: async (data, ctx) => {
529
405
  await requireAdmin(ctx);
530
406
  const repo = await getRepo(ctx.locals);
531
- await repo.updateSettings(data);
407
+ await repo.updateSettings({ ...data, ...(data.locale ? { locale: Intl.getCanonicalLocales(data.locale)[0] } : {}) });
532
408
  return { ok: true };
533
409
  },
534
410
  }),
@@ -930,6 +806,54 @@ export const server = {
930
806
  return { sources: normalized, upcoming, total: events.length, labels };
931
807
  },
932
808
  }),
809
+ // Schedule (admin month/week grid): every occurrence between two days
810
+ range: defineAction({
811
+ input: z.object({ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) }),
812
+ handler: async ({ from, to }, ctx) => {
813
+ await requireAdmin(ctx);
814
+ const repo = await getRepo(ctx.locals);
815
+ try { return await loadBoard(repo, await repo.getSettings(), from, to); }
816
+ catch (e) { throw new ActionError({ code: 'BAD_REQUEST', message: e instanceof Error ? e.message : 'Couldn’t load the calendar.' }); }
817
+ },
818
+ }),
819
+ // Calendar settings → Defaults / Bookings
820
+ saveSettings: defineAction({
821
+ input: z.object({
822
+ weekStart: z.union([z.literal(0), z.literal(1)]).optional(),
823
+ defaultCalendarId: z.string().max(100).optional(),
824
+ showBookings: z.boolean().optional(),
825
+ approval: z.enum(['auto', 'rules', 'manual']).optional(),
826
+ approvalRules: z.object({
827
+ longerThan: z.number().int().min(0).max(60 * 24 * 60),
828
+ firstTime: z.boolean(),
829
+ moreSeats: z.number().int().min(0).max(100000),
830
+ deadlineHours: z.number().int().min(0).max(24 * 60),
831
+ deadlineAction: z.enum(['approve', 'decline']),
832
+ }).optional(),
833
+ // reset every booking link to "use the site default"
834
+ applyToLinks: z.boolean().optional(),
835
+ }),
836
+ handler: async ({ applyToLinks, ...patch }, ctx) => {
837
+ await requireAdmin(ctx);
838
+ const repo = await getRepo(ctx.locals);
839
+ const settings = await repo.getSettings();
840
+ if (patch.defaultCalendarId) {
841
+ const cal = await repo.getCalendar(patch.defaultCalendarId);
842
+ if (!cal || cal.kind !== 'native') throw new ActionError({ code: 'BAD_REQUEST', message: 'Pick one of your calendars.' });
843
+ }
844
+ const calendar = { ...settings.calendar, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) };
845
+ await repo.updateSettings({ calendar });
846
+ let updated = 0;
847
+ if (applyToLinks) {
848
+ for (const l of await repo.listBookingLinks()) {
849
+ if (l.approval === 'default') continue;
850
+ await repo.updateBookingLink(l.id, { approval: 'default' });
851
+ updated++;
852
+ }
853
+ }
854
+ return { calendar: (await repo.getSettings()).calendar, updated };
855
+ },
856
+ }),
933
857
  },
934
858
 
935
859
  events: {
@@ -946,6 +870,7 @@ export const server = {
946
870
  coverAlt: z.string().optional(),
947
871
  seoTitle: z.string().optional(),
948
872
  seoDesc: z.string().optional(),
873
+ translations: DocTranslations.optional(),
949
874
  status: StatusInput.optional(),
950
875
  featured: z.boolean().optional(),
951
876
  template: z.string().optional(),
@@ -961,26 +886,13 @@ export const server = {
961
886
  calendarIds: z.array(z.string()).max(50).optional(),
962
887
  fields: z.record(z.string().max(2000)).optional(),
963
888
  rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50) }).optional(),
889
+ roomHold: z.object({ resourceIds: z.array(z.string()).max(50), span: z.enum(['event', 'day']), before: z.number().int().min(0).max(1440), after: z.number().int().min(0).max(1440), whileDraft: z.boolean(), keep: z.array(z.string()).max(500).default([]) }).optional(),
964
890
  scheduledAt: nullableTs.optional(),
965
891
  }),
966
892
  handler: async ({ id, ...data }, ctx) => {
967
893
  await requireAdmin(ctx);
968
894
  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 };
895
+ try { return { event: await saveEvent(repo, id, data, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
984
896
  },
985
897
  }),
986
898
  applyUpdate: defineAction({
@@ -988,13 +900,7 @@ export const server = {
988
900
  handler: async ({ id }, ctx) => {
989
901
  await requireAdmin(ctx);
990
902
  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 };
903
+ try { return { event: await applyEventUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
998
904
  },
999
905
  }),
1000
906
  discardChanges: defineAction({
@@ -1017,6 +923,131 @@ export const server = {
1017
923
  return { ok: true };
1018
924
  },
1019
925
  }),
926
+ // Event editor → Rooms: which rooms/people are free at this event's time
927
+ // (for every room, so the picker can label them), and which existing
928
+ // bookings the selected rooms would overlap. Works on unsaved values.
929
+ roomStatus: defineAction({
930
+ input: z.object({
931
+ eventId: z.string().default(''),
932
+ title: z.string().max(300).default(''),
933
+ start: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/),
934
+ end: z.string().max(20).default(''),
935
+ allDay: z.boolean(),
936
+ timezone: z.string().max(100).default(''),
937
+ rrule: z.string().max(500).default(''),
938
+ exdates: z.array(z.string()).max(500).default([]),
939
+ roomHold: z.object({ resourceIds: z.array(z.string()).max(50), span: z.enum(['event', 'day']), before: z.number().int().min(0).max(1440), after: z.number().int().min(0).max(1440), whileDraft: z.boolean(), keep: z.array(z.string()).max(500).default([]) }),
940
+ }),
941
+ handler: async (input, ctx) => {
942
+ await requireAdmin(ctx);
943
+ const repo = await getRepo(ctx.locals);
944
+ const settings = await repo.getSettings();
945
+ if (!bookingsEnabled()) return { rooms: [], holds: '' };
946
+ let times: { start: string; end: string };
947
+ try { times = normalizeEventTimes({ start: input.start, end: input.end, allDay: input.allDay }); }
948
+ catch { throw new ActionError({ code: 'BAD_REQUEST', message: 'Pick a valid date and time.' }); }
949
+ const resources = await repo.listResources();
950
+ const now = Date.now();
951
+ // a stand-in event holding every room, so each one gets a status
952
+ const probe = CalEventSchema.parse({
953
+ id: input.eventId || 'probe', slug: 'probe', title: input.title || 'Event', ...times, allDay: input.allDay,
954
+ timezone: input.timezone, rrule: input.rrule, exdates: input.exdates, status: 'published', startAt: 0, endAt: 0, createdAt: 0, updatedAt: 0,
955
+ roomHold: { ...input.roomHold, resourceIds: resources.map((r) => r.id), keep: input.roomHold.keep },
956
+ });
957
+ const { conflicts, holds } = await holdConflicts(repo, settings, probe, { now });
958
+ const tz = settings.timezone || 'UTC';
959
+ const locale = siteLocale(settings);
960
+ const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
961
+ const time = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
962
+ const range = (a: number, b: number) => { try { return time.formatRange(a, b); } catch { return `${time.format(a)} – ${time.format(b)}`; } };
963
+ const occurrences = new Set(holds.map((h) => h.eventStart)).size;
964
+ const first = holds[0];
965
+ const holdsLabel = first ? (input.roomHold.span === 'day' ? `Holds all day on ${day.format(first.eventStart)}` : `Holds ${range(first.start, first.end)} on ${day.format(first.eventStart)}`) + (occurrences > 1 ? ` and ${occurrences - 1} more date${occurrences === 2 ? '' : 's'}` : '') : '';
966
+ return {
967
+ holds: holdsLabel,
968
+ rooms: resources.map((r) => {
969
+ const mine = conflicts.filter((c) => c.booking.resourceId === r.id);
970
+ return {
971
+ id: r.id, name: r.name, kind: r.kind, capacity: r.capacity,
972
+ datesWithBookings: new Set(mine.map((c) => c.hold.eventStart)).size, dates: occurrences,
973
+ conflicts: mine.slice(0, 20).map((c) => ({
974
+ id: c.booking.id, name: c.booking.kind === 'block' ? `Blocked${c.booking.notes ? ` · ${c.booking.notes}` : ''}` : c.booking.name,
975
+ block: c.booking.kind === 'block', status: c.booking.status,
976
+ when: `${day.format(c.booking.start)} · ${range(c.booking.start, c.booking.end)}`,
977
+ })),
978
+ };
979
+ }),
980
+ };
981
+ },
982
+ }),
983
+ // Event editor → Rooms → "Cancel and email": frees the room for the event
984
+ cancelBookingsForEvent: defineAction({
985
+ input: z.object({ ids: z.array(z.string()).min(1).max(100), message: z.string().max(2000).default('') }),
986
+ handler: async ({ ids, message }, ctx) => {
987
+ await requireAdmin(ctx);
988
+ const repo = await getRepo(ctx.locals);
989
+ const settings = await repo.getSettings();
990
+ const opts = { origin: siteOrigin(settings, ctx.url), locals: ctx.locals, waitUntil: waitUntilFrom(ctx.locals) };
991
+ let cancelled = 0;
992
+ for (const id of ids) {
993
+ const b = await repo.getBooking(id);
994
+ if (!b || (b.status !== 'pending' && b.status !== 'confirmed')) continue;
995
+ try { await setBookingStatus(repo, b, 'cancelled', opts, message.trim() || undefined); cancelled++; } catch { /* changed meanwhile */ }
996
+ }
997
+ return { cancelled };
998
+ },
999
+ }),
1000
+ // Schedule: click a day → title, time, calendar → saved (draft or published)
1001
+ quickCreate: defineAction({
1002
+ input: z.object({
1003
+ title: z.string().max(300),
1004
+ start: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/),
1005
+ end: z.string().regex(/^(\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?)?$/).default(''),
1006
+ allDay: z.boolean(),
1007
+ calendarId: z.string().max(100).default(''),
1008
+ rrule: z.string().max(500).default(''),
1009
+ location: z.string().max(300).default(''),
1010
+ publish: z.boolean().default(false),
1011
+ roomIds: z.array(z.string()).max(50).default([]),
1012
+ }),
1013
+ handler: async (input, ctx) => {
1014
+ await requireAdmin(ctx);
1015
+ const repo = await getRepo(ctx.locals);
1016
+ try { const e = await quickCreate(repo, input, hooksFor(ctx)); return { id: e.id, status: e.status }; } catch (e) { asActionError(e); }
1017
+ },
1018
+ }),
1019
+ // Schedule: drag to another day. `occurrence` + mode 'one' moves a single
1020
+ // repeat; otherwise the whole event moves.
1021
+ move: defineAction({
1022
+ input: z.object({ id: z.string(), days: z.number().int(), mode: z.enum(['series', 'one']).default('series'), occurrence: z.string().max(20).default('') }),
1023
+ handler: async ({ id, days, mode, occurrence }, ctx) => {
1024
+ await requireAdmin(ctx);
1025
+ const repo = await getRepo(ctx.locals);
1026
+ try {
1027
+ const r = mode === 'one' ? await moveOccurrence(repo, id, occurrence, days, hooksFor(ctx)) : await moveSeries(repo, id, days, hooksFor(ctx));
1028
+ return { id: r.event.id, undo: r.undo };
1029
+ } catch (e) { asActionError(e); }
1030
+ },
1031
+ }),
1032
+ undoMove: defineAction({
1033
+ input: z.union([
1034
+ z.object({ kind: z.literal('series'), id: z.string(), days: z.number().int() }),
1035
+ z.object({ kind: z.literal('split'), id: z.string(), seriesId: z.string(), occurrence: z.string().max(20) }),
1036
+ ]),
1037
+ handler: async (undo, ctx) => {
1038
+ await requireAdmin(ctx);
1039
+ const repo = await getRepo(ctx.locals);
1040
+ try { await undoMove(repo, undo, hooksFor(ctx)); return { ok: true }; } catch (e) { asActionError(e); }
1041
+ },
1042
+ }),
1043
+ duplicate: defineAction({
1044
+ input: z.object({ id: z.string() }),
1045
+ handler: async ({ id }, ctx) => {
1046
+ await requireAdmin(ctx);
1047
+ const repo = await getRepo(ctx.locals);
1048
+ try { return { id: (await duplicateEvent(repo, id)).id }; } catch (e) { asActionError(e); }
1049
+ },
1050
+ }),
1020
1051
  // editor pickers (event card): every event with its next date
1021
1052
  list: defineAction({
1022
1053
  input: z.object({ publishedOnly: z.boolean().default(false) }),
@@ -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'}