@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,21 @@
1
+ {
2
+ "cli": {
3
+ "version": ">= 16.0.0",
4
+ "appVersionSource": "remote"
5
+ },
6
+ "build": {
7
+ "development": {
8
+ "developmentClient": true,
9
+ "distribution": "internal"
10
+ },
11
+ "preview": {
12
+ "distribution": "internal"
13
+ },
14
+ "production": {
15
+ "autoIncrement": true
16
+ }
17
+ },
18
+ "submit": {
19
+ "production": {}
20
+ }
21
+ }
@@ -0,0 +1,3 @@
1
+ # Your ACMS site (public origin). Only public values belong here: EXPO_PUBLIC_*
2
+ # variables are bundled into the app. Never put admin passwords or API keys in it.
3
+ EXPO_PUBLIC_ACMS_URL=https://example.com
@@ -0,0 +1,42 @@
1
+ # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2
+
3
+ # dependencies
4
+ node_modules/
5
+
6
+ # Expo
7
+ .expo/
8
+ dist/
9
+ web-build/
10
+ expo-env.d.ts
11
+
12
+ # Native
13
+ .kotlin/
14
+ *.orig.*
15
+ *.jks
16
+ *.p8
17
+ *.p12
18
+ *.key
19
+ *.mobileprovision
20
+
21
+ # Metro
22
+ .metro-health-check*
23
+
24
+ # debug
25
+ npm-debug.*
26
+ yarn-debug.*
27
+ yarn-error.*
28
+
29
+ # macOS
30
+ .DS_Store
31
+ *.pem
32
+
33
+ # local env files
34
+ .env*.local
35
+
36
+ # typescript
37
+ *.tsbuildinfo
38
+
39
+
40
+ # generated native folders
41
+ /ios
42
+ /android
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "__ACMS_APP_SLUG__",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "main": "expo-router/entry",
6
+ "acms": { "app": "client", "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-linking": "~57.0.11",
22
+ "expo-router": "~57.0.24",
23
+ "expo-status-bar": "~57.0.1",
24
+ "expo-system-ui": "~57.0.4",
25
+ "react": "19.2.3",
26
+ "react-dom": "19.2.3",
27
+ "react-native": "0.86.3",
28
+ "react-native-safe-area-context": "~5.7.0",
29
+ "react-native-screens": "~4.26.0",
30
+ "react-native-svg": "15.15.4",
31
+ "react-native-web": "~0.21.0",
32
+ "react-native-webview": "13.16.1"
33
+ },
34
+ "devDependencies": {
35
+ "@types/react": "~19.2.2",
36
+ "typescript": "~6.0.3"
37
+ }
38
+ }
@@ -0,0 +1,11 @@
1
+ import { defineSectionRegistry } from '@acms-subir/native';
2
+ // acms:imports (npx acms section add|new adds imports above this line)
3
+
4
+ // Native section components for this app, keyed by section type or
5
+ // "type:variant". The site's library sections (hero-banner, team, pricing, …)
6
+ // already render natively from @acms-subir/native/sections; add an entry here
7
+ // to replace one, or to give the site's own coded sections a native look.
8
+ // Sections without a native component show as the website renders them.
9
+ export const sections = defineSectionRegistry({
10
+ // acms:sections
11
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true
5
+ },
6
+ "include": ["**/*.ts", "**/*.tsx"]
7
+ }
@@ -0,0 +1,66 @@
1
+ # Agent rules for this app
2
+
3
+ Full guide: run `npx acms docs mobile` from the **site** folder (the folder
4
+ that contains this app; all `acms` commands run there, not in this folder). Finish
5
+ every change with `npx acms mobile doctor` (no failures).
6
+
7
+ This is the **editor app** for the ACMS site at `__ACMS_SITE_URL__`: an Expo
8
+ app for the site's staff (owners and editors) to write, edit sections,
9
+ review translations, manage media and answer bookings. It signs in through
10
+ the site (OAuth + PKCE in the system browser) and calls the bearer-only
11
+ `/api/v1/admin/*` API. It lives beside the website as a separate project.
12
+
13
+ ## Security rules (non-negotiable)
14
+
15
+ 1. **Never handle passwords.** No login form, no password field, no
16
+ "remember me". Sign-in is `useAuth().signIn()`, which opens the site.
17
+ 2. **Tokens only in secure storage.** `<EditorProvider>` keeps them in
18
+ `expo-secure-store` (sessionStorage on the web). Never read them into
19
+ state, never put them in AsyncStorage, files, URLs, analytics or logs.
20
+ Never `console.log` a token, a code, an API response with a token, or
21
+ a request's headers.
22
+ 3. **Use the hooks, never raw fetch.** `@acms-subir/native/editor` (`useAuth`,
23
+ `useMe`, `useDocuments`, `useDocument`, `usePublish`, `useSectionEditor`,
24
+ `useTranslations`, `useTranslationQueue`, `useMedia`, `useBookingsInbox`) or
25
+ `useEditorClient()` inside a TanStack Query hook for anything else. They
26
+ refresh tokens and sign out when the site revokes the device.
27
+ 4. **Respect roles.** The server enforces every rule, but the UI must hide
28
+ what the person can't do: read `useMe().data.permissions`, and in section
29
+ forms `canEdit(fieldKey)` / `permissions.canEditRows(key)`. Editors can't
30
+ edit locked sections or fields, or change layout, style or visibility.
31
+ 5. **Handle conflicts.** Saves send a revision. A 409 means someone saved
32
+ first: show `conflict` from `useDocument` / `useSectionEditor` with
33
+ "Keep mine" and "Use theirs"; never retry blindly over someone's work.
34
+ 6. **No secrets in the app.** `EXPO_PUBLIC_*` values are bundled and readable.
35
+ No API keys, no agent tokens, no admin routes other than `/api/v1/admin/*`.
36
+
37
+ ## Design rules
38
+
39
+ - Default look is the admin's "Inset Paper" (`useEditorTheme()`: `colors`,
40
+ `text`, `radius`), light and dark. Use the building blocks (`EditorButton`,
41
+ `EditorInput`, `Segmented`, `Notice`, `StatusText`, `ListRow`, `Card`,
42
+ `Sheet`, `FieldInput`, `SectionFields`).
43
+ - No pill-shaped chips, badges or filters (8–12px rectangles, text tabs,
44
+ marker + text). Status is colored text. No colored left-edge stripes for
45
+ selection or callouts (tinted fill instead). No gradient-squircle logos.
46
+ - Phones and tablets: `useWide()` from `components/Screen.tsx`; on ≥768
47
+ use the width (`SplitView`: list + detail), never a stretched phone column.
48
+ Touch targets at least 44pt.
49
+
50
+ ## Where things are
51
+
52
+ - `app/_layout.tsx`: `<EditorProvider>` (site URL, client id, redirect URI) and
53
+ the signed-out / signed-in switch.
54
+ - `app/index.tsx`: Home (who is signed in, tools, appearance, sign out).
55
+ - `app/auth.tsx`: the sign-in return address route.
56
+ - `lib/config.ts`: site URL, scheme, redirect URI, client id.
57
+ - `tools/*.ts`: one file per Home entry (`{ title, description, href, order }`).
58
+ - `components/Screen.tsx`: `Screen`, `SplitView`, `useWide`.
59
+
60
+ ## Before you finish
61
+
62
+ - `npm run typecheck` passes; `npx expo export --platform web` bundles.
63
+ - Try it signed in as an **editor** and as an **owner** (locked sections,
64
+ publish rights), in light and dark, on a phone and a tablet width.
65
+ - `npx acms mobile doctor` (from the site folder) passes: redirect URI listed
66
+ on the site, secure storage installed, no tokens in AsyncStorage or logs.
@@ -0,0 +1,73 @@
1
+ # __ACMS_APP_NAME__
2
+
3
+ A blank Expo app for the staff of your ACMS site (`__ACMS_SITE_URL__`). People
4
+ sign in with their site account through the website itself, and the app
5
+ already knows who they are and what their role allows. What it does after
6
+ that is yours to build: add ready-made screens with recipes, or write your own
7
+ with the hooks from `@acms-subir/native/editor`.
8
+
9
+ It is separate from the client app (the one your visitors use). Both can live
10
+ beside the site.
11
+
12
+ ## Let the site accept this app
13
+
14
+ The site only signs in apps whose return address it knows. Add this app's
15
+ address to the site's `astro.config.mjs` and restart (or deploy) the site:
16
+
17
+ ```js
18
+ acms({ mobile: { editor: { name: '__ACMS_APP_NAME__', redirectUris: ['__ACMS_REDIRECT_URI__'] } } })
19
+ ```
20
+
21
+ For the web version, also list its address, e.g. `https://staff.example.com/auth`
22
+ (`http://localhost/auth` covers local development on any port).
23
+
24
+ ## Run it
25
+
26
+ From your **site** folder (starts the site too, and points the app at an
27
+ address your phone can reach):
28
+
29
+ ```sh
30
+ cd __ACMS_APP_DIR__ && npm install && cd ..
31
+ npx acms mobile dev --dir __ACMS_APP_DIR__
32
+ ```
33
+
34
+ Scan the QR code with Expo Go, or press `i` / `a` / `w`. Tap **Sign in**: the
35
+ site opens in the browser, you approve this device, and you're back in the app.
36
+
37
+ ## Add screens
38
+
39
+ ```sh
40
+ npx acms mobile recipe list
41
+ npx acms mobile recipe add editor-content --dir __ACMS_APP_DIR__ # posts, pages, events: list, filters, search
42
+ npx acms mobile recipe add editor-writer --dir __ACMS_APP_DIR__ # write, save drafts, publish, conflicts
43
+ npx acms mobile recipe add editor-sections --dir __ACMS_APP_DIR__ # edit a page's sections, publish one
44
+ npx acms mobile recipe add editor-translations --dir __ACMS_APP_DIR__ # review another language
45
+ npx acms mobile recipe add editor-media --dir __ACMS_APP_DIR__ # media grid + photo upload
46
+ npx acms mobile recipe add editor-bookings --dir __ACMS_APP_DIR__ # approve / decline bookings
47
+ ```
48
+
49
+ Each shows up on Home automatically (a file in `tools/`).
50
+
51
+ ## Security
52
+
53
+ - Nobody types a password into this app. Sign-in happens on the website.
54
+ - Tokens live only in the device's secure storage (Keychain / Keystore via
55
+ `expo-secure-store`); on the web, in the tab's sessionStorage.
56
+ - The site decides what each person may do on every request. The app hides
57
+ what a role can't do, but never relies on that.
58
+ - Staff can sign a device out from the site (Users page), and changing a
59
+ password signs every device out.
60
+
61
+ ## Check it
62
+
63
+ ```sh
64
+ npx acms mobile doctor --dir __ACMS_APP_DIR__
65
+ ```
66
+
67
+ ## Ship it
68
+
69
+ Builds and store submission use [EAS](https://docs.expo.dev/build/introduction/).
70
+ Staff apps are often distributed internally (TestFlight, internal testing
71
+ track, or `eas build --profile preview`). Check `app.json` first:
72
+ `ios.bundleIdentifier` / `android.package` (`__ACMS_BUNDLE_ID__`) and `scheme`
73
+ can't change without updating the site's `redirectUris`.
@@ -0,0 +1,76 @@
1
+ import { useMemo } from 'react';
2
+ import { ActivityIndicator, Text, View } from 'react-native';
3
+ import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router';
4
+ import { StatusBar } from 'expo-status-bar';
5
+ import { SafeAreaProvider } from 'react-native-safe-area-context';
6
+ import { EditorProvider, completeAuthRedirect, useAuth, useEditorTheme } from '@acms-subir/native/editor';
7
+ import { siteUrl, clientId, redirectUri } from '../lib/config';
8
+ import { AppearanceProvider, useAppearance } from '../lib/appearance';
9
+ import { SignIn } from '../components/SignIn';
10
+
11
+ // Web only: the sign-in popup hands its result back to the app and closes.
12
+ completeAuthRedirect();
13
+
14
+ export default function RootLayout() {
15
+ if (!siteUrl) return <MissingUrl />;
16
+ return (
17
+ <SafeAreaProvider>
18
+ <AppearanceProvider>
19
+ <Provider />
20
+ </AppearanceProvider>
21
+ </SafeAreaProvider>
22
+ );
23
+ }
24
+
25
+ function Provider() {
26
+ const { appearance } = useAppearance();
27
+ return (
28
+ <EditorProvider baseUrl={siteUrl!} clientId={clientId} redirectUri={redirectUri} colorScheme={appearance === 'auto' ? undefined : appearance}>
29
+ <Gate />
30
+ </EditorProvider>
31
+ );
32
+ }
33
+
34
+ // Signed out → the sign-in screen; signed in → the app. The server checks
35
+ // every request anyway; this only decides what to show.
36
+ function Gate() {
37
+ const t = useEditorTheme();
38
+ const auth = useAuth();
39
+ const navTheme = useMemo(() => {
40
+ const base = t.scheme === 'dark' ? DarkTheme : DefaultTheme;
41
+ return { ...base, colors: { ...base.colors, primary: t.colors.accent, background: t.colors.panel, card: t.colors.panel, text: t.colors.text, border: t.colors.line, notification: t.colors.accent } };
42
+ }, [t]);
43
+ return (
44
+ <ThemeProvider value={navTheme}>
45
+ <StatusBar style={t.scheme === 'dark' ? 'light' : 'dark'} />
46
+ {auth.status === 'loading' ? (
47
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: t.colors.panel }}><ActivityIndicator color={t.colors.muted} /></View>
48
+ ) : !auth.signedIn ? <SignIn /> : (
49
+ <Stack
50
+ screenOptions={{
51
+ headerStyle: { backgroundColor: t.colors.panel },
52
+ headerTintColor: t.colors.text,
53
+ headerTitleStyle: { fontWeight: '600' },
54
+ headerShadowVisible: false,
55
+ headerBackTitle: 'Back',
56
+ contentStyle: { backgroundColor: t.colors.panel },
57
+ }}
58
+ >
59
+ <Stack.Screen name="index" options={{ title: 'Home' }} />
60
+ <Stack.Screen name="auth" options={{ headerShown: false }} />
61
+ </Stack>
62
+ )}
63
+ </ThemeProvider>
64
+ );
65
+ }
66
+
67
+ function MissingUrl() {
68
+ return (
69
+ <View style={{ flex: 1, justifyContent: 'center', padding: 24, gap: 8 }}>
70
+ <Text style={{ fontSize: 18, fontWeight: '600' }}>Connect your site</Text>
71
+ <Text style={{ fontSize: 15, lineHeight: 22 }}>
72
+ Set EXPO_PUBLIC_ACMS_URL in .env (for example https://example.com), or run `npx acms mobile dev` from your site folder, then restart Expo.
73
+ </Text>
74
+ </View>
75
+ );
76
+ }
@@ -0,0 +1,13 @@
1
+ import { useEffect } from 'react';
2
+ import { ActivityIndicator, View } from 'react-native';
3
+ import { router } from 'expo-router';
4
+ import { useEditorTheme } from '@acms-subir/native/editor';
5
+
6
+ // The sign-in return address (<scheme>://auth, or /auth on the web). The
7
+ // browser session normally catches it before this screen shows; if the link
8
+ // opens the app directly, just go Home.
9
+ export default function AuthReturn() {
10
+ const t = useEditorTheme();
11
+ useEffect(() => { const id = setTimeout(() => router.replace('/'), 300); return () => clearTimeout(id); }, []);
12
+ return <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: t.colors.panel }}><ActivityIndicator color={t.colors.muted} /></View>;
13
+ }
@@ -0,0 +1,55 @@
1
+ import { Text, View } from 'react-native';
2
+ import { router } from 'expo-router';
3
+ import { Card, EditorButton, ListRow, SectionLabel, Segmented, errorMessage, useAuth, useEditorTheme, useMe } from '@acms-subir/native/editor';
4
+ import { Screen, useWide } from '../components/Screen';
5
+ import { tools } from '../lib/tools';
6
+ import { useAppearance, type Appearance } from '../lib/appearance';
7
+
8
+ // Home: who is signed in, and the screens this app offers (one per file in
9
+ // tools/). Blank on purpose: add screens with recipes or build your own.
10
+ export default function Home() {
11
+ const t = useEditorTheme();
12
+ const auth = useAuth();
13
+ const me = useMe();
14
+ const { wide } = useWide();
15
+ const { appearance, setAppearance } = useAppearance();
16
+ const staffRole = me.data?.user.role ?? auth.role;
17
+ const role = staffRole ? staffRole[0].toUpperCase() + staffRole.slice(1) : 'Staff';
18
+ return (
19
+ <Screen refreshing={me.isRefetching} onRefresh={() => me.refetch()}>
20
+ <View style={{ gap: 4 }}>
21
+ <Text style={t.text.meta}>{me.data?.site.name ?? ' '}</Text>
22
+ <Text accessibilityRole="header" style={t.text.screenTitle}>Signed in as {auth.user?.name}</Text>
23
+ <Text style={t.text.body}>{role} · {auth.user?.email}</Text>
24
+ </View>
25
+ {me.isError && <Text style={[t.text.meta, { color: t.colors.red }]}>{errorMessage(me.error)}</Text>}
26
+
27
+ <SectionLabel>Tools</SectionLabel>
28
+ {tools.length ? (
29
+ <Card style={wide ? { flexDirection: 'row', flexWrap: 'wrap' } : undefined}>
30
+ {tools.map((tool) => (
31
+ <View key={tool.name} style={wide ? { width: '50%' } : undefined}>
32
+ <ListRow title={tool.title} meta={tool.description} onPress={() => router.push(tool.href as never)} right={<Text style={t.text.meta}>›</Text>} />
33
+ </View>
34
+ ))}
35
+ </Card>
36
+ ) : (
37
+ <View style={{ backgroundColor: t.colors.sel, borderRadius: t.radius.card, padding: 14, gap: 6 }}>
38
+ <Text style={t.text.row}>Nothing here yet</Text>
39
+ <Text style={t.text.body}>This app is connected and signed in. Add screens from the site folder, for example:</Text>
40
+ <Text style={[t.text.meta, { fontFamily: 'Menlo' }]}>npx acms mobile recipe add editor-content</Text>
41
+ </View>
42
+ )}
43
+
44
+ <SectionLabel>Appearance</SectionLabel>
45
+ <Segmented<Appearance> accessibilityLabel="Appearance" value={appearance} onChange={setAppearance}
46
+ options={[{ value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }, { value: 'auto', label: 'Auto' }]} />
47
+
48
+ <SectionLabel>This device</SectionLabel>
49
+ <Card>
50
+ <ListRow title={auth.device?.name ?? 'This device'} meta="Signed in through the website. Sign out here or from the site’s Users page." />
51
+ </Card>
52
+ <EditorButton variant="danger" label="Sign out" onPress={() => { void auth.signOut(); }} />
53
+ </Screen>
54
+ );
55
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "expo": {
3
+ "name": "__ACMS_APP_NAME__",
4
+ "slug": "__ACMS_APP_SLUG__",
5
+ "version": "1.0.0",
6
+ "scheme": "__ACMS_APP_SCHEME__",
7
+ "orientation": "default",
8
+ "userInterfaceStyle": "automatic",
9
+ "ios": {
10
+ "bundleIdentifier": "__ACMS_BUNDLE_ID__",
11
+ "supportsTablet": true
12
+ },
13
+ "android": {
14
+ "package": "__ACMS_BUNDLE_ID__"
15
+ },
16
+ "web": {
17
+ "output": "single"
18
+ },
19
+ "plugins": ["expo-router", "expo-secure-store"]
20
+ }
21
+ }
@@ -0,0 +1,51 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Platform, RefreshControl, ScrollView, View, useWindowDimensions, type ScrollViewProps } from 'react-native';
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
+ import { useEditorTheme } from '@acms-subir/native/editor';
5
+
6
+ /** ≥ 768pt: tablets and desktop web use the width (list + detail), phones stack */
7
+ export function useWide() {
8
+ const { width } = useWindowDimensions();
9
+ return { wide: width >= 768, width, gutter: width >= 768 ? 24 : 16 };
10
+ }
11
+
12
+ /** a scrolling screen on the Inset Paper panel, readable width on big screens */
13
+ export function Screen({ children, refreshing, onRefresh, maxWidth = 760, ...rest }: ScrollViewProps & { children: ReactNode; refreshing?: boolean; onRefresh?: () => void; maxWidth?: number }) {
14
+ const t = useEditorTheme();
15
+ const { gutter } = useWide();
16
+ const insets = useSafeAreaInsets();
17
+ return (
18
+ <ScrollView
19
+ style={{ flex: 1, backgroundColor: t.colors.panel }}
20
+ contentContainerStyle={{ padding: gutter, paddingBottom: gutter + insets.bottom, gap: 16, width: '100%', maxWidth, alignSelf: 'center' }}
21
+ keyboardShouldPersistTaps="handled"
22
+ automaticallyAdjustKeyboardInsets
23
+ refreshControl={onRefresh ? <RefreshControl refreshing={!!refreshing} onRefresh={onRefresh} tintColor={t.colors.muted} /> : undefined}
24
+ {...rest}
25
+ >
26
+ {children}
27
+ </ScrollView>
28
+ );
29
+ }
30
+
31
+ /**
32
+ * List + detail. On tablets both show side by side (the list keeps a
33
+ * comfortable width, the detail uses the rest); on phones only the list shows
34
+ * and rows open their own screen.
35
+ */
36
+ export function SplitView({ list, detail, listWidth = 380 }: { list: ReactNode; detail: ReactNode; listWidth?: number }) {
37
+ const t = useEditorTheme();
38
+ const { wide } = useWide();
39
+ if (!wide) return <View style={{ flex: 1, backgroundColor: t.colors.panel }}>{list}</View>;
40
+ return (
41
+ <View style={{ flex: 1, flexDirection: 'row', backgroundColor: t.colors.shell }}>
42
+ <View style={{ width: listWidth, backgroundColor: t.colors.panel, borderRightWidth: 1, borderRightColor: t.colors.line }}>{list}</View>
43
+ <View style={{ flex: 1, backgroundColor: t.colors.panel }}>{detail}</View>
44
+ </View>
45
+ );
46
+ }
47
+
48
+ /** buttons in a screen header (the web header has no right inset of its own) */
49
+ export function HeaderActions({ children }: { children: ReactNode }) {
50
+ return <View style={{ flexDirection: 'row', gap: 8, marginRight: Platform.OS === 'web' ? 16 : 0 }}>{children}</View>;
51
+ }
@@ -0,0 +1,36 @@
1
+ import { ActivityIndicator, Image, Text, View } from 'react-native';
2
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
3
+ import { EditorButton, Notice, resolveUrl, useAuth, useEditorTheme, useSiteInfo } from '@acms-subir/native/editor';
4
+ import { siteUrl } from '../lib/config';
5
+ import { useWide } from './Screen';
6
+
7
+ // Staff sign in on the website itself (system browser): the app never sees a
8
+ // password. The site shows an approve screen, then returns here with a code.
9
+ export function SignIn() {
10
+ const t = useEditorTheme();
11
+ const auth = useAuth();
12
+ const site = useSiteInfo();
13
+ const insets = useSafeAreaInsets();
14
+ const { wide } = useWide();
15
+ const name = site.data?.name ?? '';
16
+ const logo = resolveUrl(site.data?.logo || site.data?.icon, siteUrl!);
17
+ const busy = auth.status === 'signingIn';
18
+ return (
19
+ <View style={{ flex: 1, backgroundColor: wide ? t.colors.shell : t.colors.panel, alignItems: 'center', justifyContent: 'center', padding: 24, paddingTop: 24 + insets.top }}>
20
+ <View style={{ width: '100%', maxWidth: 420, gap: 20, ...(wide ? { backgroundColor: t.colors.panel, borderRadius: t.radius.sheet, padding: 32, borderWidth: 1, borderColor: t.colors.line } : {}) }}>
21
+ <View style={{ gap: 12 }}>
22
+ {logo ? <Image source={{ uri: logo }} accessibilityLabel={name} style={{ height: 48, width: 160 }} resizeMode="contain" /> : null}
23
+ {site.isPending ? <ActivityIndicator color={t.colors.muted} style={{ alignSelf: 'flex-start' }} /> : (
24
+ <Text accessibilityRole="header" style={t.text.screenTitle}>{name || 'Your site'}</Text>
25
+ )}
26
+ <Text style={t.text.body}>Sign in with your {name || 'site'} account to write, edit sections and answer bookings from this device.</Text>
27
+ </View>
28
+ {!!auth.signedOutReason && <Notice tone="warn" title="Signed out">{auth.signedOutReason}</Notice>}
29
+ {!!auth.error && <Notice tone="error" title="Couldn’t sign in">{auth.error}</Notice>}
30
+ {site.isError && <Notice tone="warn">Can’t reach {siteUrl}. Check the address in .env and your connection.</Notice>}
31
+ <EditorButton variant="primary" label={busy ? 'Waiting for the browser…' : `Sign in with ${name || 'the site'}`} busy={busy} disabled={busy} onPress={() => { void auth.signIn(); }} />
32
+ <Text style={t.text.meta}>You’ll approve this device on the website. The app never sees your password, and you can sign it out any time from the site’s Users page.</Text>
33
+ </View>
34
+ </View>
35
+ );
36
+ }
@@ -0,0 +1,27 @@
1
+ import { Pressable, ScrollView, Text, View } from 'react-native';
2
+ import { useEditorTheme } from '@acms-subir/native/editor';
3
+
4
+ /**
5
+ * Filters as text tabs (never pills): the active one is stronger text with a
6
+ * short underline. Scrolls sideways when there are many.
7
+ */
8
+ export function TextTabs<T extends string>({ value, options, onChange, accessibilityLabel }: {
9
+ value: T; options: { value: T; label: string; count?: number }[]; onChange: (v: T) => void; accessibilityLabel?: string;
10
+ }) {
11
+ const t = useEditorTheme();
12
+ return (
13
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} accessibilityRole="tablist" accessibilityLabel={accessibilityLabel} contentContainerStyle={{ gap: 18, paddingRight: 8 }}>
14
+ {options.map((o) => {
15
+ const on = o.value === value;
16
+ return (
17
+ <Pressable key={o.value} accessibilityRole="tab" accessibilityState={{ selected: on }} onPress={() => onChange(o.value)} style={{ minHeight: 40, justifyContent: 'center' }}>
18
+ <Text style={{ color: on ? t.colors.text : t.colors.muted, fontWeight: on ? '700' : '500', fontSize: 15 }}>
19
+ {o.label}{o.count !== undefined ? <Text style={{ color: t.colors.muted, fontWeight: '500' }}>{` ${o.count}`}</Text> : null}
20
+ </Text>
21
+ <View style={{ height: 2, borderRadius: 1, marginTop: 4, backgroundColor: on ? t.colors.accent : 'transparent' }} />
22
+ </Pressable>
23
+ );
24
+ })}
25
+ </ScrollView>
26
+ );
27
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "cli": {
3
+ "version": ">= 16.0.0",
4
+ "appVersionSource": "remote"
5
+ },
6
+ "build": {
7
+ "development": {
8
+ "developmentClient": true,
9
+ "distribution": "internal"
10
+ },
11
+ "preview": {
12
+ "distribution": "internal"
13
+ },
14
+ "production": {
15
+ "autoIncrement": true
16
+ }
17
+ },
18
+ "submit": {
19
+ "production": {}
20
+ }
21
+ }
@@ -0,0 +1,5 @@
1
+ # Your ACMS site (public origin). Only public values belong here: EXPO_PUBLIC_*
2
+ # variables are bundled into the app. Never put passwords, API keys or tokens
3
+ # in it: staff sign in through the site in the browser, and the app keeps
4
+ # its per-device tokens in secure storage.
5
+ EXPO_PUBLIC_ACMS_URL=https://example.com
@@ -0,0 +1,42 @@
1
+ # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2
+
3
+ # dependencies
4
+ node_modules/
5
+
6
+ # Expo
7
+ .expo/
8
+ dist/
9
+ web-build/
10
+ expo-env.d.ts
11
+
12
+ # Native
13
+ .kotlin/
14
+ *.orig.*
15
+ *.jks
16
+ *.p8
17
+ *.p12
18
+ *.key
19
+ *.mobileprovision
20
+
21
+ # Metro
22
+ .metro-health-check*
23
+
24
+ # debug
25
+ npm-debug.*
26
+ yarn-debug.*
27
+ yarn-error.*
28
+
29
+ # macOS
30
+ .DS_Store
31
+ *.pem
32
+
33
+ # local env files
34
+ .env*.local
35
+
36
+ # typescript
37
+ *.tsbuildinfo
38
+
39
+
40
+ # generated native folders
41
+ /ios
42
+ /android
@@ -0,0 +1,12 @@
1
+ import { createContext, useContext, useState, type ReactNode } from 'react';
2
+
3
+ // Light / Dark / Auto for this session (Auto follows the device). Persist it
4
+ // with expo-secure-store or AsyncStorage if people should keep their choice.
5
+ export type Appearance = 'light' | 'dark' | 'auto';
6
+ const Ctx = createContext<{ appearance: Appearance; setAppearance: (a: Appearance) => void }>({ appearance: 'auto', setAppearance: () => {} });
7
+
8
+ export function AppearanceProvider({ children }: { children: ReactNode }) {
9
+ const [appearance, setAppearance] = useState<Appearance>('auto');
10
+ return <Ctx.Provider value={{ appearance, setAppearance }}>{children}</Ctx.Provider>;
11
+ }
12
+ export const useAppearance = () => useContext(Ctx);
@@ -0,0 +1,17 @@
1
+ import Constants from 'expo-constants';
2
+ import { redirectUriFor } from '@acms-subir/native/editor';
3
+
4
+ // Set in .env (production site) or .env.local (written by `npx acms mobile dev`).
5
+ export const siteUrl = process.env.EXPO_PUBLIC_ACMS_URL;
6
+
7
+ // The app's scheme (app.json) doubles as its sign-in return address:
8
+ // iOS/Android: <scheme>://auth web: <this web app's origin>/auth
9
+ // Both must be listed in the site's astro.config.mjs:
10
+ // acms({ mobile: { editor: { redirectUris: ['<scheme>://auth'] } } })
11
+ const configured = Constants.expoConfig?.scheme;
12
+ export const scheme = (Array.isArray(configured) ? configured[0] : configured) || '__ACMS_APP_SCHEME__';
13
+ export const redirectUri = redirectUriFor(scheme);
14
+
15
+ // Shown on the site's approve screen and device list. The bundle id is a good,
16
+ // stable choice (letters, digits, . _ : -).
17
+ export const clientId = '__ACMS_BUNDLE_ID__';