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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/sections.mjs +186 -0
  9. package/cli/themes.mjs +52 -8
  10. package/docs/mobile.md +527 -0
  11. package/docs/sections.md +203 -0
  12. package/docs/theme-authoring.md +1 -1
  13. package/integration.mjs +55 -1
  14. package/mobile-template/client/AGENTS.md +97 -0
  15. package/mobile-template/client/README.md +65 -0
  16. package/mobile-template/client/app/_layout.tsx +56 -0
  17. package/mobile-template/client/app/index.tsx +21 -0
  18. package/mobile-template/client/app.json +21 -0
  19. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  20. package/mobile-template/client/eas.json +21 -0
  21. package/mobile-template/client/env.example +3 -0
  22. package/mobile-template/client/gitignore +42 -0
  23. package/mobile-template/client/package.json +38 -0
  24. package/mobile-template/client/sections/index.ts +11 -0
  25. package/mobile-template/client/tsconfig.json +7 -0
  26. package/mobile-template/editor/AGENTS.md +66 -0
  27. package/mobile-template/editor/README.md +73 -0
  28. package/mobile-template/editor/app/_layout.tsx +76 -0
  29. package/mobile-template/editor/app/auth.tsx +13 -0
  30. package/mobile-template/editor/app/index.tsx +55 -0
  31. package/mobile-template/editor/app.json +21 -0
  32. package/mobile-template/editor/components/Screen.tsx +51 -0
  33. package/mobile-template/editor/components/SignIn.tsx +36 -0
  34. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  35. package/mobile-template/editor/eas.json +21 -0
  36. package/mobile-template/editor/env.example +5 -0
  37. package/mobile-template/editor/gitignore +42 -0
  38. package/mobile-template/editor/lib/appearance.tsx +12 -0
  39. package/mobile-template/editor/lib/config.ts +17 -0
  40. package/mobile-template/editor/lib/format.ts +24 -0
  41. package/mobile-template/editor/lib/tools.ts +16 -0
  42. package/mobile-template/editor/package.json +40 -0
  43. package/mobile-template/editor/tools/README.md +2 -0
  44. package/mobile-template/editor/tsconfig.json +7 -0
  45. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  46. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  47. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  48. package/mobile-template/recipes/booking/recipe.json +17 -0
  49. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  50. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  51. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  52. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  54. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  55. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  56. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  57. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  58. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  59. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  60. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  62. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  63. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  64. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  65. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  67. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  68. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  69. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  70. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  72. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  73. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  74. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  75. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  76. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  77. package/mobile-template/recipes/events/recipe.json +15 -0
  78. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  79. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  80. package/mobile-template/recipes/news/recipe.json +13 -0
  81. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  82. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  83. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  84. package/mobile-template/recipes/push/recipe.json +20 -0
  85. package/package.json +4 -2
  86. package/section-library/README.md +16 -0
  87. package/section-library/contact/Contact.astro +49 -0
  88. package/section-library/contact/section.json +106 -0
  89. package/section-library/cta-band/CtaBand.astro +28 -0
  90. package/section-library/cta-band/section.json +51 -0
  91. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  92. package/section-library/feature-grid/section.json +107 -0
  93. package/section-library/gallery/Gallery.astro +29 -0
  94. package/section-library/gallery/section.json +51 -0
  95. package/section-library/hero-banner/HeroBanner.astro +42 -0
  96. package/section-library/hero-banner/section.json +81 -0
  97. package/section-library/latest-posts/LatestPosts.astro +22 -0
  98. package/section-library/latest-posts/section.json +37 -0
  99. package/section-library/pricing/Pricing.astro +40 -0
  100. package/section-library/pricing/section.json +117 -0
  101. package/section-library/questions/Questions.astro +38 -0
  102. package/section-library/questions/section.json +69 -0
  103. package/section-library/service-times/ServiceTimes.astro +42 -0
  104. package/section-library/service-times/section.json +127 -0
  105. package/section-library/team/Team.astro +45 -0
  106. package/section-library/team/section.json +88 -0
  107. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  108. package/section-library/upcoming-events/section.json +51 -0
  109. package/src/actions/index.ts +19 -171
  110. package/src/components/AiSettings.tsx +45 -5
  111. package/src/components/DesignManager.tsx +112 -16
  112. package/src/components/MobileApp.tsx +392 -0
  113. package/src/components/ThemeSections.astro +40 -21
  114. package/src/components/ThemeSectionsEditor.tsx +757 -229
  115. package/src/components/editor/PostEditor.tsx +174 -10
  116. package/src/components/import/ImportExport.tsx +4 -1
  117. package/src/components/import/runner.ts +1 -0
  118. package/src/components/sections/SectionFields.tsx +560 -0
  119. package/src/components/sections/SectionPicker.tsx +120 -0
  120. package/src/components/sections/TranslationPanel.tsx +143 -0
  121. package/src/components/sections/checks.ts +64 -0
  122. package/src/layouts/Admin.astro +6 -1
  123. package/src/layouts/Site.astro +107 -19
  124. package/src/lib/agent-discovery.ts +11 -7
  125. package/src/lib/agent.ts +35 -2
  126. package/src/lib/ai-schema.ts +5 -0
  127. package/src/lib/ai.ts +5 -0
  128. package/src/lib/api.ts +12 -0
  129. package/src/lib/content-service.ts +198 -0
  130. package/src/lib/design-service.ts +153 -5
  131. package/src/lib/drafts.ts +3 -3
  132. package/src/lib/editor/auth.ts +264 -0
  133. package/src/lib/editor/bookings.ts +60 -0
  134. package/src/lib/editor/config.ts +25 -0
  135. package/src/lib/editor/documents.ts +227 -0
  136. package/src/lib/editor/http.ts +48 -0
  137. package/src/lib/editor/me.ts +30 -0
  138. package/src/lib/editor/migration.ts +57 -0
  139. package/src/lib/editor/next.ts +11 -0
  140. package/src/lib/editor/openapi.ts +124 -0
  141. package/src/lib/editor/schemas.ts +91 -0
  142. package/src/lib/editor/sections.ts +96 -0
  143. package/src/lib/editor/store.ts +184 -0
  144. package/src/lib/editor/token-route.ts +74 -0
  145. package/src/lib/editor/translations.ts +163 -0
  146. package/src/lib/email/accounts.ts +2 -1
  147. package/src/lib/email/types.ts +2 -1
  148. package/src/lib/home-source.ts +34 -0
  149. package/src/lib/i18n.ts +34 -0
  150. package/src/lib/import/apply.ts +31 -2
  151. package/src/lib/import/bundle.ts +4 -0
  152. package/src/lib/import/export.ts +1 -0
  153. package/src/lib/import/ghost.ts +1 -0
  154. package/src/lib/media/upload.ts +83 -0
  155. package/src/lib/mobile/associations.ts +25 -0
  156. package/src/lib/mobile/calendar.ts +19 -11
  157. package/src/lib/mobile/config.mjs +59 -0
  158. package/src/lib/mobile/content.ts +18 -13
  159. package/src/lib/mobile/embed.ts +83 -0
  160. package/src/lib/mobile/http.ts +8 -6
  161. package/src/lib/mobile/locale.ts +18 -0
  162. package/src/lib/mobile/openapi.ts +111 -0
  163. package/src/lib/mobile/pages.ts +71 -0
  164. package/src/lib/mobile/route.ts +3 -3
  165. package/src/lib/mobile/schemas.ts +60 -0
  166. package/src/lib/mobile/site.ts +41 -0
  167. package/src/lib/push/api.ts +158 -0
  168. package/src/lib/push/config.ts +20 -0
  169. package/src/lib/push/expo.ts +76 -0
  170. package/src/lib/push/migration.ts +60 -0
  171. package/src/lib/push/service.ts +302 -0
  172. package/src/lib/push/store.ts +144 -0
  173. package/src/lib/push/types.ts +43 -0
  174. package/src/lib/repo/migrations.ts +11 -0
  175. package/src/lib/repo/sql.ts +40 -7
  176. package/src/lib/repo/types.ts +9 -3
  177. package/src/lib/roles.ts +5 -0
  178. package/src/lib/scheduler.ts +3 -0
  179. package/src/lib/schema.ts +27 -1
  180. package/src/lib/section-ai.ts +55 -0
  181. package/src/lib/section-targets.ts +62 -0
  182. package/src/lib/sections-agent.ts +146 -0
  183. package/src/lib/sections.ts +217 -0
  184. package/src/lib/theme.ts +16 -1
  185. package/src/lib/translate.ts +97 -0
  186. package/src/lib/translations.ts +39 -0
  187. package/src/middleware.ts +32 -0
  188. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  189. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  190. package/src/pages/[...slug].astro +8 -3
  191. package/src/pages/admin/app-auth.astro +159 -0
  192. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  193. package/src/pages/admin/calendar/events/[id].astro +9 -0
  194. package/src/pages/admin/login.astro +6 -2
  195. package/src/pages/admin/logout.ts +5 -2
  196. package/src/pages/admin/mobile.astro +18 -0
  197. package/src/pages/admin/pages/[id].astro +62 -0
  198. package/src/pages/admin/section-preview.astro +61 -0
  199. package/src/pages/admin/users.astro +107 -11
  200. package/src/pages/api/agent/docs.ts +6 -2
  201. package/src/pages/api/agent/index.ts +1 -1
  202. package/src/pages/api/ai/settings.ts +7 -4
  203. package/src/pages/api/design/ai.ts +15 -0
  204. package/src/pages/api/design/index.ts +12 -42
  205. package/src/pages/api/design/translate.ts +15 -0
  206. package/src/pages/api/live/thumb.ts +1 -1
  207. package/src/pages/api/mcp.ts +2 -2
  208. package/src/pages/api/media/upload.ts +10 -78
  209. package/src/pages/api/push/admin.ts +27 -0
  210. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  211. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  212. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  213. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  214. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  215. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  216. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  217. package/src/pages/api/v1/admin/me.ts +6 -0
  218. package/src/pages/api/v1/admin/media/index.ts +32 -0
  219. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  220. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  221. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  222. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  223. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  224. package/src/pages/api/v1/auth/revoke.ts +5 -0
  225. package/src/pages/api/v1/auth/token.ts +5 -0
  226. package/src/pages/api/v1/devices/[token].ts +20 -0
  227. package/src/pages/api/v1/devices/index.ts +17 -0
  228. package/src/pages/api/v1/openapi.json.ts +7 -0
  229. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  230. package/src/pages/api/v1/pages/home.ts +7 -0
  231. package/src/pages/api/v1/push/topics.ts +7 -0
  232. package/src/pages/api/v1/site.ts +7 -0
  233. package/src/pages/api/v1/theme.ts +7 -0
  234. package/src/pages/blog/[slug].astro +8 -2
  235. package/src/pages/blog/index.astro +11 -4
  236. package/src/pages/embed/section/[...path].astro +42 -0
  237. package/src/pages/events/[slug].astro +7 -2
  238. package/src/pages/index.astro +11 -5
  239. package/src/pages/sitemap.xml.ts +8 -0
  240. package/src/styles/admin.css +69 -0
  241. package/src/styles/editor.css +21 -0
  242. package/src/styles/mobile-app.css +151 -0
  243. package/src/styles/sections.css +7 -1
  244. package/src/styles/theme-editor.css +255 -0
  245. package/src/theme-sdk/contract.d.mts +2193 -89
  246. package/src/theme-sdk/contract.mjs +77 -20
  247. package/src/theme-sdk/fields.d.mts +88 -0
  248. package/src/theme-sdk/fields.mjs +326 -0
  249. package/src/theme-sdk/index.ts +10 -2
  250. package/src/theme-sdk/tokens.d.mts +11 -0
  251. package/src/virtual.d.ts +1 -0
@@ -0,0 +1,24 @@
1
+ // Dates for staff screens, in the device's language and time zone.
2
+ export function shortDate(iso: string | null | undefined, locale?: string): string {
3
+ if (!iso) return '';
4
+ const d = new Date(iso);
5
+ if (Number.isNaN(d.getTime())) return '';
6
+ const sameYear = d.getFullYear() === new Date().getFullYear();
7
+ return d.toLocaleDateString(locale, { month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }) });
8
+ }
9
+ export function dateTime(iso: string | null | undefined, locale?: string): string {
10
+ if (!iso) return '';
11
+ const d = new Date(iso);
12
+ if (Number.isNaN(d.getTime())) return '';
13
+ return d.toLocaleString(locale, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
14
+ }
15
+ /** "just now", "5 min ago", "3 h ago", else a short date */
16
+ export function ago(iso: string | null | undefined, now = Date.now()): string {
17
+ if (!iso) return '';
18
+ const ms = now - new Date(iso).getTime();
19
+ if (!Number.isFinite(ms)) return '';
20
+ if (ms < 60_000) return 'just now';
21
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} min ago`;
22
+ if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)} h ago`;
23
+ return shortDate(iso);
24
+ }
@@ -0,0 +1,16 @@
1
+ // Screens the app offers on Home. Each recipe adds one file to tools/
2
+ // (e.g. tools/content.ts) exporting { title, description, href, order }, so
3
+ // Home lists it without you editing this file. Add your own the same way.
4
+ export interface Tool { title: string; description: string; href: string; order?: number }
5
+
6
+ // require.context is Metro's static folder import (enabled with expo-router)
7
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
8
+ // @ts-ignore
9
+ const context = require.context('../tools', false, /\.tsx?$/);
10
+
11
+ export const tools: (Tool & { name: string })[] = context.keys()
12
+ .map((key: string) => ({ name: key.replace(/^\.\/|\.tsx?$/g, ''), ...(context(key).default as Tool) }))
13
+ .filter((t: Tool) => t && typeof t.href === 'string')
14
+ .sort((a: Tool, b: Tool) => (a.order ?? 50) - (b.order ?? 50) || a.title.localeCompare(b.title));
15
+
16
+ export const hasTool = (name: string) => tools.some((t) => t.name === name);
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "__ACMS_APP_SLUG__",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "main": "expo-router/entry",
6
+ "acms": { "app": "editor", "template": 1 },
7
+ "scripts": {
8
+ "start": "expo start",
9
+ "android": "expo start --android",
10
+ "ios": "expo start --ios",
11
+ "web": "expo start --web",
12
+ "typecheck": "tsc --noEmit",
13
+ "doctor": "npx expo-doctor"
14
+ },
15
+ "dependencies": {
16
+ "@acms-subir/client": "^0.1.0",
17
+ "@acms-subir/native": "^0.1.0",
18
+ "@tanstack/react-query": "^5.104.0",
19
+ "expo": "~57.0.26",
20
+ "expo-constants": "~57.0.20",
21
+ "expo-crypto": "~57.0.3",
22
+ "expo-device": "~57.0.2",
23
+ "expo-linking": "~57.0.11",
24
+ "expo-router": "~57.0.24",
25
+ "expo-secure-store": "~57.0.4",
26
+ "expo-status-bar": "~57.0.1",
27
+ "expo-system-ui": "~57.0.4",
28
+ "expo-web-browser": "~57.0.3",
29
+ "react": "19.2.3",
30
+ "react-dom": "19.2.3",
31
+ "react-native": "0.86.3",
32
+ "react-native-safe-area-context": "~5.7.0",
33
+ "react-native-screens": "~4.26.0",
34
+ "react-native-web": "~0.21.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/react": "~19.2.2",
38
+ "typescript": "~6.0.3"
39
+ }
40
+ }
@@ -0,0 +1,2 @@
1
+ Each file here adds a row to Home: `export default { title, description, href, order }`.
2
+ Recipes (`npx acms mobile recipe add editor-content`, …) drop their file in.
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true
5
+ },
6
+ "include": ["**/*.ts", "**/*.tsx"]
7
+ }
@@ -0,0 +1,57 @@
1
+ import { useState } from 'react';
2
+ import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
3
+ import { Stack, router, useLocalSearchParams } from 'expo-router';
4
+ import { ErrorView, useAvailability, useLayout, useLocale, useResources, useTheme } from '@acms-subir/native';
5
+
6
+ // Open slots for one booking link over the next two weeks.
7
+ export default function SlotsScreen() {
8
+ const t = useTheme();
9
+ const { locale } = useLocale();
10
+ const { gutter } = useLayout();
11
+ const { resource, link } = useLocalSearchParams<{ resource: string; link: string }>();
12
+ const resources = useResources();
13
+ const r = resources.data?.resources.find((x) => x.slug === resource);
14
+ const l = r?.links.find((x) => x.slug === link);
15
+ const [minutes, setMinutes] = useState<number | undefined>();
16
+ const length = minutes ?? l?.lengths[0];
17
+ const slots = useAvailability(resource && link ? { resource, link, days: 14, minutes: length } : null);
18
+ if (slots.error && !slots.data) return <ErrorView error={slots.error} retry={() => slots.refetch()} />;
19
+ const tz = slots.data?.timezone;
20
+ const day = (date: string) => new Date(`${date}T12:00:00Z`).toLocaleDateString(locale, { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' });
21
+ const time = (iso: string) => new Date(iso).toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
22
+ const days = (slots.data?.days ?? []).filter((d) => d.slots.length);
23
+ return (
24
+ <ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 20, width: '100%', maxWidth: 840, alignSelf: 'center' }}>
25
+ <Stack.Screen options={{ title: l?.name ?? 'Choose a time' }} />
26
+ {r && <Text style={t.styles.text.muted}>{r.name}{tz ? ` · times in ${tz}` : ''}</Text>}
27
+ {!!l && l.lengths.length > 1 && (
28
+ <View style={{ flexDirection: 'row', gap: 16 }} accessibilityRole="tablist">
29
+ {l.lengths.map((m) => (
30
+ <Pressable key={m} accessibilityRole="tab" accessibilityState={{ selected: m === length }} onPress={() => setMinutes(m)} hitSlop={8}>
31
+ <Text style={[t.styles.text.body, { color: m === length ? t.colors.ink : t.colors.muted, fontWeight: m === length ? '700' : '400' }]}>{m} min</Text>
32
+ </Pressable>
33
+ ))}
34
+ </View>
35
+ )}
36
+ {slots.isPending && <ActivityIndicator color={t.colors.accent} />}
37
+ {!slots.isPending && !days.length && <Text style={t.styles.text.muted}>No open times in the next two weeks.</Text>}
38
+ {days.map((d) => (
39
+ <View key={d.date} style={{ gap: 10 }}>
40
+ <Text style={t.styles.text.heading}>{day(d.date)}</Text>
41
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
42
+ {d.slots.map((s) => (
43
+ <Pressable
44
+ key={s.start}
45
+ accessibilityRole="button"
46
+ onPress={() => router.push({ pathname: '/book/details', params: { resource, link, start: s.start, end: s.end, minutes: String(s.minutes) } })}
47
+ style={({ pressed }) => ({ minWidth: 96, paddingVertical: 10, paddingHorizontal: 12, borderRadius: Math.min(t.radius, 10), borderWidth: 1, borderColor: t.colors.line, backgroundColor: pressed ? t.colors.soft : t.colors.panel, alignItems: 'center' })}
48
+ >
49
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{time(s.start)}</Text>
50
+ </Pressable>
51
+ ))}
52
+ </View>
53
+ </View>
54
+ ))}
55
+ </ScrollView>
56
+ );
57
+ }
@@ -0,0 +1,105 @@
1
+ import { useState } from 'react';
2
+ import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Switch, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
3
+ import { Stack, router, useLocalSearchParams } from 'expo-router';
4
+ import * as Crypto from 'expo-crypto';
5
+ import { isAcmsError } from '@acms-subir/client';
6
+ import { Button, formatEventDate, useBooking, useLayout, useLocale, useResources, useTheme } from '@acms-subir/native';
7
+
8
+ // Details form and confirmation. useBooking keeps one idempotency key per
9
+ // booking intent, so pressing "Book" again after a timeout can't double-book.
10
+ export default function DetailsScreen() {
11
+ const t = useTheme();
12
+ const { locale } = useLocale();
13
+ const { gutter } = useLayout();
14
+ const p = useLocalSearchParams<{ resource: string; link: string; start: string; end: string; minutes: string }>();
15
+ const r = useResources().data?.resources.find((x) => x.slug === p.resource);
16
+ const l = r?.links.find((x) => x.slug === p.link);
17
+ const booking = useBooking({ createKey: Crypto.randomUUID });
18
+ const [name, setName] = useState('');
19
+ const [email, setEmail] = useState('');
20
+ const [phone, setPhone] = useState('');
21
+ const [answers, setAnswers] = useState<Record<string, string>>({});
22
+ const when = formatEventDate({ start: p.start, end: p.end, timezone: r?.timezone }, locale);
23
+
24
+ const input = { borderWidth: 1, borderColor: t.colors.line, borderRadius: Math.min(t.radius, 10), padding: 12, fontSize: 16, color: t.colors.ink, backgroundColor: t.colors.panel } as const;
25
+ const field = (label: string, value: string, onChange: (v: string) => void, opts: { keyboard?: KeyboardTypeOptions; multiline?: boolean; auto?: 'name' | 'email' | 'tel' } = {}) => (
26
+ <View key={label} style={{ gap: 6 }}>
27
+ <Text style={t.styles.text.label}>{label}</Text>
28
+ <TextInput value={value} onChangeText={onChange} keyboardType={opts.keyboard} multiline={opts.multiline} autoComplete={opts.auto}
29
+ autoCapitalize={opts.auto === 'email' ? 'none' : 'sentences'} placeholderTextColor={t.colors.muted}
30
+ style={[input, opts.multiline && { minHeight: 96, textAlignVertical: 'top' }]} />
31
+ </View>
32
+ );
33
+
34
+ if (booking.data) {
35
+ const b = booking.data.booking;
36
+ return (
37
+ <ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 12, width: '100%', maxWidth: 640, alignSelf: 'center' }}>
38
+ <Stack.Screen options={{ title: 'Booked', headerBackVisible: false }} />
39
+ <Text accessibilityRole="header" style={t.styles.text.display}>{b.status === 'confirmed' ? 'You’re booked' : 'Request sent'}</Text>
40
+ <Text style={t.styles.text.body}>{l?.name} · {when}</Text>
41
+ <Text style={t.styles.text.muted}>
42
+ {b.status === 'confirmed' ? `A confirmation is on its way to ${b.email}.` : `We’ll email ${b.email} once it’s confirmed.`}
43
+ </Text>
44
+ <Button label="Done" onPress={() => { booking.reset(); router.dismissAll(); }} />
45
+ </ScrollView>
46
+ );
47
+ }
48
+
49
+ const error = booking.error;
50
+ const submit = () => booking.mutate({ resource: p.resource, link: p.link, start: p.start, minutes: Number(p.minutes) || undefined, name: name.trim(), email: email.trim(), ...(phone.trim() ? { phone: phone.trim() } : {}), answers });
51
+ return (
52
+ <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
53
+ <ScrollView keyboardShouldPersistTaps="handled" style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }}>
54
+ <Stack.Screen options={{ title: 'Your details' }} />
55
+ <View style={[t.styles.card, { gap: 2 }]}>
56
+ <Text style={t.styles.text.heading}>{l?.name ?? 'Booking'}</Text>
57
+ <Text style={t.styles.text.muted}>{r?.name} · {when}</Text>
58
+ </View>
59
+ {field('Name', name, setName, { auto: 'name' })}
60
+ {field('Email', email, setEmail, { keyboard: 'email-address', auto: 'email' })}
61
+ {l?.askPhone && field('Phone', phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
62
+ {l?.questions.map((q) => {
63
+ const label = q.label + (q.required ? '' : ' (optional)');
64
+ const set = (v: string) => setAnswers((a) => ({ ...a, [q.id]: v }));
65
+ if (q.type === 'checkbox') return (
66
+ <View key={q.id} style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
67
+ <Text style={[t.styles.text.body, { flex: 1 }]}>{label}</Text>
68
+ <Switch value={answers[q.id] === 'yes'} onValueChange={(v) => set(v ? 'yes' : '')} trackColor={{ true: t.colors.accent }} />
69
+ </View>
70
+ );
71
+ if (q.type === 'select') return (
72
+ <View key={q.id} style={{ gap: 6 }}>
73
+ <Text style={t.styles.text.label}>{label}</Text>
74
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
75
+ {q.options.map((o) => {
76
+ const on = answers[q.id] === o;
77
+ return (
78
+ <Pressable key={o} accessibilityRole="radio" accessibilityState={{ checked: on }} onPress={() => set(o)}
79
+ style={{ paddingVertical: 10, paddingHorizontal: 12, borderRadius: Math.min(t.radius, 10), borderWidth: 1, borderColor: on ? t.colors.accent : t.colors.line, backgroundColor: on ? t.colors.soft : t.colors.panel }}>
80
+ <Text style={[t.styles.text.body, on && { fontWeight: '600' }]}>{o}</Text>
81
+ </Pressable>
82
+ );
83
+ })}
84
+ </View>
85
+ </View>
86
+ );
87
+ return field(label, answers[q.id] ?? '', set, {
88
+ multiline: q.type === 'textarea',
89
+ keyboard: q.type === 'email' ? 'email-address' : q.type === 'phone' ? 'phone-pad' : q.type === 'number' ? 'numeric' : undefined,
90
+ });
91
+ })}
92
+ {error && (
93
+ <View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }}>
94
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>That didn’t go through.</Text>
95
+ <Text style={t.styles.text.muted}>
96
+ {isAcmsError(error) && error.status === 0 ? 'Check your connection and try again. You won’t be booked twice.' : error.message}
97
+ </Text>
98
+ </View>
99
+ )}
100
+ <Button label={booking.isPending ? 'Booking…' : 'Book'} disabled={booking.isPending || !name.trim() || !email.trim()} onPress={submit} />
101
+ <Text style={t.styles.text.small}>Your details go only to the organiser of this booking.</Text>
102
+ </ScrollView>
103
+ </KeyboardAvoidingView>
104
+ );
105
+ }
@@ -0,0 +1,38 @@
1
+ import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
2
+ import { Stack, router } from 'expo-router';
3
+ import { ErrorView, useLayout, useResources, useTheme } from '@acms-subir/native';
4
+
5
+ // Everything the site lets people book, grouped by resource.
6
+ export default function BookScreen() {
7
+ const t = useTheme();
8
+ const { gutter, wide } = useLayout();
9
+ const { data, error, isPending, refetch } = useResources();
10
+ if (error && !data) return <ErrorView error={error} retry={refetch} />;
11
+ const resources = (data?.resources ?? []).filter((r) => r.links.length);
12
+ return (
13
+ <ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 1040, alignSelf: 'center' }}>
14
+ <Stack.Screen options={{ title: 'Book' }} />
15
+ {isPending && <ActivityIndicator color={t.colors.accent} />}
16
+ {!isPending && !resources.length && <Text style={t.styles.text.muted}>Nothing is open for booking right now.</Text>}
17
+ <View style={{ flexDirection: wide ? 'row' : 'column', flexWrap: 'wrap', gap: 16 }}>
18
+ {resources.map((r) => (
19
+ <View key={r.id} style={[t.styles.card, { gap: 10, width: wide ? '48.5%' : '100%' }]}>
20
+ <Text style={t.styles.text.heading}>{r.name}</Text>
21
+ {!!r.description && <Text style={t.styles.text.muted}>{r.description}</Text>}
22
+ {r.links.map((l) => (
23
+ <Pressable
24
+ key={l.id}
25
+ accessibilityRole="button"
26
+ onPress={() => router.push({ pathname: '/book/[resource]', params: { resource: r.slug, link: l.slug } })}
27
+ style={({ pressed }) => ({ borderColor: t.colors.line, borderWidth: 1, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 2, opacity: pressed ? 0.85 : 1 })}
28
+ >
29
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{l.name}</Text>
30
+ <Text style={t.styles.text.small}>{l.lengths.length ? l.lengths.map((m) => `${m} min`).join(' · ') : l.mode}</Text>
31
+ </Pressable>
32
+ ))}
33
+ </View>
34
+ ))}
35
+ </View>
36
+ </ScrollView>
37
+ );
38
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "kind": "client",
3
+ "title": "Booking",
4
+ "description": "Resources and booking links → open slots → details form → confirmation, with retry-safe submits.",
5
+ "packages": [
6
+ "expo-crypto"
7
+ ],
8
+ "routes": [
9
+ "/book",
10
+ "/book/[resource]",
11
+ "/book/details"
12
+ ],
13
+ "next": [
14
+ "Link to it from a screen: <Link href=\"/book\">Book</Link> (expo-router)",
15
+ "Bookings must be enabled on the site (Admin → Bookings) with at least one public booking link."
16
+ ]
17
+ }
@@ -0,0 +1,113 @@
1
+ import { useState } from 'react';
2
+ import { ActivityIndicator, FlatList, RefreshControl, ScrollView, Text, View } from 'react-native';
3
+ import { Stack } from 'expo-router';
4
+ import type { EditorBooking } from '@acms-subir/client';
5
+ import { Card, EditorButton, EditorInput, ListRow, Notice, Sheet, StatusText, errorMessage, useBookingsInbox, useEditorTheme, useMe } from '@acms-subir/native/editor';
6
+ import { SplitView, useWide } from '../../components/Screen';
7
+ import { TextTabs } from '../../components/TextTabs';
8
+ import { dateTime } from '../../lib/format';
9
+
10
+ type Filter = 'pending' | 'active' | 'confirmed' | 'cancelled' | 'all';
11
+ type Action = 'approve' | 'decline' | 'cancel';
12
+
13
+ export default function BookingsScreen() {
14
+ const t = useEditorTheme();
15
+ const { wide, gutter } = useWide();
16
+ const me = useMe();
17
+ const [filter, setFilter] = useState<Filter>('active');
18
+ const inbox = useBookingsInbox({ status: filter });
19
+ const [selected, setSelected] = useState<string | null>(null);
20
+ const [asking, setAsking] = useState<{ booking: EditorBooking; action: Action } | null>(null);
21
+ const current = inbox.items.find((b) => b.id === selected) ?? null;
22
+ if (me.isSuccess && !me.data.permissions.bookings) return <View style={{ flex: 1, padding: 16, backgroundColor: t.colors.panel }}><Stack.Screen options={{ title: 'Bookings' }} /><Notice>Bookings are off for this site.</Notice></View>;
23
+ const list = (
24
+ <FlatList
25
+ data={inbox.items}
26
+ keyExtractor={(b) => b.id}
27
+ contentContainerStyle={{ padding: gutter, gap: 2 }}
28
+ refreshControl={<RefreshControl refreshing={inbox.isRefetching && !inbox.isFetchingNextPage} onRefresh={() => inbox.refetch()} tintColor={t.colors.muted} />}
29
+ onEndReachedThreshold={0.4}
30
+ onEndReached={() => { if (inbox.hasNextPage && !inbox.isFetchingNextPage) void inbox.fetchNextPage(); }}
31
+ ListHeaderComponent={
32
+ <View style={{ gap: 8, marginBottom: 8 }}>
33
+ <TextTabs<Filter> accessibilityLabel="Show" value={filter} onChange={(f) => { setFilter(f); setSelected(null); }} options={[
34
+ { value: 'active', label: 'Inbox' }, { value: 'pending', label: 'Pending', count: inbox.pending || undefined }, { value: 'confirmed', label: 'Confirmed' },
35
+ { value: 'cancelled', label: 'Cancelled' }, { value: 'all', label: 'All' },
36
+ ]} />
37
+ {!!inbox.pending && filter === 'active' && <Text style={[t.text.meta, { color: t.colors.accentText }]}>{inbox.pending} waiting for an answer</Text>}
38
+ </View>
39
+ }
40
+ ListEmptyComponent={inbox.isPending ? <ActivityIndicator color={t.colors.muted} /> : inbox.error ? <Notice tone="error">{errorMessage(inbox.error)}</Notice> : <Notice>No bookings here.</Notice>}
41
+ renderItem={({ item }) => (
42
+ wide ? (
43
+ <ListRow title={item.name} selected={selected === item.id} onPress={() => setSelected(item.id)}
44
+ meta={<Text numberOfLines={1} style={t.text.meta}><StatusText status={item.status} />{` ${dateTime(item.start)} · ${item.title}`}</Text>} />
45
+ ) : (
46
+ <BookingCard booking={item} expanded={selected === item.id} onToggle={() => setSelected(selected === item.id ? null : item.id)} onAct={(action) => setAsking({ booking: item, action })} acting={inbox.acting?.id === item.id} />
47
+ )
48
+ )}
49
+ />
50
+ );
51
+ return (
52
+ <>
53
+ <Stack.Screen options={{ title: 'Bookings' }} />
54
+ <SplitView list={list} detail={current ? (
55
+ <ScrollView contentContainerStyle={{ padding: 24, maxWidth: 720 }}>
56
+ <BookingCard booking={current} expanded onAct={(action) => setAsking({ booking: current, action })} acting={inbox.acting?.id === current.id} />
57
+ </ScrollView>
58
+ ) : <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}><Text style={t.text.meta}>Choose a booking on the left.</Text></View>} />
59
+ {asking && <AnswerSheet {...asking} onClose={() => setAsking(null)} onConfirm={async (message) => {
60
+ const { booking, action } = asking;
61
+ await (action === 'approve' ? inbox.approve(booking.id, message) : action === 'decline' ? inbox.decline(booking.id, message) : inbox.cancel(booking.id, message));
62
+ setAsking(null);
63
+ }} />}
64
+ </>
65
+ );
66
+ }
67
+
68
+ function BookingCard({ booking: b, expanded, onToggle, onAct, acting }: { booking: EditorBooking; expanded: boolean; onToggle?: () => void; onAct: (a: Action) => void; acting: boolean }) {
69
+ const t = useEditorTheme();
70
+ const upcoming = new Date(b.end).getTime() > Date.now();
71
+ return (
72
+ <Card style={{ marginBottom: 8 }}>
73
+ <ListRow title={b.name} onPress={onToggle} accessibilityLabel={`${b.name}, ${b.status}, ${dateTime(b.start)}`}
74
+ meta={<Text numberOfLines={1} style={t.text.meta}><StatusText status={b.status} />{` ${dateTime(b.start)}`}</Text>}
75
+ right={b.seats > 1 ? <Text style={t.text.meta}>{b.seats} seats</Text> : undefined} />
76
+ {expanded && (
77
+ <View style={{ paddingHorizontal: 14, paddingBottom: 14, gap: 8 }}>
78
+ <Text style={t.text.body}>{b.title}{b.kind === 'rsvp' ? ' (RSVP)' : ''}</Text>
79
+ <Text selectable style={t.text.body}>{b.email}{b.phone ? `\n${b.phone}` : ''}</Text>
80
+ {Object.entries(b.answers).map(([q, a]) => <Text key={q} style={t.text.meta}><Text style={{ fontWeight: '600', color: t.colors.text2 }}>{q}: </Text>{a}</Text>)}
81
+ {!!b.notes && <Text style={t.text.meta}>Notes: {b.notes}</Text>}
82
+ <View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap', marginTop: 4 }}>
83
+ {b.status === 'pending' && <EditorButton variant="primary" label="Approve" busy={acting} onPress={() => onAct('approve')} />}
84
+ {b.status === 'pending' && <EditorButton label="Decline" disabled={acting} onPress={() => onAct('decline')} />}
85
+ {b.status === 'confirmed' && upcoming && <EditorButton variant="danger" label="Cancel booking" disabled={acting} onPress={() => onAct('cancel')} />}
86
+ </View>
87
+ </View>
88
+ )}
89
+ </Card>
90
+ );
91
+ }
92
+
93
+ function AnswerSheet({ booking, action, onClose, onConfirm }: { booking: EditorBooking; action: Action; onClose: () => void; onConfirm: (message?: string) => Promise<void> }) {
94
+ const t = useEditorTheme();
95
+ const [message, setMessage] = useState('');
96
+ const [busy, setBusy] = useState(false);
97
+ const [error, setError] = useState<string | null>(null);
98
+ const verb = action === 'approve' ? 'Approve' : action === 'decline' ? 'Decline' : 'Cancel booking';
99
+ return (
100
+ <Sheet visible onClose={onClose} title={`${verb}: ${booking.name}`} footer={
101
+ <EditorButton variant={action === 'approve' ? 'primary' : 'danger'} label={verb} busy={busy} onPress={async () => {
102
+ setBusy(true); setError(null);
103
+ try { await onConfirm(message.trim() || undefined); } catch (e) { setError(errorMessage(e)); } finally { setBusy(false); }
104
+ }} />
105
+ }>
106
+ <Text style={t.text.body}>{booking.title} · {dateTime(booking.start)}</Text>
107
+ <Text style={t.text.label}>Message to {booking.name} (optional)</Text>
108
+ <EditorInput multiline value={message} onChangeText={setMessage} placeholder={action === 'approve' ? 'See you then!' : 'Sorry, that time doesn’t work.'} accessibilityLabel="Message" maxLength={1000} />
109
+ <Text style={t.text.meta}>{booking.name} gets an email{action === 'approve' ? ' confirming the booking' : ''}.</Text>
110
+ {!!error && <Notice tone="error">{error}</Notice>}
111
+ </Sheet>
112
+ );
113
+ }
@@ -0,0 +1 @@
1
+ export default { title: 'Bookings', description: 'Approve and answer requests', href: '/bookings', order: 60 };
@@ -0,0 +1,8 @@
1
+ {
2
+ "kind": "editor",
3
+ "title": "Bookings",
4
+ "description": "The bookings inbox: pending requests first, then upcoming; approve, decline or cancel with an optional message to the guest. Tablets show list + detail.",
5
+ "packages": [],
6
+ "routes": ["/bookings"],
7
+ "next": ["It appears on Home (tools/bookings.ts). Bookings must be on for the site (calendar + booking links)."]
8
+ }
@@ -0,0 +1,13 @@
1
+ import { Stack, useLocalSearchParams } from 'expo-router';
2
+ import type { DocKind } from '@acms-subir/client';
3
+ import { DocumentDetail } from '../../../components/DocumentDetail';
4
+
5
+ export default function ContentDetailScreen() {
6
+ const { kind, id } = useLocalSearchParams<{ kind: DocKind; id: string }>();
7
+ return (
8
+ <>
9
+ <Stack.Screen options={{ title: kind === 'page' ? 'Page' : kind === 'event' ? 'Event' : 'Post' }} />
10
+ <DocumentDetail kind={kind} id={id} />
11
+ </>
12
+ );
13
+ }
@@ -0,0 +1,76 @@
1
+ import { useState } from 'react';
2
+ import { ActivityIndicator, FlatList, RefreshControl, Text, View } from 'react-native';
3
+ import { Stack, router } from 'expo-router';
4
+ import type { DocKind } from '@acms-subir/client';
5
+ import { EditorButton, EditorInput, ListRow, Notice, StatusText, errorMessage, useDocuments, useEditorTheme, useMe } from '@acms-subir/native/editor';
6
+ import { HeaderActions, SplitView, useWide } from '../../components/Screen';
7
+ import { TextTabs } from '../../components/TextTabs';
8
+ import { DocumentDetail } from '../../components/DocumentDetail';
9
+ import { hasTool } from '../../lib/tools';
10
+ import { ago } from '../../lib/format';
11
+
12
+ type Status = 'all' | 'draft' | 'scheduled' | 'published' | 'changed';
13
+
14
+ export default function ContentScreen() {
15
+ const t = useEditorTheme();
16
+ const { wide, gutter } = useWide();
17
+ const me = useMe();
18
+ const [kind, setKind] = useState<DocKind>('post');
19
+ const [collection, setCollection] = useState<string>('');
20
+ const [status, setStatus] = useState<Status>('all');
21
+ const [q, setQ] = useState('');
22
+ const [selected, setSelected] = useState<{ kind: DocKind; id: string } | null>(null);
23
+ const collections = me.data?.features.collections ?? [];
24
+ const docs = useDocuments({ kind, status: status === 'all' ? undefined : status, q: q.trim() || undefined, collection: kind === 'post' && collection ? collection : undefined, limit: 30 });
25
+ const kinds: { value: DocKind; label: string }[] = [
26
+ { value: 'post', label: collections.length === 1 ? collections[0].plural : 'Posts' },
27
+ { value: 'page', label: 'Pages' },
28
+ ...(me.data?.features.calendar ? [{ value: 'event' as const, label: 'Events' }] : []),
29
+ ];
30
+ const open = (d: { kind: DocKind; id: string }) => (wide ? setSelected(d) : router.push({ pathname: '/content/[kind]/[id]', params: d }));
31
+
32
+ const list = (
33
+ <FlatList
34
+ data={docs.items}
35
+ keyExtractor={(d) => d.id}
36
+ contentContainerStyle={{ padding: gutter, gap: 2 }}
37
+ keyboardShouldPersistTaps="handled"
38
+ refreshControl={<RefreshControl refreshing={docs.isRefetching && !docs.isFetchingNextPage} onRefresh={() => docs.refetch()} tintColor={t.colors.muted} />}
39
+ onEndReachedThreshold={0.4}
40
+ onEndReached={() => { if (docs.hasNextPage && !docs.isFetchingNextPage) void docs.fetchNextPage(); }}
41
+ ListHeaderComponent={
42
+ <View style={{ gap: 10, marginBottom: 8 }}>
43
+ <TextTabs accessibilityLabel="Kind" value={kind} options={kinds} onChange={(k) => { setKind(k); setSelected(null); }} />
44
+ {kind === 'post' && collections.length > 1 && (
45
+ <TextTabs accessibilityLabel="Collection" value={collection} onChange={setCollection}
46
+ options={[{ value: '', label: 'All collections' }, ...collections.map((c) => ({ value: c.key, label: c.plural }))]} />
47
+ )}
48
+ <TextTabs<Status> accessibilityLabel="Status" value={status} onChange={setStatus} options={[
49
+ { value: 'all', label: 'All' }, { value: 'draft', label: 'Drafts' }, { value: 'scheduled', label: 'Scheduled' },
50
+ { value: 'published', label: 'Published' }, { value: 'changed', label: 'Changed' },
51
+ ]} />
52
+ <EditorInput placeholder="Search titles" value={q} onChangeText={setQ} accessibilityLabel="Search" autoCapitalize="none" returnKeyType="search" clearButtonMode="while-editing" />
53
+ {docs.isSuccess && <Text style={t.text.meta}>{docs.total} {docs.total === 1 ? 'item' : 'items'}</Text>}
54
+ </View>
55
+ }
56
+ ListEmptyComponent={docs.isPending ? <ActivityIndicator color={t.colors.muted} /> : docs.error ? <Notice tone="error">{errorMessage(docs.error)}</Notice> : <Notice>Nothing here yet.</Notice>}
57
+ ListFooterComponent={docs.isFetchingNextPage ? <ActivityIndicator style={{ margin: 16 }} color={t.colors.muted} /> : null}
58
+ renderItem={({ item }) => (
59
+ <ListRow
60
+ title={item.title || 'Untitled'}
61
+ selected={wide && selected?.id === item.id}
62
+ onPress={() => open({ kind: item.kind, id: item.id })}
63
+ meta={<Text numberOfLines={1} style={t.text.meta}><StatusText status={item.status} suffix={item.hasChanges ? 'edited' : undefined} />{` ${ago(item.updatedAt)}`}</Text>}
64
+ />
65
+ )}
66
+ />
67
+ );
68
+ return (
69
+ <>
70
+ <Stack.Screen options={{ title: 'Content', headerRight: hasTool('writer') ? () => <HeaderActions><EditorButton label="New" variant="primary" compact onPress={() => router.push('/write/new')} /></HeaderActions> : undefined }} />
71
+ <SplitView list={list} detail={selected ? <DocumentDetail key={selected.id} kind={selected.kind} id={selected.id} /> : (
72
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }}><Text style={t.text.meta}>Choose something on the left.</Text></View>
73
+ )} />
74
+ </>
75
+ );
76
+ }
@@ -0,0 +1,55 @@
1
+ import { useState } from 'react';
2
+ import { ActivityIndicator, Linking, Text, View } from 'react-native';
3
+ import { router } from 'expo-router';
4
+ import type { DocKind } from '@acms-subir/client';
5
+ import { Card, EditorButton, htmlToText, ListRow, Notice, StatusText, errorMessage, isConflict, resolveUrl, useDocument, useEditorContext, useEditorTheme, usePublish } from '@acms-subir/native/editor';
6
+ import { Screen } from './Screen';
7
+ import { hasTool } from '../lib/tools';
8
+ import { dateTime } from '../lib/format';
9
+
10
+ /** a document's status, words and publish actions (the content recipe's detail) */
11
+ export function DocumentDetail({ kind, id }: { kind: DocKind; id: string }) {
12
+ const t = useEditorTheme();
13
+ const { baseUrl } = useEditorContext();
14
+ const doc = useDocument(kind, id);
15
+ const pub = usePublish(kind, id);
16
+ const [message, setMessage] = useState<string | null>(null);
17
+ const d = doc.document;
18
+ if (doc.isPending) return <Screen><ActivityIndicator color={t.colors.muted} /></Screen>;
19
+ if (!d) return <Screen><Notice tone="error">{errorMessage(doc.error) || 'Not found.'}</Notice></Screen>;
20
+ const live = d.status === 'published' || d.status === 'scheduled';
21
+ const run = async (what: () => Promise<unknown>, done: string) => {
22
+ setMessage(null);
23
+ try { await what(); setMessage(done); } catch (e) { setMessage(isConflict(e) ? 'This changed on the site. It has been reloaded; try again.' : errorMessage(e)); }
24
+ };
25
+ const preview = htmlToText(d.bodyHtml).slice(0, 600);
26
+ return (
27
+ <Screen refreshing={doc.isRefetching} onRefresh={() => doc.refetch()}>
28
+ <View style={{ gap: 6 }}>
29
+ <Text accessibilityRole="header" style={t.text.screenTitle}>{d.title || 'Untitled'}</Text>
30
+ <Text style={t.text.meta}>
31
+ <StatusText status={d.status} suffix={d.status === 'scheduled' && d.scheduledAt ? dateTime(d.scheduledAt) : d.hasChanges ? 'unpublished edits' : undefined} />
32
+ {` Updated ${dateTime(d.updatedAt)}`}
33
+ </Text>
34
+ </View>
35
+ {!!message && <Notice tone={/couldn|changed|error|failed|isn/i.test(message) ? 'warn' : 'success'}>{message}</Notice>}
36
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
37
+ {hasTool('writer') && <EditorButton label="Edit" variant="primary" onPress={() => router.push({ pathname: '/write/[kind]/[id]', params: { kind, id } })} />}
38
+ {(!live || d.hasChanges) && d.status !== 'scheduled' && (
39
+ <EditorButton label={live ? 'Publish edits' : 'Publish'} busy={pub.action === 'publish'} disabled={pub.pending} variant={hasTool('writer') ? 'secondary' : 'primary'}
40
+ onPress={() => run(pub.publish, live ? 'Your edits are live.' : 'Published.')} />
41
+ )}
42
+ {d.hasChanges && live && <EditorButton label="Discard edits" busy={pub.action === 'discard'} disabled={pub.pending} onPress={() => run(pub.discard, 'Edits discarded.')} />}
43
+ {live && <EditorButton label="Unpublish" variant="danger" busy={pub.action === 'unpublish'} disabled={pub.pending} onPress={() => run(pub.unpublish, 'Unpublished. It is a draft again.')} />}
44
+ </View>
45
+ <Card>
46
+ <ListRow title="Address" meta={d.url} onPress={d.status === 'published' ? () => { const u = resolveUrl(d.url, baseUrl); if (u) void Linking.openURL(u); } : undefined} />
47
+ {!!d.excerpt && <ListRow title="Summary" meta={d.excerpt} />}
48
+ {kind === 'event' && d.event && <ListRow title="When" meta={`${d.event.allDay ? d.event.start.slice(0, 10) : dateTime(d.event.start)}${d.event.location.name ? ` · ${d.event.location.name}` : ''}`} />}
49
+ {d.tags.length > 0 && <ListRow title="Tags" meta={d.tags.join(', ')} />}
50
+ {d.languages.length > 0 && <ListRow title="Languages" meta={d.languages.join(', ')} />}
51
+ </Card>
52
+ {!!preview && <Text style={t.text.body} numberOfLines={14}>{preview}</Text>}
53
+ </Screen>
54
+ );
55
+ }
@@ -0,0 +1 @@
1
+ export default { title: 'Content', description: 'Posts, pages and events', href: '/content', order: 10 };
@@ -0,0 +1,8 @@
1
+ {
2
+ "kind": "editor",
3
+ "title": "Content",
4
+ "description": "Posts, pages and events: kind and status filters, search, infinite scroll; publish, unpublish and discard staged edits. Tablets show list + detail side by side.",
5
+ "packages": [],
6
+ "routes": ["/content", "/content/[kind]/[id]"],
7
+ "next": ["It appears on Home (tools/content.ts). Add editor-writer to edit text from the detail screen."]
8
+ }