@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,18 @@
1
+ import { editorRoute, logWrite } from '../../../../../../../../../lib/editor/http';
2
+ import { DocKindParam } from '../../../../../../../../../lib/editor/documents';
3
+ import { SetDocInput, changeDocTranslation, getDocTranslation } from '../../../../../../../../../lib/editor/translations';
4
+ import { methodNotAllowed } from '../../../../../../../../../lib/mobile/http';
5
+ import { readJson } from '../../../../../../../../../lib/api';
6
+ import { invalidateRedirectCache } from '../../../../../../../../../middleware';
7
+ export { preflight as OPTIONS } from '../../../../../../../../../lib/mobile/http';
8
+ export const GET = editorRoute(async ({ repo, context: { params } }) => getDocTranslation(repo, DocKindParam.parse(params.kind), params.id!, params.locale!));
9
+ // a person's words: stored as reviewed
10
+ export const PUT = editorRoute(async (c) => {
11
+ const { params } = c.context;
12
+ const kind = DocKindParam.parse(params.kind);
13
+ const body = SetDocInput.parse(await readJson(c.context.request, 2_500_000));
14
+ const result = await changeDocTranslation(c.repo, kind, params.id!, params.locale!, body.revision, { op: 'set', values: body.values }, { url: c.context.url, locals: c.context.locals, onRedirect: invalidateRedirectCache });
15
+ await logWrite(c, 'translate', `${kind}:${params.id}:${params.locale}`);
16
+ return result;
17
+ }, { write: true });
18
+ export const ALL = () => methodNotAllowed('GET, PUT, OPTIONS');
@@ -0,0 +1,13 @@
1
+ import { editorRoute, logWrite } from '../../../../../lib/editor/http';
2
+ import { CreateInput, createDoc, docJson, listDocs, listQuery } from '../../../../../lib/editor/documents';
3
+ import { methodNotAllowed, queryParams } from '../../../../../lib/mobile/http';
4
+ import { readJson } from '../../../../../lib/api';
5
+ export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
6
+ export const GET = editorRoute(async ({ repo, context }) => listDocs(repo, listQuery.parse(queryParams(context.url))));
7
+ export const POST = editorRoute(async (c) => {
8
+ const input = CreateInput.parse(await readJson(c.context.request, 20_000));
9
+ const doc = await createDoc(c.repo, input, c.who.user.id);
10
+ await logWrite(c, 'create', `${input.kind}:${doc.id}`);
11
+ return docJson(input.kind, doc);
12
+ }, { write: true, status: 201 });
13
+ export const ALL = () => methodNotAllowed('GET, POST, OPTIONS');
@@ -0,0 +1,6 @@
1
+ import { editorRoute } from '../../../../lib/editor/http';
2
+ import { me } from '../../../../lib/editor/me';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ export const GET = editorRoute(({ repo, who, context }) => me(repo, who, context.url, context.locals));
6
+ export const ALL = () => methodNotAllowed('GET, OPTIONS');
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ import { editorRoute, logWrite } from '../../../../../lib/editor/http';
3
+ import { methodNotAllowed, pageQuery, queryParams } from '../../../../../lib/mobile/http';
4
+ import { ApiError, readResponseBytes } from '../../../../../lib/api';
5
+ import { MAX_UPLOAD_BODY, storeUpload } from '../../../../../lib/media/upload';
6
+ import type { Media } from '../../../../../lib/schema';
7
+ export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
8
+ const view = (m: Media) => ({ id: m.id, url: `/media/${m.key}`, filename: m.filename, mime: m.mime, width: m.width, height: m.height, size: m.size, alt: m.alt,
9
+ variants: m.variants.map((v) => ({ width: v.w, url: `/media/${v.key}` })), createdAt: new Date(m.createdAt).toISOString() });
10
+ const ListQuery = z.object({ q: z.string().max(200).optional(), type: z.enum(['image', 'document']).optional(), ...pageQuery }).strict();
11
+ export const GET = editorRoute(async ({ repo, context }) => {
12
+ const q = ListQuery.parse(queryParams(context.url));
13
+ let list = await repo.listMedia();
14
+ if (q.type) list = list.filter((m) => (q.type === 'image') === m.mime.startsWith('image/'));
15
+ if (q.q) { const needle = q.q.toLowerCase(); list = list.filter((m) => `${m.filename} ${m.alt}`.toLowerCase().includes(needle)); }
16
+ list.sort((a, b) => b.createdAt - a.createdAt);
17
+ const start = (q.page - 1) * q.limit;
18
+ return { items: list.slice(start, start + q.limit).map(view), pagination: { page: q.page, limit: q.limit, nextPage: start + q.limit < list.length ? q.page + 1 : null } };
19
+ });
20
+ // same types, limits and storage as the admin uploader (images arrive pre-compressed)
21
+ export const POST = editorRoute(async (c) => {
22
+ const type = c.context.request.headers.get('content-type') ?? '';
23
+ if (!type.toLowerCase().startsWith('multipart/form-data')) throw new ApiError(415, 'Use Content-Type: multipart/form-data with a "file" field.');
24
+ const bytes = await readResponseBytes(c.context.request, MAX_UPLOAD_BODY);
25
+ let form: FormData;
26
+ try { form = await new Response(bytes as BodyInit, { headers: { 'content-type': type } }).formData(); }
27
+ catch { throw new ApiError(400, 'The upload is not valid multipart form data.'); }
28
+ const media = await storeUpload(form, c.context.locals, c.repo);
29
+ await logWrite(c, 'upload', `media:${media.id}`);
30
+ return view(media);
31
+ }, { write: true, status: 201, limit: 30 });
32
+ export const ALL = () => methodNotAllowed('GET, POST, OPTIONS');
@@ -0,0 +1,14 @@
1
+ import { editorRoute, logWrite } from '../../../../../../../lib/editor/http';
2
+ import { SectionPatch, patchSection } from '../../../../../../../lib/editor/sections';
3
+ import { methodNotAllowed } from '../../../../../../../lib/mobile/http';
4
+ import { readJson } from '../../../../../../../lib/api';
5
+ export { preflight as OPTIONS } from '../../../../../../../lib/mobile/http';
6
+ // stage new field values for one section (content only for editors; locks apply)
7
+ export const PATCH = editorRoute(async (c) => {
8
+ const { pageId, sectionId } = c.context.params;
9
+ const body = SectionPatch.parse(await readJson(c.context.request, 500_000));
10
+ const result = await patchSection(c.repo, c.who, pageId!, sectionId!, body, c.context.url.origin);
11
+ await logWrite(c, 'section', `${pageId}:${sectionId}`);
12
+ return result;
13
+ }, { write: true });
14
+ export const ALL = () => methodNotAllowed('PATCH, OPTIONS');
@@ -0,0 +1,14 @@
1
+ import { editorRoute, logWrite } from '../../../../../../../lib/editor/http';
2
+ import { RevisionInput, publishSection } from '../../../../../../../lib/editor/sections';
3
+ import { methodNotAllowed } from '../../../../../../../lib/mobile/http';
4
+ import { readJson } from '../../../../../../../lib/api';
5
+ export { preflight as OPTIONS } from '../../../../../../../lib/mobile/http';
6
+ // publish just this section's staged copy (same rules as the Design screen)
7
+ export const POST = editorRoute(async (c) => {
8
+ const { pageId, sectionId } = c.context.params;
9
+ const body = RevisionInput.parse(await readJson(c.context.request, 5_000));
10
+ const result = await publishSection(c.repo, c.who, pageId!, sectionId!, body.revision, c.context.url.origin);
11
+ await logWrite(c, 'section-publish', `${pageId}:${sectionId}`);
12
+ return result;
13
+ }, { write: true });
14
+ export const ALL = () => methodNotAllowed('POST, OPTIONS');
@@ -0,0 +1,22 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { editorRoute, logWrite } from '../../../../../../../../../lib/editor/http';
3
+ import { AiSectionInput, ReviewSectionInput, changeSectionTranslation } from '../../../../../../../../../lib/editor/translations';
4
+ import { failure, methodNotAllowed } from '../../../../../../../../../lib/mobile/http';
5
+ import { ApiError, readJson } from '../../../../../../../../../lib/api';
6
+ export { preflight as OPTIONS } from '../../../../../../../../../lib/mobile/http';
7
+ const handler = (ai: boolean) => editorRoute(async (c) => {
8
+ const { params } = c.context;
9
+ const raw = await readJson(c.context.request, 10_000);
10
+ const mode = ai ? (() => { const b = AiSectionInput.parse(raw); return { revision: b.revision, mode: { op: 'ai' as const, paths: b.paths, includeReviewed: b.includeReviewed } }; })()
11
+ : (() => { const b = ReviewSectionInput.parse(raw); return { revision: b.revision, mode: { op: 'review' as const, paths: b.paths } }; })();
12
+ const result = await changeSectionTranslation(c.repo, c.who, params.pageId!, params.sectionId!, params.locale!, mode.revision, mode.mode, c.context.url.origin, c.context.locals);
13
+ await logWrite(c, ai ? 'translate-ai' : 'translate-review', `${params.pageId}:${params.sectionId}:${params.locale}`);
14
+ return result;
15
+ }, { write: true, ...(ai ? { limit: 10 } : {}) });
16
+ const review = handler(false), aiDraft = handler(true);
17
+ export const POST: APIRoute = (context) => {
18
+ if (context.params.action === 'review') return review(context);
19
+ if (context.params.action === 'ai-draft') return aiDraft(context);
20
+ return failure(new ApiError(404, 'Unknown action.'));
21
+ };
22
+ export const ALL = () => methodNotAllowed('POST, OPTIONS');
@@ -0,0 +1,14 @@
1
+ import { editorRoute, logWrite } from '../../../../../../../../../lib/editor/http';
2
+ import { SetSectionInput, changeSectionTranslation, getSectionTranslation } from '../../../../../../../../../lib/editor/translations';
3
+ import { methodNotAllowed } from '../../../../../../../../../lib/mobile/http';
4
+ import { readJson } from '../../../../../../../../../lib/api';
5
+ export { preflight as OPTIONS } from '../../../../../../../../../lib/mobile/http';
6
+ export const GET = editorRoute(async ({ repo, context: { params } }) => getSectionTranslation(repo, params.pageId!, params.sectionId!, params.locale!));
7
+ export const PUT = editorRoute(async (c) => {
8
+ const { params } = c.context;
9
+ const body = SetSectionInput.parse(await readJson(c.context.request, 500_000));
10
+ const result = await changeSectionTranslation(c.repo, c.who, params.pageId!, params.sectionId!, params.locale!, body.revision, { op: 'set', values: body.values }, c.context.url.origin, c.context.locals);
11
+ await logWrite(c, 'translate', `${params.pageId}:${params.sectionId}:${params.locale}`);
12
+ return result;
13
+ }, { write: true });
14
+ export const ALL = () => methodNotAllowed('GET, PUT, OPTIONS');
@@ -0,0 +1,6 @@
1
+ import { editorRoute } from '../../../../../lib/editor/http';
2
+ import { SectionsQuery, listSections } from '../../../../../lib/editor/sections';
3
+ import { methodNotAllowed, queryParams } from '../../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
5
+ export const GET = editorRoute(async ({ repo, who, context }) => listSections(repo, SectionsQuery.parse(queryParams(context.url)), who));
6
+ export const ALL = () => methodNotAllowed('GET, OPTIONS');
@@ -0,0 +1,5 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { authPreflight, revokeRoute } from '../../../../lib/editor/token-route';
3
+ // Editor apps: sign this device out (RFC 7009). Either token revokes the device.
4
+ export const POST: APIRoute = (context) => revokeRoute(context);
5
+ export const OPTIONS: APIRoute = () => authPreflight();
@@ -0,0 +1,5 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { authPreflight, tokenRoute } from '../../../../lib/editor/token-route';
3
+ // Editor apps: exchange a sign-in code (+ PKCE verifier) or a refresh token for device tokens.
4
+ export const POST: APIRoute = (context) => tokenRoute(context);
5
+ export const OPTIONS: APIRoute = () => authPreflight();
@@ -0,0 +1,20 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../../lib/repo';
3
+ import { unregisterDevice, updateDevice } from '../../../../lib/push/api';
4
+ import { failure, limitRequest, methodNotAllowed, response } from '../../../../lib/mobile/http';
5
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
6
+ // PUT: change topics/locale. DELETE: opt out (idempotent).
7
+ function handler(run: (repo: Awaited<ReturnType<typeof getRepo>>, request: Request, token: string) => Promise<unknown>): APIRoute {
8
+ return async context => {
9
+ try {
10
+ const repo = await getRepo(context.locals);
11
+ let address = 'unknown';
12
+ try { address = context.clientAddress || address; } catch { /* shared fallback bucket */ }
13
+ await limitRequest(repo, address, true);
14
+ return response(await run(repo, context.request, context.params.token ?? ''));
15
+ } catch (error) { return failure(error); }
16
+ };
17
+ }
18
+ export const PUT = handler((repo, request, token) => updateDevice(repo, request, token));
19
+ export const DELETE = handler((repo, _request, token) => unregisterDevice(repo, token));
20
+ export const ALL = () => methodNotAllowed('PUT, DELETE, OPTIONS');
@@ -0,0 +1,17 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../../lib/repo';
3
+ import { registerDevice } from '../../../../lib/push/api';
4
+ import { failure, limitRequest, methodNotAllowed, response } from '../../../../lib/mobile/http';
5
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
6
+ // Register or refresh this device's Expo push token (only when push is enabled).
7
+ export const POST: APIRoute = async context => {
8
+ try {
9
+ const repo = await getRepo(context.locals);
10
+ let address = 'unknown';
11
+ try { address = context.clientAddress || address; } catch { /* shared fallback bucket */ }
12
+ await limitRequest(repo, address, true);
13
+ const result = await registerDevice(repo, context.request);
14
+ return response(result, result.created ? 201 : 200);
15
+ } catch (error) { return failure(error); }
16
+ };
17
+ export const ALL = () => methodNotAllowed('POST, OPTIONS');
@@ -0,0 +1,7 @@
1
+ import { getOpenApi } from '../../../lib/mobile/openapi';
2
+ import { readRoute } from '../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../lib/mobile/http';
5
+ export const GET = readRoute(getOpenApi, true, true);
6
+ export const HEAD = GET;
7
+ export const ALL = () => methodNotAllowed();
@@ -0,0 +1,7 @@
1
+ import { getPage } from '../../../../lib/mobile/pages';
2
+ import { readRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ export const GET = readRoute(getPage);
6
+ export const HEAD = GET;
7
+ export const ALL = () => methodNotAllowed();
@@ -0,0 +1,7 @@
1
+ import { getPage } from '../../../../lib/mobile/pages';
2
+ import { readRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ export const GET = readRoute((repo, url) => getPage(repo, url, 'home'));
6
+ export const HEAD = GET;
7
+ export const ALL = () => methodNotAllowed();
@@ -0,0 +1,7 @@
1
+ import { listPushTopics } from '../../../../lib/push/api';
2
+ import { readRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ export const GET = readRoute(listPushTopics, true);
6
+ export const HEAD = GET;
7
+ export const ALL = () => methodNotAllowed();
@@ -0,0 +1,7 @@
1
+ import { getSite } from '../../../lib/mobile/site';
2
+ import { readRoute } from '../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../lib/mobile/http';
5
+ export const GET = readRoute(getSite);
6
+ export const HEAD = GET;
7
+ export const ALL = () => methodNotAllowed();
@@ -0,0 +1,7 @@
1
+ import { getThemeTokens } from '../../../lib/mobile/site';
2
+ import { readRoute } from '../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../lib/mobile/http';
5
+ export const GET = readRoute(getThemeTokens);
6
+ export const HEAD = GET;
7
+ export const ALL = () => methodNotAllowed();
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import { requestLocale } from '../../lib/i18n';
3
+ import { localizeDoc, isExtraLocale } from '../../lib/translations';
2
4
  import Site from '../../layouts/Site.astro';
3
5
  import { getRepo } from '../../lib/repo';
4
6
  import { renderMarkdown } from '../../lib/markdown';
@@ -20,7 +22,10 @@ if (!col) return new Response(null, { status: 404, statusText: 'Not found' });
20
22
 
21
23
  const { slug } = Astro.params;
22
24
  const repo = await getRepo(Astro.locals);
23
- const post = slug ? await repo.getPostBySlug(slug, col.key) : null;
25
+ // another language (/es/…): translated words over the shared post
26
+ const locale = requestLocale(Astro.url, Astro.locals);
27
+ const rawPost = slug ? await repo.getPostBySlug(slug, col.key) : null;
28
+ const post = rawPost ? localizeDoc(rawPost, locale) : null;
24
29
 
25
30
  const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
26
31
  if (!post || (post.status !== 'published' && !isAdmin)) {
@@ -35,7 +40,8 @@ const [tags, allUsers, allPosts, rawSettings, allMedia] = await Promise.all([
35
40
  post.cover.startsWith('/media/') ? repo.listMedia() : Promise.resolve([]),
36
41
  ]);
37
42
  // ?designPreview=1 (admin-only) renders the staged Design-screen changes
38
- const settings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
43
+ const baseSettings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
44
+ const settings = isExtraLocale(locale) ? { ...baseSettings, locale } : baseSettings;
39
45
  // ordered author objects; the first is the primary (gets the author card)
40
46
  const authors = authorIds
41
47
  .map((id) => allUsers.find((u) => u.id === id))
@@ -8,6 +8,8 @@ import { formatDate } from '../../lib/dates';
8
8
  import { resolveDesign } from '../../lib/theme';
9
9
  import { effectiveSettings } from '../../lib/designPreview';
10
10
  import { collectionFromPath, collectionUrl, tagUrl } from '../../lib/content';
11
+ import { requestLocale, localePath } from '../../lib/i18n';
12
+ import { localizeDoc, isExtraLocale } from '../../lib/translations';
11
13
 
12
14
  // One file serves every collection's index — injectRoute can't pass props,
13
15
  // so the collection is resolved from the request path.
@@ -16,8 +18,13 @@ if (!col) return new Response(null, { status: 404, statusText: 'Not found' });
16
18
 
17
19
  const PAGE_SIZE = 12;
18
20
  const repo = await getRepo(Astro.locals);
19
- const [allPosts, tags, users, rawSettings] = await Promise.all([repo.listPublishedPosts(col.key), repo.listTags(), repo.listUsers(), repo.getSettings()]);
20
- const settings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
21
+ // another language (/es/blog): translated titles/excerpts, links stay in the language
22
+ const locale = requestLocale(Astro.url, Astro.locals);
23
+ const lp = (path: string) => (isExtraLocale(locale) ? localePath(path, locale) : path);
24
+ const [rawPosts, tags, users, rawSettings] = await Promise.all([repo.listPublishedPosts(col.key), repo.listTags(), repo.listUsers(), repo.getSettings()]);
25
+ const allPosts = rawPosts.map((p) => localizeDoc(p, locale));
26
+ const baseSettings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
27
+ const settings = isExtraLocale(locale) ? { ...baseSettings, locale } : baseSettings;
21
28
  const design = resolveDesign(settings);
22
29
 
23
30
  // page 1 leads with the featured post (if any) as a big split card; it's
@@ -81,7 +88,7 @@ const iso = (ts: number | null) => (ts ? new Date(ts).toISOString() : undefined)
81
88
  <PostCard
82
89
  variant="feature"
83
90
  headingLevel={2}
84
- href={collectionUrl(col.key, lead.slug)}
91
+ href={lp(collectionUrl(col.key, lead.slug))}
85
92
  title={lead.title}
86
93
  excerpt={lead.excerpt}
87
94
  cover={lead.cover}
@@ -99,7 +106,7 @@ const iso = (ts: number | null) => (ts ? new Date(ts).toISOString() : undefined)
99
106
  <PostCard
100
107
  variant={layout === 'cards' ? 'card' : 'row'}
101
108
  headingLevel={2}
102
- href={collectionUrl(col.key, p.slug)}
109
+ href={lp(collectionUrl(col.key, p.slug))}
103
110
  title={p.title}
104
111
  excerpt={p.excerpt}
105
112
  cover={p.cover}
@@ -0,0 +1,42 @@
1
+ ---
2
+ import Site from '../../../layouts/Site.astro';
3
+ import ThemeSections from '../../../components/ThemeSections.astro';
4
+ import { getRepo } from '../../../lib/repo';
5
+ import { ApiError } from '../../../lib/api';
6
+ import { I18N } from '../../../lib/i18n';
7
+ import { embedSection, embedBridgeScript } from '../../../lib/mobile/embed';
8
+
9
+ // One published section, chrome-less, for mobile apps without a native
10
+ // component for it: GET /embed/section/<page slug|home>/<section id>
11
+ // ?locale=es the language to render (default: the site's)
12
+ // ?scheme=dark light/dark for themes that follow the device
13
+ // The document posts its height and link taps to the app (see
14
+ // lib/mobile/embed.ts); it never navigates itself.
15
+ const repo = await getRepo(Astro.locals);
16
+ let embed;
17
+ try {
18
+ embed = await embedSection(repo, Astro.url, Astro.params.path ?? '');
19
+ } catch (error) {
20
+ const status = error instanceof ApiError ? error.status : 500;
21
+ return new Response((status === 400 ? 'Bad request' : status === 404 ? 'Not found' : 'Error'), { status, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex' } });
22
+ }
23
+ if (I18N.enabled && embed.locale && embed.locale !== I18N.defaultLocale) (Astro.locals as { acmsLocale?: string }).acmsLocale = embed.locale;
24
+ Astro.response.headers.set('Content-Security-Policy', 'frame-ancestors *');
25
+ Astro.response.headers.set('Cache-Control', 'public, max-age=0, must-revalidate');
26
+ Astro.response.headers.set('X-Robots-Tag', 'noindex');
27
+ ---
28
+
29
+ <Site title="Section" bare noindex>
30
+ <div id="acms-embed" data-section={embed.sectionId} data-scheme={embed.scheme}>
31
+ <ThemeSections page={embed.page} settings={embed.settings} locale={embed.locale} editable={false} />
32
+ </div>
33
+ <style is:global>
34
+ html, body { background: var(--bg); }
35
+ main.site-main { max-width: none; margin: 0; padding: 0; }
36
+ #acms-embed { display: flow-root; }
37
+ #acms-embed .acms-sections { margin: 0; }
38
+ #acms-embed .acms-section { padding: 0; }
39
+ astro-dev-toolbar { display: none !important; }
40
+ </style>
41
+ <script is:inline set:html={embedBridgeScript(embed.sectionId)} />
42
+ </Site>
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import { requestLocale } from '../../lib/i18n';
3
+ import { localizeDoc, isExtraLocale } from '../../lib/translations';
2
4
  import Site from '../../layouts/Site.astro';
3
5
  import EventPage from '../../components/EventPage.astro';
4
6
  import { getRepo } from '../../lib/repo';
@@ -21,13 +23,16 @@ import { waitUntilFrom } from '../../lib/webhooks';
21
23
  // repeating event — the calendar grid links carry it.
22
24
  if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
23
25
  const repo = await getRepo(Astro.locals);
24
- const event = await repo.getEventBySlug(Astro.params.slug ?? '');
26
+ const locale = requestLocale(Astro.url, Astro.locals);
27
+ const rawEvent = await repo.getEventBySlug(Astro.params.slug ?? '');
28
+ const event = rawEvent ? localizeDoc(rawEvent, locale) : null;
25
29
  const [rawSettings, allCalendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
26
30
  const calMap = new Map(allCalendars.map((c) => [c.id, c]));
27
31
  const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
28
32
  if (!event || (!eventIsPublic(event, calMap) && !isAdmin)) return new Response(null, { status: 404, statusText: 'Not found' });
29
33
 
30
- const settings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
34
+ const baseSettings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
35
+ const settings = isExtraLocale(locale) ? { ...baseSettings, locale } : baseSettings;
31
36
  const now = Date.now();
32
37
  const on = Astro.url.searchParams.get('on') ?? undefined;
33
38
  const occurrence = featuredOccurrence(event, settings, now, on && /^\d{4}-\d{2}-\d{2}$/.test(on) ? on : undefined);
@@ -14,14 +14,20 @@ const repo = await getRepo(Astro.locals);
14
14
  const locale = I18N.enabled ? localeFromPath(Astro.url.pathname) : I18N.defaultLocale;
15
15
  const wantsLocaleHome = I18N.enabled && locale !== I18N.defaultLocale;
16
16
  let [home, rawSettings] = await Promise.all([
17
- repo.getHomePage(wantsLocaleHome ? locale : undefined),
17
+ repo.getHomePage(),
18
18
  repo.getSettings(),
19
19
  ]);
20
20
  // ?designPreview=1 (admin-only) renders the staged Design-screen changes
21
21
  const settings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
22
- // a locale home that isn't authored yet falls back to the default home —
23
- // /es must never 404 mid-setup
24
- if (wantsLocaleHome && !home) home = await repo.getHomePage();
22
+ // Another language: the default home page's section layout with that
23
+ // language's translated words, once it has any. A separately authored home
24
+ // page for the language (the older setup) keeps serving until then, and /es
25
+ // never 404s mid-setup.
26
+ if (wantsLocaleHome) {
27
+ const translated = Boolean(home && settings.sectionPages?.[home.id]?.sections.some((s) => s.translations?.[locale]));
28
+ const separate = translated ? null : await repo.getHomePage(locale);
29
+ if (separate) home = separate;
30
+ }
25
31
 
26
32
  // Live state must not survive in a cached page after its snapshot expires.
27
33
  if (settings.live.mode !== 'off' || home?.blocks.some(block => block.type === 'live') ||
@@ -46,7 +52,7 @@ const homeForTemplate = home ? { ...home, bodyHtml: homeHtml } : null;
46
52
 
47
53
  <Site title={home?.seoTitle || undefined} description={home?.seoDesc || undefined}>
48
54
  {home && settings.sectionPages?.[home.id]?.mode === 'sections' ? (
49
- <ThemeSections page={home} settings={settings} />
55
+ <ThemeSections page={home} settings={settings} locale={locale} />
50
56
  ) : HomeTemplate ? (
51
57
  <HomeTemplate settings={settings} page={homeForTemplate} posts={posts} tags={tags} />
52
58
  ) : home ? (
@@ -5,6 +5,8 @@ import { siteOrigin, xmlEscape } from '../lib/seo';
5
5
  import { CONTENT, collectionUrl, tagUrl } from '../lib/content';
6
6
  import { CALENDAR, eventUrl, bookingsEnabled, resourceUrl } from '../lib/calendar/config';
7
7
  import { eventIsPublic } from '../lib/calendar/events';
8
+ import { extraLocales, localePath } from '../lib/i18n';
9
+ import { hasTranslation } from '../lib/translations';
8
10
 
9
11
  // DB-backed sitemap: content lives in D1/libSQL, so this is generated per
10
12
  // request instead of at build time. Draft/scheduled content never appears.
@@ -49,6 +51,12 @@ export const GET: APIRoute = async ({ locals, url }) => {
49
51
  ...publicEvents.map((e) => ({ loc: eventUrl(e.slug), lastmod: e.updatedAt })),
50
52
  ]
51
53
  : []),
54
+ // translated documents also live at /<locale>/… (acms({ i18n }))
55
+ ...extraLocales().flatMap((l) => [
56
+ ...pages.filter((p) => p.status === 'published' && !p.isHome && hasTranslation(p, l)).map((p) => ({ loc: localePath(`/${p.slug}`, l), lastmod: p.updatedAt })),
57
+ ...posts.filter((p) => hasTranslation(p, l)).map((p) => ({ loc: localePath(collectionUrl(p.collection, p.slug), l), lastmod: p.updatedAt })),
58
+ ...publicEvents.filter((e) => hasTranslation(e, l)).map((e) => ({ loc: localePath(eventUrl(e.slug), l), lastmod: e.updatedAt })),
59
+ ]),
52
60
  ...(bookingsEnabled()
53
61
  ? [{ loc: CALENDAR.bookBase }, ...(await repo.listResources()).filter((r) => r.visibility === 'public').map((r) => ({ loc: resourceUrl(r.slug), lastmod: r.updatedAt }))]
54
62
  : []),
@@ -756,6 +756,43 @@ dialog.modal[open] { animation: modal-in 0.16s ease-out; }
756
756
  .login-wrap { background: var(--bg); }
757
757
  .btn.block { width: 100%; justify-content: center; min-height: 38px; }
758
758
  .login-note { margin-top: 14px; }
759
+ .login-for-app { text-align: center; margin: -8px 0 14px; color: var(--muted); }
760
+
761
+ /* editor-app sign-in (consent) — same paper card as login, a little wider */
762
+ .consent-wrap { align-items: start; padding-top: max(8vh, 20px); }
763
+ .consent-card { width: 100%; max-width: 440px; padding: 0; overflow: hidden; }
764
+ .consent-site { display: flex; align-items: center; gap: 8px; padding: 14px 22px; border-bottom: 1px solid var(--line); color: var(--text-2); font-weight: 600; font-size: 13px; min-width: 0; }
765
+ .consent-site span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
766
+ .consent-body { padding: 22px 22px 18px; }
767
+ .consent-body h1 { font-size: 20px; line-height: 1.3; letter-spacing: -0.02em; margin: 0 0 6px; overflow-wrap: anywhere; }
768
+ .consent-app { color: var(--text); }
769
+ .consent-lead { color: var(--muted); margin: 0 0 18px; }
770
+ .consent-lead b { color: var(--text-2); font-weight: 600; }
771
+ .consent-who { display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-md); margin-bottom: 18px; min-width: 0; }
772
+ .consent-initial { flex: none; width: 36px; height: 36px; border-radius: var(--r-md); display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-deep); font-weight: 700; font-size: 15px; }
773
+ .consent-id { display: flex; flex-direction: column; min-width: 0; flex: 1; }
774
+ .consent-id b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
775
+ .consent-id span { color: var(--muted); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
776
+ .consent-role { flex: none; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--text-2); }
777
+ .consent-role i { width: 7px; height: 7px; border-radius: 2px; background: var(--blue); }
778
+ .consent-role i.is-owner { background: var(--accent); }
779
+ .consent-label { font-size: 12px; font-weight: 600; color: var(--muted); margin: 0 0 6px; }
780
+ .consent-can { list-style: none; margin: 0 0 18px; padding: 0; display: grid; gap: 6px; }
781
+ .consent-can li { position: relative; padding-left: 20px; color: var(--text-2); }
782
+ .consent-can li::before { content: ''; position: absolute; left: 3px; top: 0.45em; width: 8px; height: 5px; border-left: 2px solid var(--green); border-bottom: 2px solid var(--green); transform: rotate(-45deg); }
783
+ .consent-device { margin-bottom: 16px; }
784
+ .consent-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
785
+ .consent-actions .btn { justify-content: center; min-height: 40px; }
786
+ .consent-meta { margin: 18px 0 0; padding-top: 14px; border-top: 1px solid var(--line); display: grid; gap: 6px; font-size: 12px; }
787
+ .consent-meta div { display: flex; gap: 10px; min-width: 0; }
788
+ .consent-meta dt { color: var(--faint); flex: 0 0 76px; }
789
+ .consent-meta dd { margin: 0; min-width: 0; overflow-wrap: anywhere; color: var(--text-2); }
790
+ .consent-meta code { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 11.5px; }
791
+ .consent-foot { margin: 12px 0 0; }
792
+ @media (max-width: 480px) {
793
+ .consent-wrap { padding: 0; align-items: stretch; }
794
+ .consent-card { max-width: none; border-radius: 0; border: 0; min-height: 100vh; box-shadow: none; }
795
+ }
759
796
 
760
797
  /* recent list on dashboard */
761
798
  .recent { list-style: none; margin: 0; padding: 0; }
@@ -1473,3 +1510,35 @@ a.bk-row:hover, .bk-row.is-sel { background: var(--hover); }
1473
1510
  .dm-hist li { flex-direction: column; align-items: flex-start; }
1474
1511
  .dm-status-actions { margin-left: 0; width: 100%; }
1475
1512
  }
1513
+
1514
+ /* role: marker + text (no badges) */
1515
+ .role-mark { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--text-2); }
1516
+ .role-mark i { width: 7px; height: 7px; border-radius: 2px; background: var(--blue); }
1517
+ .role-mark i.is-owner { background: var(--accent); }
1518
+
1519
+ /* Users → Signed-in apps */
1520
+ .devices-card { margin-top: 20px; }
1521
+ .devices-head { padding: 16px 20px 12px; border-bottom: 1px solid var(--line); }
1522
+ .devices-head h2 { font-size: 15px; margin: 0 0 2px; letter-spacing: -0.01em; }
1523
+ .devices-head .hint { margin: 0; }
1524
+ .devices-empty { color: var(--muted); padding: 18px 20px 20px; margin: 0; }
1525
+ .devices-group + .devices-group { border-top: 1px solid var(--line); }
1526
+ .devices-person { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 20px 2px; min-width: 0; }
1527
+ .devices-person > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1528
+ .devices-person .t-sub { color: var(--faint); font-size: 12px; }
1529
+ .devices-list { list-style: none; margin: 0; padding: 4px 8px 8px; }
1530
+ .device-row { display: grid; grid-template-columns: 32px minmax(0, 1.2fr) minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 10px 12px; border-radius: var(--r-md); }
1531
+ .device-row:hover { background: var(--hover); }
1532
+ .device-icon { width: 32px; height: 32px; border-radius: var(--r-sm); display: grid; place-items: center; background: var(--panel-2); border: 1px solid var(--line); color: var(--muted); }
1533
+ .device-main { display: flex; flex-direction: column; min-width: 0; }
1534
+ .device-main b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1535
+ .device-main .t-sub { color: var(--faint); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 11.5px; }
1536
+ .device-when { display: flex; flex-wrap: wrap; column-gap: 16px; row-gap: 2px; color: var(--text-2); font-size: 12.5px; font-variant-numeric: tabular-nums; }
1537
+ .device-when > span { white-space: nowrap; }
1538
+ .device-k { color: var(--faint); margin-right: 3px; }
1539
+ @media (max-width: 640px) {
1540
+ .device-row { grid-template-columns: 32px minmax(0, 1fr) auto; row-gap: 4px; }
1541
+ .device-when { grid-column: 2 / 4; grid-row: 2; }
1542
+ .device-action { grid-column: 3; grid-row: 1; }
1543
+ .devices-head, .devices-person { padding-left: 16px; padding-right: 16px; }
1544
+ }
@@ -1024,3 +1024,24 @@
1024
1024
  .pe-evcard-one small { display: block; font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--accent-deep); }
1025
1025
  .pe-evcard-one b { display: block; font-size: 15px; color: var(--text); margin-top: 2px; }
1026
1026
  .pe-evcard-one em { display: block; font-style: normal; font-size: 12px; color: var(--amber); margin-top: 3px; }
1027
+
1028
+ /* ---- translations: language switch + panel ---- */
1029
+ .pe-langs button { position: relative; padding: 4px 10px; font-weight: 600; letter-spacing: .02em; }
1030
+ .pe-lang-dot { position: absolute; top: 3px; right: 3px; width: 6px; height: 6px; border-radius: 2px; background: var(--amber, #93660c); }
1031
+ .pe-trpanel { margin: 0 0 18px; padding: 14px 16px; border-radius: var(--r-md); background: var(--panel-2); font-size: 13px; color: var(--text-2); }
1032
+ .pe-tr-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; }
1033
+ .pe-tr-head b { font-size: 14px; color: var(--text); }
1034
+ .pe-tr-states { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 12px; }
1035
+ .pe-tr-states .is-missing { color: var(--red); }
1036
+ .pe-tr-states .is-outdated { color: var(--amber, #93660c); }
1037
+ .pe-tr-states .is-ai { color: var(--blue, #2f5fb3); }
1038
+ .pe-tr-states .is-reviewed { color: var(--green, #127442); }
1039
+ .pe-tr-note { margin: 6px 0 10px; color: var(--muted); font-size: 12.5px; }
1040
+ .pe-tr-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 10px; }
1041
+ .pe-tr-fields label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 500; color: var(--muted); }
1042
+ .pe-tr-fields input, .pe-tr-fields textarea { font: inherit; font-size: 13.5px; color: var(--text); padding: 7px 10px; border: 1px solid var(--line-strong); border-radius: var(--r-sm); background: var(--bg); resize: vertical; }
1043
+ .pe-tr-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
1044
+ .pe-tr-msg { font-size: 12.5px; color: var(--muted); }
1045
+ @media (max-width: 767px) { .pe-langs { order: 3; } .pe-tr-fields input, .pe-tr-fields textarea { font-size: 16px; } }
1046
+ .pe-seg button[aria-checked='true'] { background: var(--bg); color: var(--text); box-shadow: var(--shadow-sm); }
1047
+ :root[data-theme='dark'] .pe-seg button[aria-checked='true'] { background: #3a3632; }