@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
@@ -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>
@@ -0,0 +1,392 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { AdminPushState } from '../lib/push/api';
3
+ import type { PushAuto, PushNotification } from '../lib/push/types';
4
+ import '../styles/mobile-app.css';
5
+
6
+ // Admin → Mobile app: send a notification to the site's own app, choose the
7
+ // automatic notices, see who is subscribed and what was delivered. Owner only;
8
+ // the page exists only when acms({ mobile: { push } }) is on.
9
+
10
+ const ENDPOINT = '/api/push/admin';
11
+ const CUSTOM = '__custom';
12
+ const SOON_MINUTES = [10, 15, 30, 60, 120, 1440];
13
+
14
+ async function api<T>(body?: Record<string, unknown>): Promise<T> {
15
+ const res = await fetch(ENDPOINT, {
16
+ method: body ? 'POST' : 'GET', credentials: 'same-origin',
17
+ headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
18
+ body: body ? JSON.stringify(body) : undefined,
19
+ });
20
+ const data = await res.json().catch(() => null);
21
+ if (!res.ok) throw new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
22
+ return data as T;
23
+ }
24
+ const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
25
+ const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString()} ${n === 1 ? one : many}`;
26
+ const minutesLabel = (m: number) => (m >= 1440 ? '1 day' : m >= 60 ? `${m / 60} hour${m === 60 ? '' : 's'}` : `${m} minutes`);
27
+
28
+ function ago(ms: number, now: number) {
29
+ const s = Math.round((now - ms) / 1000);
30
+ if (s < 60) return 'Just now';
31
+ if (s < 3600) return `${Math.floor(s / 60)} min ago`;
32
+ const d = new Date(ms);
33
+ const sameDay = new Date(now).toDateString() === d.toDateString();
34
+ return sameDay ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
35
+ : d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
36
+ }
37
+
38
+ const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon' };
39
+
40
+ export default function MobileApp() {
41
+ const [state, setState] = useState<AdminPushState | null>(null);
42
+ const [loadError, setLoadError] = useState('');
43
+ useEffect(() => { api<AdminPushState>().then(setState, (e) => setLoadError(errText(e))); }, []);
44
+ if (loadError) return <p className="flash error">{loadError}</p>;
45
+ if (!state) return <div className="ma-skel" aria-busy="true" aria-label="Loading"><i /><i /><i /></div>;
46
+ return <MobileAppView state={state} setState={setState} />;
47
+ }
48
+
49
+ function MobileAppView({ state, setState }: { state: AdminPushState; setState: (s: AdminPushState) => void }) {
50
+ const topicLabel = useMemo(() => {
51
+ const map = new Map(state.topics.map((t) => [t.id, t.label]));
52
+ for (const c of state.collections) if (!map.has(`collection:${c.key}`)) map.set(`collection:${c.key}`, c.label);
53
+ for (const c of state.calendars) if (!map.has(`calendar:${c.slug}`)) map.set(`calendar:${c.slug}`, c.name);
54
+ map.set('*', 'Everyone');
55
+ return (id: string) => map.get(id) ?? id;
56
+ }, [state]);
57
+ const { stats } = state;
58
+ return (
59
+ <div className="ma">
60
+ <div className="kpis ma-kpis">
61
+ <div className="kpi"><small>Devices</small><b>{stats.devices.toLocaleString()}</b></div>
62
+ <div className="kpi"><small>iPhone and iPad</small><b>{stats.platforms.ios.toLocaleString()}</b></div>
63
+ <div className="kpi"><small>Android</small><b>{stats.platforms.android.toLocaleString()}</b></div>
64
+ <div className="kpi"><small>Opened in the last 30 days</small><b>{stats.active30.toLocaleString()}</b></div>
65
+ </div>
66
+ {stats.devices === 0 && (
67
+ <div className="notice ma-notice">
68
+ <div>
69
+ <b>No app has signed up for notifications yet.</b>
70
+ <p>Add the push recipe to your Expo app, then open it on a phone and turn notifications on. From your site folder: <code>npx acms mobile recipe add push</code></p>
71
+ </div>
72
+ </div>
73
+ )}
74
+ <Composer state={state} setState={setState} topicLabel={topicLabel} />
75
+ <div className="ma-grid ma-grid-2">
76
+ <Automatic state={state} setState={setState} />
77
+ <Audience state={state} topicLabel={topicLabel} />
78
+ </div>
79
+ <DeliveryLog log={state.log} topicLabel={topicLabel} />
80
+ <p className="hint ma-foot">
81
+ Sent through Expo’s push service{state.expo.customEndpoint ? ' (custom endpoint from ACMS_EXPO_PUSH_URL)' : ''}.{' '}
82
+ {state.expo.accessToken ? 'Expo access token is set.' : 'No Expo access token set; only needed when enhanced push security is on for your Expo project (EXPO_ACCESS_TOKEN).'}{' '}
83
+ Devices that don’t open the app for {state.retentionDays} days are removed.
84
+ </p>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ // ---- composer -----------------------------------------------------------------
90
+
91
+ function Composer({ state, setState, topicLabel }: { state: AdminPushState; setState: (s: AdminPushState) => void; topicLabel: (id: string) => string }) {
92
+ const [title, setTitle] = useState('');
93
+ const [body, setBody] = useState('');
94
+ const [linkRef, setLinkRef] = useState('');
95
+ const [customUrl, setCustomUrl] = useState('');
96
+ const [mode, setMode] = useState<'all' | 'topics'>('all');
97
+ const [topics, setTopics] = useState<string[]>([]);
98
+ const [count, setCount] = useState<number | null>(state.stats.devices);
99
+ const [busy, setBusy] = useState(false);
100
+ const [msg, setMsg] = useState<{ ok?: string; err?: string }>({});
101
+ const dialog = useRef<HTMLDialogElement>(null);
102
+
103
+ const audience = mode === 'all' ? ['*'] : topics;
104
+ const target = state.links.find((l) => l.ref === linkRef);
105
+ const url = linkRef === CUSTOM ? customUrl.trim() : target?.url ?? '';
106
+ const groups = [...new Set(state.links.map((l) => l.group))];
107
+
108
+ useEffect(() => {
109
+ if (mode === 'all') { setCount(state.stats.devices); return; }
110
+ if (!topics.length) { setCount(0); return; }
111
+ let live = true;
112
+ api<{ recipients: number }>({ action: 'count', audience: topics }).then((r) => live && setCount(r.recipients), () => live && setCount(null));
113
+ return () => { live = false; };
114
+ }, [mode, topics.join('|'), state.stats.devices]);
115
+
116
+ const toggle = (id: string) => setTopics((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
117
+ const problem = !title.trim() ? 'Add a title.' : mode === 'topics' && !topics.length ? 'Choose at least one topic.'
118
+ : linkRef === CUSTOM && url && !/^(\/(?!\/)|https?:\/\/)/i.test(url) ? 'Links start with / or https://.' : '';
119
+
120
+ const send = async () => {
121
+ setBusy(true); setMsg({});
122
+ try {
123
+ const r = await api<{ notification: PushNotification; state: AdminPushState }>({ action: 'send', title: title.trim(), body: body.trim(), url, audience });
124
+ dialog.current?.close();
125
+ const n = r.notification;
126
+ setMsg(n.status === 'failed' ? { err: `Not delivered: ${n.error || 'the push service refused it.'}` }
127
+ : { ok: n.recipients ? `Sent to ${plural(n.ok, 'device')}${n.failed ? ` · ${n.failed} failed` : ''}.` : 'Saved, but no device matched this audience.' });
128
+ if (n.status !== 'failed') { setTitle(''); setBody(''); setLinkRef(''); setCustomUrl(''); }
129
+ setState(r.state);
130
+ } catch (e) {
131
+ dialog.current?.close();
132
+ setMsg({ err: errText(e) });
133
+ } finally { setBusy(false); }
134
+ };
135
+
136
+ return (
137
+ <section className="card card-pad ma-compose" aria-labelledby="ma-compose-h">
138
+ <div className="ma-compose-form">
139
+ <header className="ma-head">
140
+ <h2 id="ma-compose-h">New notification</h2>
141
+ <p>Goes to the lock screen of everyone you choose. Tapping it opens the link in your app.</p>
142
+ </header>
143
+ <label className="field">
144
+ <span>Title <em className="ma-count">{title.length}/80</em></span>
145
+ <input type="text" value={title} maxLength={80} placeholder="Service times changed this Sunday" onChange={(e) => setTitle(e.target.value)} />
146
+ </label>
147
+ <label className="field">
148
+ <span>Message <em className="ma-count">{body.length}/240</em></span>
149
+ <textarea rows={3} value={body} maxLength={240} placeholder="We meet at 10:30 this week only. See you there." onChange={(e) => setBody(e.target.value)} />
150
+ </label>
151
+ <label className="field">
152
+ <span>Opens</span>
153
+ <select value={linkRef} onChange={(e) => setLinkRef(e.target.value)}>
154
+ <option value="">The app’s home screen</option>
155
+ {groups.map((g) => (
156
+ <optgroup key={g} label={g}>
157
+ {state.links.filter((l) => l.group === g).map((l) => <option key={l.ref} value={l.ref}>{l.label}</option>)}
158
+ </optgroup>
159
+ ))}
160
+ <option value={CUSTOM}>Custom address…</option>
161
+ </select>
162
+ </label>
163
+ {linkRef === CUSTOM && (
164
+ <label className="field">
165
+ <span>Address</span>
166
+ <input className="mono" type="url" value={customUrl} placeholder="/visit or https://example.com" onChange={(e) => setCustomUrl(e.target.value)} />
167
+ </label>
168
+ )}
169
+ {target && <p className="hint ma-linkhint">Opens {target.url}, in the reader’s language when a translation exists.</p>}
170
+ <fieldset className="ma-audience">
171
+ <legend>Who gets it</legend>
172
+ <div className="seg" role="tablist" aria-label="Audience">
173
+ <button type="button" role="tab" aria-selected={mode === 'all'} onClick={() => setMode('all')}>Everyone</button>
174
+ <button type="button" role="tab" aria-selected={mode === 'topics'} onClick={() => setMode('topics')}>People following…</button>
175
+ </div>
176
+ {mode === 'topics' && (
177
+ <div className="ma-topics">
178
+ {state.topics.map((t) => (
179
+ <label key={t.id} className={`ma-opt${topics.includes(t.id) ? ' on' : ''}`}>
180
+ <input type="checkbox" checked={topics.includes(t.id)} onChange={() => toggle(t.id)} />
181
+ <span>{t.label}</span>
182
+ <em>{(state.stats.topics[t.id] ?? 0).toLocaleString()}</em>
183
+ </label>
184
+ ))}
185
+ </div>
186
+ )}
187
+ </fieldset>
188
+ {msg.ok && <p className="flash" role="status">{msg.ok}</p>}
189
+ {msg.err && <p className="flash error" role="alert">{msg.err}</p>}
190
+ <div className="ma-send">
191
+ <span className="ma-reach">{count === null ? 'Counting…' : count === 0 ? 'No devices match yet' : `Reaches ${plural(count, 'device')}`}</span>
192
+ <button type="button" className="btn primary" disabled={!!problem || busy} title={problem || undefined} onClick={() => dialog.current?.showModal()}>Send…</button>
193
+ </div>
194
+ </div>
195
+ <LockScreen state={state} title={title} body={body} />
196
+ <dialog className="modal" ref={dialog} aria-labelledby="ma-confirm-h">
197
+ <div className="modal-head"><span id="ma-confirm-h">Send to {count === null ? 'these devices' : plural(count, 'device')}?</span>
198
+ <button type="button" className="modal-x" onClick={() => dialog.current?.close()} aria-label="Close">✕</button></div>
199
+ <div className="modal-body">
200
+ <div className="ma-confirm">
201
+ <b>{title || 'Untitled'}</b>
202
+ {body && <p>{body}</p>}
203
+ <small>{mode === 'all' ? 'Everyone with notifications on' : topics.map(topicLabel).join(', ')}{url ? ` · opens ${url}` : ''}</small>
204
+ </div>
205
+ <p className="hint modal-note">It arrives within seconds and can’t be recalled.</p>
206
+ <div className="modal-foot">
207
+ <button type="button" className="btn" onClick={() => dialog.current?.close()}>Cancel</button>
208
+ <button type="button" className="btn primary" disabled={busy} onClick={send}>{busy ? 'Sending…' : 'Send now'}</button>
209
+ </div>
210
+ </div>
211
+ </dialog>
212
+ </section>
213
+ );
214
+ }
215
+
216
+ // A phone lock screen with the notification as it will show. Device preview:
217
+ // fixed neutral colors (like the site previews), not admin tokens.
218
+ function LockScreen({ state, title, body }: { state: AdminPushState; title: string; body: string }) {
219
+ const [now, setNow] = useState(() => new Date());
220
+ useEffect(() => { const t = setInterval(() => setNow(new Date()), 30_000); return () => clearInterval(t); }, []);
221
+ return (
222
+ <figure className="ma-preview" aria-label="Lock screen preview">
223
+ <div className="ma-phone">
224
+ <div className="ma-phone-screen">
225
+ <div className="ma-clock">
226
+ <small>{now.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}</small>
227
+ <b>{now.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).replace(/\s?[AP]M$/i, '')}</b>
228
+ </div>
229
+ <div className="ma-note">
230
+ <div className="ma-note-top">
231
+ <span className="ma-appicon" aria-hidden="true">
232
+ {state.site.icon ? <img src={state.site.icon} alt="" /> : (
233
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0" /></svg>
234
+ )}
235
+ </span>
236
+ <span className="ma-appname">{state.site.name || 'Your app'}</span>
237
+ <span className="ma-now">now</span>
238
+ </div>
239
+ <b className="ma-note-title">{title.trim() || 'Your title'}</b>
240
+ <p className="ma-note-body">{body.trim() || 'Your message appears here.'}</p>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ <figcaption className="hint">Phones shorten long text; keep the key words first.</figcaption>
245
+ </figure>
246
+ );
247
+ }
248
+
249
+ // ---- automatic ----------------------------------------------------------------
250
+
251
+ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: AdminPushState) => void }) {
252
+ const [auto, setAuto] = useState<PushAuto>(state.auto);
253
+ const [busy, setBusy] = useState(false);
254
+ const [msg, setMsg] = useState<{ ok?: string; err?: string }>({});
255
+ const dirty = JSON.stringify(auto) !== JSON.stringify(state.auto);
256
+ const flip = (list: string[], id: string) => (list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);
257
+ const save = async () => {
258
+ setBusy(true); setMsg({});
259
+ try {
260
+ const r = await api<{ auto: PushAuto; state: AdminPushState }>({ action: 'saveAuto', auto });
261
+ setState(r.state); setAuto(r.auto); setMsg({ ok: 'Saved.' });
262
+ } catch (e) { setMsg({ err: errText(e) }); } finally { setBusy(false); }
263
+ };
264
+ const calendarChoices = (key: 'newEvent' | 'startingSoon') => {
265
+ const list = auto[key].calendars;
266
+ const all = list.includes('*');
267
+ const set = (calendars: string[]) => setAuto({ ...auto, [key]: { ...auto[key], calendars } });
268
+ return (
269
+ <div className="ma-choices">
270
+ <label className="ma-check"><input type="checkbox" checked={all} onChange={() => set(all ? [] : ['*'])} /><span>All public calendars</span></label>
271
+ {state.calendars.map((c) => (
272
+ <label key={c.id} className="ma-check"><input type="checkbox" disabled={all} checked={all || list.includes(c.id)} onChange={() => set(flip(list, c.id))} />
273
+ <span><i className="ma-dot" style={{ background: c.color || 'var(--faint)' }} />{c.name}</span></label>
274
+ ))}
275
+ </div>
276
+ );
277
+ };
278
+ return (
279
+ <section className="card card-pad" aria-labelledby="ma-auto-h">
280
+ <header className="ma-head">
281
+ <h2 id="ma-auto-h">Automatic notifications</h2>
282
+ <p>Sent once, when something goes live for the first time. Edits never notify again.</p>
283
+ </header>
284
+ <div className="ma-rules">
285
+ <div className="ma-rule">
286
+ <div className="ma-rule-head"><b>New post published</b><small>Title and summary, opens the post.</small></div>
287
+ <div className="ma-choices">
288
+ {state.collections.map((c) => (
289
+ <label key={c.key} className="ma-check"><input type="checkbox" checked={auto.newPost.collections.includes(c.key)}
290
+ onChange={() => setAuto({ ...auto, newPost: { collections: flip(auto.newPost.collections, c.key) } })} /><span>{c.label}</span></label>
291
+ ))}
292
+ </div>
293
+ </div>
294
+ {state.calendarEnabled && (
295
+ <>
296
+ <div className="ma-rule">
297
+ <div className="ma-rule-head"><b>New event published</b><small>Name, date and place, opens the event.</small></div>
298
+ {calendarChoices('newEvent')}
299
+ </div>
300
+ <div className="ma-rule">
301
+ <div className="ma-rule-head"><b>Starting soon</b><small>A reminder before each event time, once per date.</small></div>
302
+ {calendarChoices('startingSoon')}
303
+ <label className="field ma-lead">
304
+ <span>Send</span>
305
+ <select value={auto.startingSoon.minutes} onChange={(e) => setAuto({ ...auto, startingSoon: { ...auto.startingSoon, minutes: Number(e.target.value) } })}>
306
+ {[...new Set([...SOON_MINUTES, auto.startingSoon.minutes])].sort((a, b) => a - b).map((m) => <option key={m} value={m}>{minutesLabel(m)} before</option>)}
307
+ </select>
308
+ </label>
309
+ </div>
310
+ </>
311
+ )}
312
+ </div>
313
+ {msg.err && <p className="flash error" role="alert">{msg.err}</p>}
314
+ <div className="form-actions">
315
+ <button type="button" className="btn" disabled={!dirty || busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</button>
316
+ {dirty ? <button type="button" className="btn ghost" onClick={() => { setAuto(state.auto); setMsg({}); }}>Discard</button>
317
+ : msg.ok && <span className="ma-saved" role="status">{msg.ok}</span>}
318
+ </div>
319
+ </section>
320
+ );
321
+ }
322
+
323
+ // ---- audience -----------------------------------------------------------------
324
+
325
+ function Audience({ state, topicLabel }: { state: AdminPushState; topicLabel: (id: string) => string }) {
326
+ const { stats } = state;
327
+ const rows = [...new Set([...state.topics.map((t) => t.id), ...Object.keys(stats.topics)])]
328
+ .map((id) => ({ id, n: stats.topics[id] ?? 0 }));
329
+ const max = Math.max(1, ...rows.map((r) => r.n));
330
+ return (
331
+ <section className="card card-pad" aria-labelledby="ma-aud-h">
332
+ <header className="ma-head">
333
+ <h2 id="ma-aud-h">Following</h2>
334
+ <p>Topics people turned on in the app. Everyone gets “Everyone” notifications.</p>
335
+ </header>
336
+ <ul className="ma-bars">
337
+ {rows.map((r) => (
338
+ <li key={r.id}>
339
+ <span className="ma-bar-label">{topicLabel(r.id)}<code>{r.id}</code></span>
340
+ <span className="ma-bar" aria-hidden="true"><i style={{ width: `${(r.n / max) * 100}%` }} /></span>
341
+ <b>{r.n.toLocaleString()}</b>
342
+ </li>
343
+ ))}
344
+ </ul>
345
+ <p className="hint ma-aud-note">Your app decides which topics to offer; it reads them from <code>/api/v1/push/topics</code>.</p>
346
+ </section>
347
+ );
348
+ }
349
+
350
+ // ---- delivery log -------------------------------------------------------------
351
+
352
+ function DeliveryLog({ log, topicLabel }: { log: PushNotification[]; topicLabel: (id: string) => string }) {
353
+ const now = Date.now();
354
+ return (
355
+ <section className="card ma-log" aria-labelledby="ma-log-h">
356
+ <header className="ma-head card-pad ma-log-head">
357
+ <h2 id="ma-log-h">Delivery log</h2>
358
+ <p>The last 50 notifications. Failures update after Expo reports back (about 15 minutes).</p>
359
+ </header>
360
+ {!log.length ? <p className="ma-empty">Nothing sent yet.</p> : (
361
+ <div className="ma-scroll">
362
+ <table className="list ma-table">
363
+ <thead><tr><th>Sent</th><th>Notification</th><th>Audience</th><th>From</th><th className="num">Delivered</th></tr></thead>
364
+ <tbody>
365
+ {log.map((n) => (
366
+ <tr key={n.id}>
367
+ <td data-l="Sent" className="ma-when">{ago(n.createdAt, now)}</td>
368
+ <td data-l="Notification">
369
+ <div className="t-title">{n.title}</div>
370
+ {n.body && <div className="ma-body">{n.body}</div>}
371
+ {n.url && <div className="t-sub ma-url">{n.url}</div>}
372
+ </td>
373
+ <td data-l="Audience" className="ma-aud">{n.topics.map(topicLabel).join(', ')}</td>
374
+ <td data-l="From">{SOURCE[n.source]}{n.source === 'manual' && n.author ? ` ${n.author}` : n.source === 'manual' ? ' an owner' : ''}</td>
375
+ <td data-l="Delivered" className="num ma-result">
376
+ {n.status === 'sending' ? <span className="ma-st" data-tone="info">Sending…</span>
377
+ : !n.recipients ? <span className="ma-st">No devices</span>
378
+ : <>
379
+ <span className="ma-st" data-tone={n.status === 'failed' ? 'bad' : n.failed ? 'warn' : 'ok'}>{n.ok.toLocaleString()} of {n.recipients.toLocaleString()}</span>
380
+ {(n.failed > 0 || n.removed > 0) && <small>{n.failed ? `${n.failed} failed` : ''}{n.failed && n.removed ? ' · ' : ''}{n.removed ? `${n.removed} removed` : ''}</small>}
381
+ {n.error && <small className="ma-err" title={n.error}>{n.error}</small>}
382
+ </>}
383
+ </td>
384
+ </tr>
385
+ ))}
386
+ </tbody>
387
+ </table>
388
+ </div>
389
+ )}
390
+ </section>
391
+ );
392
+ }