@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
@@ -7,7 +7,8 @@ import Highlight from '@tiptap/extension-highlight';
7
7
  import { TableKit } from '@tiptap/extension-table';
8
8
  import { Placeholder, CharacterCount } from '@tiptap/extensions';
9
9
  import { actions } from 'astro:actions';
10
- import type { CalEvent, Page, Post, Status } from '../../lib/schema';
10
+ import type { CalEvent, DocTranslations, Page, Post, Status } from '../../lib/schema';
11
+ import { textHash } from '../../theme-sdk/fields.mjs';
11
12
  import { ImageCard, EmbedCard, ButtonCard, Callout, HtmlCard, detectEmbed } from './extensions/cards';
12
13
  import { FileCard, GalleryCard, BookmarkCard } from './extensions/mediaCards';
13
14
  import { CodeBlock } from './extensions/codeBlock';
@@ -62,6 +63,10 @@ interface Props {
62
63
  templates?: string[];
63
64
  // public identity for the Search / Social preview tabs
64
65
  site?: { title: string; url: string };
66
+ // extra site languages (acms({ i18n })) and whether AI drafts are available
67
+ languages?: { code: string; name: string }[];
68
+ mainLanguage?: { code: string; name: string };
69
+ translationDrafts?: boolean;
65
70
  }
66
71
 
67
72
  interface Meta extends EventMeta {
@@ -136,13 +141,17 @@ function joinBrokenTableRows(text: string): string {
136
141
  return out.join('\n');
137
142
  }
138
143
 
144
+ type TrField = 'title' | 'excerpt' | 'bodyHtml' | 'seoTitle' | 'seoDesc' | 'locationName' | 'ctaLabel';
145
+ const emptyTr = () => ({ title: '', excerpt: '', bodyHtml: '', seoTitle: '', seoDesc: '', slug: '', locationName: '', ctaLabel: '', meta: {} as Record<string, { state: 'ai' | 'reviewed'; src: string }> });
146
+ const TR_STATE: Record<string, string> = { missing: 'not translated', ai: 'AI draft', reviewed: 'reviewed', outdated: 'English changed' };
147
+
139
148
  const NO_EVENT: EventMeta = {
140
149
  start: '', end: '', allDay: false, timezone: '', rrule: '', exdates: [], cancelled: false,
141
150
  location: { name: '', address: '', url: '' }, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
142
151
  rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4 },
143
152
  };
144
153
 
145
- export default function PostEditor({ kind = 'post', post, page, event, calendars = [], eventFields = [], siteTimezone = '', initialHTML, unsplashEnabled, previewPath, sharePath, livePath: livePathProp, templates = [], site = { title: '', url: '' } }: Props) {
154
+ export default function PostEditor({ kind = 'post', post, page, event, calendars = [], eventFields = [], siteTimezone = '', initialHTML, unsplashEnabled, previewPath, sharePath, livePath: livePathProp, templates = [], site = { title: '', url: '' }, languages = [], mainLanguage, translationDrafts = false }: Props) {
146
155
  const doc = (kind === 'page' ? page : kind === 'event' ? event : post)!;
147
156
  // posts and events share the feature image, excerpt, tags and "featured"
148
157
  const postLike = kind !== 'page';
@@ -230,6 +239,21 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
230
239
  };
231
240
  // excerpt line under the title: shown when it has text or was asked for
232
241
  const [excerptOpen, setExcerptOpen] = useState(false);
242
+
243
+ // ---- translations: the same document's words in another language ----
244
+ // Spanish (etc.) lives in `translations[lang]`; switching language swaps
245
+ // the editor body, the rest (dates, cover, tags…) stays shared.
246
+ const [trs, setTrs] = useState<DocTranslations>(() => ((doc as { translations?: DocTranslations }).translations ?? {}) as DocTranslations);
247
+ const trsRef = useRef(trs);
248
+ trsRef.current = trs;
249
+ const [lang, setLangState] = useState('');
250
+ const langRef = useRef('');
251
+ const mainBody = useRef<{ html: string; json: unknown } | null>(null);
252
+ const trBodyDirty = useRef(false);
253
+ const [trBusy, setTrBusy] = useState(false);
254
+ const [trMsg, setTrMsg] = useState('');
255
+ const mainLangName = mainLanguage?.name || 'English';
256
+ const mainLangShort = (mainLanguage?.code || 'en').toUpperCase();
233
257
  // sidebar: reveal the date input for drafts that have no date yet
234
258
  const [dateOpen, setDateOpen] = useState(false);
235
259
  // which Preview tab opens (the sidebar's search card jumps to Search)
@@ -322,7 +346,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
322
346
  return false;
323
347
  },
324
348
  },
325
- onUpdate: () => queueSave(),
349
+ onUpdate: () => { if (langRef.current) trBodyDirty.current = true; queueSave(); },
326
350
  onSelectionUpdate: ({ editor: ed }) => {
327
351
  // the top-level block holding the caret; the live pane scrolls to it.
328
352
  // Debounced and split-only so typing doesn't re-render the editor.
@@ -344,8 +368,18 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
344
368
  if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; }
345
369
  const m = { ...metaRef.current, ...overrides };
346
370
  setSave('saving');
347
- const bodyJson = JSON.stringify(ed.getJSON());
348
- const bodyHtml = ed.getHTML();
371
+ // in another language the editor holds that language's body: store it as
372
+ // the translation and keep sending the main-language body unchanged
373
+ if (langRef.current && trBodyDirty.current) {
374
+ trBodyDirty.current = false;
375
+ const l = langRef.current;
376
+ const cur = trsRef.current[l] ?? emptyTr();
377
+ trsRef.current = { ...trsRef.current, [l]: { ...cur, bodyHtml: ed.isEmpty ? '' : ed.getHTML(), meta: { ...cur.meta, bodyHtml: { state: 'reviewed', src: textHash(mainBody.current?.html ?? '') } } } };
378
+ setTrs(trsRef.current);
379
+ }
380
+ const bodyJson = langRef.current && mainBody.current ? JSON.stringify(mainBody.current.json) : JSON.stringify(ed.getJSON());
381
+ const bodyHtml = langRef.current && mainBody.current ? mainBody.current.html : ed.getHTML();
382
+ const translations = languages.length ? trsRef.current : undefined;
349
383
  if (kind === 'event') {
350
384
  const r = await actions.events.save({
351
385
  id: docId,
@@ -375,6 +409,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
375
409
  fields: m.fields,
376
410
  rsvp: m.rsvp,
377
411
  scheduledAt: m.scheduledAt,
412
+ translations,
378
413
  });
379
414
  if (r.error) {
380
415
  setSave('error');
@@ -403,6 +438,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
403
438
  status: m.status,
404
439
  isHome: m.isHome,
405
440
  locale: m.locale,
441
+ translations,
406
442
  });
407
443
  if (r.error) {
408
444
  setSave('error');
@@ -437,6 +473,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
437
473
  template: m.template,
438
474
  publishedAt: m.publishedAt,
439
475
  scheduledAt: m.scheduledAt,
476
+ translations,
440
477
  });
441
478
  if (r.error) {
442
479
  setSave('error');
@@ -463,6 +500,92 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
463
500
  saveTimer.current = setTimeout(() => save(), 1200);
464
501
  }, [save]);
465
502
 
503
+ // the main-language text a translated field is made from
504
+ const mainValue = (f: TrField): string => {
505
+ const m = metaRef.current;
506
+ switch (f) {
507
+ case 'title': return m.title;
508
+ case 'excerpt': return postLike ? m.excerpt : '';
509
+ case 'seoTitle': return m.seoTitle;
510
+ case 'seoDesc': return m.seoDesc;
511
+ case 'locationName': return kind === 'event' ? m.location.name : '';
512
+ case 'ctaLabel': return kind === 'event' ? m.cta.label : '';
513
+ default: return langRef.current && mainBody.current ? mainBody.current.html : (editorRef.current?.isEmpty ? '' : editorRef.current?.getHTML() ?? '');
514
+ }
515
+ };
516
+ const trFields = (): TrField[] => ['title', ...(postLike ? ['excerpt' as const] : []), 'bodyHtml', 'seoTitle', 'seoDesc', ...(kind === 'event' ? ['locationName' as const, 'ctaLabel' as const] : [])];
517
+ const trStatus = (l: string, f: TrField) => {
518
+ const src = mainValue(f);
519
+ if (!src.trim()) return 'n/a';
520
+ const t = trsRef.current[l];
521
+ const v = t?.[f] ?? '';
522
+ if (!String(v).trim()) return 'missing';
523
+ const meta = t?.meta?.[f];
524
+ if (meta && meta.src !== textHash(src)) return 'outdated';
525
+ return meta?.state === 'reviewed' ? 'reviewed' : 'ai';
526
+ };
527
+ const setTr = (f: TrField, value: string, state: 'ai' | 'reviewed' = 'reviewed') => {
528
+ const l = langRef.current;
529
+ if (!l) return;
530
+ const cur = trsRef.current[l] ?? emptyTr();
531
+ trsRef.current = { ...trsRef.current, [l]: { ...cur, [f]: value, meta: { ...cur.meta, [f]: { state, src: textHash(mainValue(f)) } } } };
532
+ setTrs(trsRef.current);
533
+ queueSave();
534
+ };
535
+ // switch the editor between the main language and a translation
536
+ const switchLang = async (next: string) => {
537
+ const ed = editorRef.current;
538
+ if (!ed || next === langRef.current) return;
539
+ await save();
540
+ if (!langRef.current) mainBody.current = { html: ed.getHTML(), json: ed.getJSON() };
541
+ const html = next ? (trsRef.current[next]?.bodyHtml ?? '') : (mainBody.current?.html ?? '');
542
+ langRef.current = next;
543
+ setLangState(next);
544
+ trBodyDirty.current = false;
545
+ ed.commands.setContent(html, { emitUpdate: false });
546
+ if (!next) mainBody.current = null;
547
+ setTrMsg('');
548
+ };
549
+ // machine drafts for every field still missing or with changed English
550
+ const translateAll = async (includeReviewed = false) => {
551
+ const l = langRef.current;
552
+ if (!l) return;
553
+ const fields = trFields().filter((f) => { const st = trStatus(l, f); return st === 'missing' || (st === 'outdated' && (includeReviewed || trsRef.current[l]?.meta?.[f]?.state !== 'reviewed')) || (includeReviewed && st === 'ai'); });
554
+ if (!fields.length) { setTrMsg('Everything is already translated.'); return; }
555
+ setTrBusy(true); setTrMsg('');
556
+ try {
557
+ const res = await fetch('/api/design/translate', {
558
+ method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
559
+ body: JSON.stringify({ locale: l, units: fields.map((f) => ({ id: f, text: mainValue(f), kind: f === 'bodyHtml' ? 'richtext' : f === 'excerpt' || f === 'seoDesc' ? 'textarea' : 'text', label: f })) }),
560
+ });
561
+ const body = await res.json().catch(() => ({})) as { results?: Record<string, string>; error?: string };
562
+ if (!res.ok) { setTrMsg(body.error || `Translation failed (${res.status}).`); return; }
563
+ const results = body.results ?? {};
564
+ const cur = trsRef.current[l] ?? emptyTr();
565
+ const next = { ...cur, meta: { ...cur.meta } } as typeof cur;
566
+ for (const f of fields) {
567
+ if (results[f] === undefined) continue;
568
+ (next as Record<string, unknown>)[f] = results[f];
569
+ next.meta[f] = { state: 'ai', src: textHash(mainValue(f)) };
570
+ }
571
+ trsRef.current = { ...trsRef.current, [l]: next };
572
+ setTrs(trsRef.current);
573
+ if (results.bodyHtml !== undefined && langRef.current === l) { editorRef.current?.commands.setContent(results.bodyHtml, { emitUpdate: false }); trBodyDirty.current = false; }
574
+ setTrMsg(`Translated ${Object.keys(results).length} ${Object.keys(results).length === 1 ? 'part' : 'parts'} as AI drafts. Check them, then mark them right.`);
575
+ queueSave();
576
+ } catch { setTrMsg('Couldn’t reach the server.'); } finally { setTrBusy(false); }
577
+ };
578
+ const confirmAll = () => {
579
+ const l = langRef.current;
580
+ if (!l) return;
581
+ const cur = trsRef.current[l] ?? emptyTr();
582
+ const meta = { ...cur.meta };
583
+ for (const f of trFields()) if (String(cur[f] ?? '').trim() && mainValue(f).trim()) meta[f] = { state: 'reviewed', src: textHash(mainValue(f)) };
584
+ trsRef.current = { ...trsRef.current, [l]: { ...cur, meta } };
585
+ setTrs(trsRef.current);
586
+ queueSave();
587
+ };
588
+
466
589
  const setMeta = useCallback((patch: Partial<Meta>) => {
467
590
  setMetaState((prev) => {
468
591
  const next = { ...prev, ...patch };
@@ -808,6 +931,17 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
808
931
  Preview
809
932
  </button>
810
933
  </div>
934
+ {languages.length > 0 && (
935
+ <div className="pe-seg pe-langs" role="radiogroup" aria-label="Language">
936
+ <button type="button" role="radio" aria-checked={!lang} onClick={() => void switchLang('')}>{mainLangShort}</button>
937
+ {languages.map((l) => (
938
+ <button key={l.code} type="button" role="radio" aria-checked={lang === l.code} title={`${l.name} version`} onClick={() => void switchLang(l.code)}>
939
+ {l.code.toUpperCase()}
940
+ {trFields().some((f) => ['missing', 'outdated'].includes(trStatus(l.code, f))) && <i className="pe-lang-dot" aria-label="needs translation" />}
941
+ </button>
942
+ ))}
943
+ </div>
944
+ )}
811
945
  <div className="pe-topbar-right">
812
946
  <div className="pe-publish-wrap">
813
947
  <button type="button" className="pe-btn primary sm" onClick={() => setPublishOpen((v) => !v)}>
@@ -973,7 +1107,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
973
1107
  Feature image
974
1108
  </button>
975
1109
  )}
976
- {!meta.excerpt && !excerptOpen && (
1110
+ {!meta.excerpt && !excerptOpen && !lang && (
977
1111
  <button type="button" title="A short teaser under the title, on post cards and in link previews" onClick={() => setExcerptOpen(true)}>
978
1112
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><path d="M4 7h16M4 12h10M4 17h7" /></svg>
979
1113
  Excerpt
@@ -982,6 +1116,36 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
982
1116
  </div>
983
1117
  )}
984
1118
 
1119
+ {lang && (() => {
1120
+ const name = languages.find((l) => l.code === lang)?.name ?? lang;
1121
+ const statuses = trFields().map((f) => [f, trStatus(lang, f)] as const).filter(([, st]) => st !== 'n/a');
1122
+ const label: Record<TrField, string> = { title: 'Title', excerpt: 'Excerpt', bodyHtml: 'Body', seoTitle: 'Search title', seoDesc: 'Search description', locationName: 'Place name', ctaLabel: 'Button text' };
1123
+ const t = trs[lang] ?? emptyTr();
1124
+ return (
1125
+ <section className="pe-trpanel" aria-label={`${name} version`}>
1126
+ <div className="pe-tr-head">
1127
+ <b>{name} version</b>
1128
+ <span className="pe-tr-states">{statuses.map(([f, st]) => <span key={f} className={`is-${st}`}>{label[f]}: {TR_STATE[st]}</span>)}</span>
1129
+ </div>
1130
+ <p className="pe-tr-note">You're editing the {name} words. Dates, photos, tags and links are shared with {mainLangName}. Anything left empty shows in {mainLangName}.</p>
1131
+ <div className="pe-tr-fields">
1132
+ {postLike && <label>Excerpt<textarea rows={2} value={t.excerpt} placeholder={meta.excerpt} onChange={(e) => setTr('excerpt', e.target.value)} /></label>}
1133
+ <label>Search title<input value={t.seoTitle} placeholder={meta.seoTitle || meta.title} onChange={(e) => setTr('seoTitle', e.target.value)} /></label>
1134
+ <label>Search description<textarea rows={2} value={t.seoDesc} placeholder={meta.seoDesc} onChange={(e) => setTr('seoDesc', e.target.value)} /></label>
1135
+ {kind === 'event' && <label>Place name<input value={t.locationName} placeholder={meta.location.name} onChange={(e) => setTr('locationName', e.target.value)} /></label>}
1136
+ {kind === 'event' && <label>Button text<input value={t.ctaLabel} placeholder={meta.cta.label} onChange={(e) => setTr('ctaLabel', e.target.value)} /></label>}
1137
+ </div>
1138
+ <div className="pe-tr-actions">
1139
+ {translationDrafts && <button type="button" className="pe-btn sm" disabled={trBusy} onClick={() => void translateAll()}>{trBusy ? 'Translating…' : `Translate the rest with AI`}</button>}
1140
+ {statuses.some(([, st]) => st === 'ai' || st === 'outdated') && <button type="button" className="pe-btn ghost sm" onClick={confirmAll}>✓ Looks right</button>}
1141
+ {!translationDrafts && <span className="pe-tr-msg">Turn on AI in Settings → AI &amp; agents and the {name} can draft itself.</span>}
1142
+ <button type="button" className="pe-btn ghost sm" onClick={() => void switchLang('')}>Back to {mainLangName}</button>
1143
+ {trMsg && <span className="pe-tr-msg" role="status">{trMsg}</span>}
1144
+ </div>
1145
+ </section>
1146
+ );
1147
+ })()}
1148
+
985
1149
  <textarea
986
1150
  className="pe-title"
987
1151
  rows={1}
@@ -989,19 +1153,19 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
989
1153
  // grow to fit on mount and on narrow screens, not just while typing
990
1154
  if (el) { el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px`; }
991
1155
  }}
992
- placeholder={kind === 'page' ? 'Page title' : `${col.singular} title`}
993
- value={meta.title}
1156
+ placeholder={lang ? meta.title || 'Title' : kind === 'page' ? 'Page title' : `${col.singular} title`}
1157
+ value={lang ? (trs[lang]?.title ?? '') : meta.title}
994
1158
  onChange={(e) => {
995
1159
  e.target.style.height = 'auto';
996
1160
  e.target.style.height = `${e.target.scrollHeight}px`;
997
- setMeta({ title: e.target.value });
1161
+ if (langRef.current) setTr('title', e.target.value); else setMeta({ title: e.target.value });
998
1162
  }}
999
1163
  onKeyDown={(e) => {
1000
1164
  if (e.key === 'Enter') { e.preventDefault(); editor?.commands.focus('start'); }
1001
1165
  }}
1002
1166
  />
1003
1167
 
1004
- {postLike && (meta.excerpt || excerptOpen) && (
1168
+ {postLike && !lang && (meta.excerpt || excerptOpen) && (
1005
1169
  <textarea
1006
1170
  className="pe-excerpt"
1007
1171
  rows={1}
@@ -13,7 +13,7 @@ interface Plan {
13
13
  }
14
14
  interface Run { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Report; createdAt: number }
15
15
  interface Report {
16
- counts?: { posts: { created: number; updated: number }; pages: { created: number; updated: number }; users: number; tags: number; media: number; redirects: number; nav: number };
16
+ counts?: { posts: { created: number; updated: number }; pages: { created: number; updated: number }; users: number; tags: number; media: number; redirects: number; nav: number; layouts?: number };
17
17
  settings?: string[];
18
18
  warnings?: { item: string; code: string; message: string }[];
19
19
  failedAssets?: { url: string; error: string }[];
@@ -336,6 +336,9 @@ function Done({ report, reset, openHistory }: { report: Report; reset: () => voi
336
336
  {report.settings?.length ? ' · site identity updated' : ''}
337
337
  </p>
338
338
  )}
339
+ {!!c?.layouts && (
340
+ <p className="imp-mark ok">{plural(c.layouts, 'page layout')} staged in the design draft · <a href="/admin/design#review">Review and publish</a></p>
341
+ )}
339
342
  {!!report.failedAssets?.length && (
340
343
  <>
341
344
  <p className="imp-mark warn">{plural(report.failedAssets.length, 'file')} couldn’t be copied and still point at the old site</p>
@@ -268,6 +268,7 @@ export async function runImport(
268
268
  nav: bundle.nav.map((n) => ({ ...n, url: fix(n.url) })),
269
269
  legacy: bundle.items.filter((i) => i.legacyPath).map((i) => ({ sourceId: i.sourceId, type: i.type, path: i.legacyPath })),
270
270
  redirects: bundle.redirects,
271
+ layouts: bundle.layouts ?? [],
271
272
  warnings: bundle.warnings,
272
273
  failedAssets,
273
274
  });