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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/preview.mjs +8 -4
  9. package/cli/sections.mjs +188 -0
  10. package/cli/themes.mjs +52 -8
  11. package/docs/mobile.md +527 -0
  12. package/docs/sections.md +203 -0
  13. package/docs/theme-authoring.md +19 -3
  14. package/integration.mjs +76 -5
  15. package/mobile-template/client/AGENTS.md +97 -0
  16. package/mobile-template/client/README.md +65 -0
  17. package/mobile-template/client/app/_layout.tsx +56 -0
  18. package/mobile-template/client/app/index.tsx +21 -0
  19. package/mobile-template/client/app.json +21 -0
  20. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  21. package/mobile-template/client/eas.json +21 -0
  22. package/mobile-template/client/env.example +3 -0
  23. package/mobile-template/client/gitignore +42 -0
  24. package/mobile-template/client/package.json +38 -0
  25. package/mobile-template/client/sections/index.ts +11 -0
  26. package/mobile-template/client/tsconfig.json +7 -0
  27. package/mobile-template/editor/AGENTS.md +66 -0
  28. package/mobile-template/editor/README.md +73 -0
  29. package/mobile-template/editor/app/_layout.tsx +76 -0
  30. package/mobile-template/editor/app/auth.tsx +13 -0
  31. package/mobile-template/editor/app/index.tsx +55 -0
  32. package/mobile-template/editor/app.json +21 -0
  33. package/mobile-template/editor/components/Screen.tsx +51 -0
  34. package/mobile-template/editor/components/SignIn.tsx +36 -0
  35. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  36. package/mobile-template/editor/eas.json +21 -0
  37. package/mobile-template/editor/env.example +5 -0
  38. package/mobile-template/editor/gitignore +42 -0
  39. package/mobile-template/editor/lib/appearance.tsx +12 -0
  40. package/mobile-template/editor/lib/config.ts +17 -0
  41. package/mobile-template/editor/lib/format.ts +24 -0
  42. package/mobile-template/editor/lib/tools.ts +16 -0
  43. package/mobile-template/editor/package.json +40 -0
  44. package/mobile-template/editor/tools/README.md +2 -0
  45. package/mobile-template/editor/tsconfig.json +7 -0
  46. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  47. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  48. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  49. package/mobile-template/recipes/booking/recipe.json +17 -0
  50. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  51. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  52. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  54. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  55. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  56. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  57. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  58. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  59. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  60. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  62. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  63. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  64. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  65. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  67. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  68. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  69. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  70. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  72. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  73. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  74. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  75. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  76. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  77. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  78. package/mobile-template/recipes/events/recipe.json +15 -0
  79. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  80. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  81. package/mobile-template/recipes/news/recipe.json +13 -0
  82. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  83. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  84. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  85. package/mobile-template/recipes/push/recipe.json +20 -0
  86. package/package.json +17 -13
  87. package/section-library/README.md +16 -0
  88. package/section-library/contact/Contact.astro +49 -0
  89. package/section-library/contact/section.json +106 -0
  90. package/section-library/cta-band/CtaBand.astro +28 -0
  91. package/section-library/cta-band/section.json +51 -0
  92. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  93. package/section-library/feature-grid/section.json +107 -0
  94. package/section-library/gallery/Gallery.astro +29 -0
  95. package/section-library/gallery/section.json +51 -0
  96. package/section-library/hero-banner/HeroBanner.astro +42 -0
  97. package/section-library/hero-banner/section.json +81 -0
  98. package/section-library/latest-posts/LatestPosts.astro +22 -0
  99. package/section-library/latest-posts/section.json +37 -0
  100. package/section-library/pricing/Pricing.astro +40 -0
  101. package/section-library/pricing/section.json +117 -0
  102. package/section-library/questions/Questions.astro +38 -0
  103. package/section-library/questions/section.json +69 -0
  104. package/section-library/service-times/ServiceTimes.astro +42 -0
  105. package/section-library/service-times/section.json +127 -0
  106. package/section-library/team/Team.astro +45 -0
  107. package/section-library/team/section.json +88 -0
  108. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  109. package/section-library/upcoming-events/section.json +51 -0
  110. package/src/actions/index.ts +205 -174
  111. package/src/components/AiSettings.tsx +45 -5
  112. package/src/components/DesignManager.tsx +112 -16
  113. package/src/components/MobileApp.tsx +392 -0
  114. package/src/components/ThemeSections.astro +40 -21
  115. package/src/components/ThemeSectionsEditor.tsx +757 -229
  116. package/src/components/booking/BookingFlow.astro +6 -3
  117. package/src/components/calendar/BookingsHead.astro +51 -0
  118. package/src/components/calendar/CalendarBoard.tsx +618 -0
  119. package/src/components/calendar/MiniMonth.tsx +54 -0
  120. package/src/components/calendar/NewEventDialog.tsx +44 -0
  121. package/src/components/calendar/QuickAdd.tsx +263 -0
  122. package/src/components/calendar/RoomHolds.tsx +203 -0
  123. package/src/components/calendar/WhereItLands.tsx +51 -0
  124. package/src/components/editor/EventPanel.tsx +23 -2
  125. package/src/components/editor/PostEditor.tsx +206 -14
  126. package/src/components/import/ImportExport.tsx +4 -1
  127. package/src/components/import/runner.ts +1 -0
  128. package/src/components/sections/SectionFields.tsx +560 -0
  129. package/src/components/sections/SectionPicker.tsx +120 -0
  130. package/src/components/sections/TranslationPanel.tsx +143 -0
  131. package/src/components/sections/checks.ts +64 -0
  132. package/src/layouts/Admin.astro +11 -7
  133. package/src/layouts/Site.astro +136 -21
  134. package/src/lib/agent-discovery.ts +11 -7
  135. package/src/lib/agent.ts +35 -2
  136. package/src/lib/ai-schema.ts +5 -0
  137. package/src/lib/ai.ts +5 -0
  138. package/src/lib/api.ts +12 -0
  139. package/src/lib/backup/content.ts +6 -1
  140. package/src/lib/booking/agent.ts +3 -2
  141. package/src/lib/booking/approval.ts +70 -0
  142. package/src/lib/booking/holds.ts +120 -0
  143. package/src/lib/booking/service.ts +37 -3
  144. package/src/lib/calendar/agent-schema.ts +9 -1
  145. package/src/lib/calendar/agent.ts +16 -1
  146. package/src/lib/calendar/board.ts +171 -0
  147. package/src/lib/calendar/config.ts +3 -0
  148. package/src/lib/calendar/grid.ts +169 -0
  149. package/src/lib/calendar/migration.ts +16 -0
  150. package/src/lib/calendar/render.ts +9 -7
  151. package/src/lib/calendar/schedule.ts +166 -0
  152. package/src/lib/content-service.ts +203 -0
  153. package/src/lib/design-service.ts +153 -5
  154. package/src/lib/drafts.ts +3 -3
  155. package/src/lib/editor/auth.ts +264 -0
  156. package/src/lib/editor/bookings.ts +60 -0
  157. package/src/lib/editor/config.ts +25 -0
  158. package/src/lib/editor/documents.ts +227 -0
  159. package/src/lib/editor/http.ts +48 -0
  160. package/src/lib/editor/me.ts +30 -0
  161. package/src/lib/editor/migration.ts +57 -0
  162. package/src/lib/editor/next.ts +11 -0
  163. package/src/lib/editor/openapi.ts +124 -0
  164. package/src/lib/editor/schemas.ts +91 -0
  165. package/src/lib/editor/sections.ts +96 -0
  166. package/src/lib/editor/store.ts +184 -0
  167. package/src/lib/editor/token-route.ts +74 -0
  168. package/src/lib/editor/translations.ts +163 -0
  169. package/src/lib/email/accounts.ts +2 -1
  170. package/src/lib/email/types.ts +2 -1
  171. package/src/lib/home-source.ts +34 -0
  172. package/src/lib/i18n.ts +34 -0
  173. package/src/lib/import/apply.ts +31 -2
  174. package/src/lib/import/bundle.ts +4 -0
  175. package/src/lib/import/export.ts +1 -0
  176. package/src/lib/import/ghost.ts +1 -0
  177. package/src/lib/media/upload.ts +83 -0
  178. package/src/lib/mobile/associations.ts +25 -0
  179. package/src/lib/mobile/calendar.ts +26 -14
  180. package/src/lib/mobile/config.mjs +59 -0
  181. package/src/lib/mobile/content.ts +18 -13
  182. package/src/lib/mobile/embed.ts +83 -0
  183. package/src/lib/mobile/http.ts +14 -6
  184. package/src/lib/mobile/locale.ts +18 -0
  185. package/src/lib/mobile/openapi.ts +111 -0
  186. package/src/lib/mobile/pages.ts +71 -0
  187. package/src/lib/mobile/route.ts +3 -3
  188. package/src/lib/mobile/schemas.ts +60 -0
  189. package/src/lib/mobile/site.ts +41 -0
  190. package/src/lib/push/api.ts +158 -0
  191. package/src/lib/push/config.ts +20 -0
  192. package/src/lib/push/expo.ts +76 -0
  193. package/src/lib/push/migration.ts +60 -0
  194. package/src/lib/push/service.ts +302 -0
  195. package/src/lib/push/store.ts +144 -0
  196. package/src/lib/push/types.ts +43 -0
  197. package/src/lib/repo/migrations.ts +14 -0
  198. package/src/lib/repo/sql.ts +65 -20
  199. package/src/lib/repo/types.ts +10 -4
  200. package/src/lib/roles.ts +5 -0
  201. package/src/lib/scheduler.ts +6 -1
  202. package/src/lib/schema.ts +76 -1
  203. package/src/lib/section-ai.ts +55 -0
  204. package/src/lib/section-targets.ts +62 -0
  205. package/src/lib/sections-agent.ts +146 -0
  206. package/src/lib/sections.ts +242 -0
  207. package/src/lib/site-chrome.ts +34 -0
  208. package/src/lib/theme-assets.ts +16 -0
  209. package/src/lib/theme.ts +33 -4
  210. package/src/lib/translate.ts +97 -0
  211. package/src/lib/translations.ts +39 -0
  212. package/src/middleware.ts +38 -2
  213. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  214. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  215. package/src/pages/[...slug].astro +8 -3
  216. package/src/pages/admin/app-auth.astro +159 -0
  217. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  218. package/src/pages/admin/calendar/bookings.astro +32 -26
  219. package/src/pages/admin/calendar/calendars.astro +4 -263
  220. package/src/pages/admin/calendar/events/[id].astro +9 -0
  221. package/src/pages/admin/calendar/events/index.astro +41 -83
  222. package/src/pages/admin/calendar/index.astro +43 -0
  223. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  224. package/src/pages/admin/calendar/resources/index.astro +16 -17
  225. package/src/pages/admin/calendar/resources/link.astro +52 -12
  226. package/src/pages/admin/calendar/settings.astro +458 -0
  227. package/src/pages/admin/integrations.astro +5 -11
  228. package/src/pages/admin/login.astro +6 -2
  229. package/src/pages/admin/logout.ts +5 -2
  230. package/src/pages/admin/mobile.astro +18 -0
  231. package/src/pages/admin/pages/[id].astro +62 -0
  232. package/src/pages/admin/section-preview.astro +61 -0
  233. package/src/pages/admin/users.astro +107 -11
  234. package/src/pages/api/agent/docs.ts +6 -2
  235. package/src/pages/api/agent/index.ts +1 -1
  236. package/src/pages/api/ai/settings.ts +7 -4
  237. package/src/pages/api/design/ai.ts +15 -0
  238. package/src/pages/api/design/index.ts +12 -42
  239. package/src/pages/api/design/translate.ts +15 -0
  240. package/src/pages/api/live/thumb.ts +1 -1
  241. package/src/pages/api/mcp.ts +2 -2
  242. package/src/pages/api/media/upload.ts +10 -78
  243. package/src/pages/api/push/admin.ts +27 -0
  244. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  245. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  246. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  247. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  248. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  249. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  250. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  251. package/src/pages/api/v1/admin/me.ts +6 -0
  252. package/src/pages/api/v1/admin/media/index.ts +32 -0
  253. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  254. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  255. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  256. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  257. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  258. package/src/pages/api/v1/auth/revoke.ts +5 -0
  259. package/src/pages/api/v1/auth/token.ts +5 -0
  260. package/src/pages/api/v1/devices/[token].ts +20 -0
  261. package/src/pages/api/v1/devices/index.ts +17 -0
  262. package/src/pages/api/v1/openapi.json.ts +7 -0
  263. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  264. package/src/pages/api/v1/pages/home.ts +7 -0
  265. package/src/pages/api/v1/push/topics.ts +7 -0
  266. package/src/pages/api/v1/site.ts +7 -0
  267. package/src/pages/api/v1/theme.ts +7 -0
  268. package/src/pages/blog/[slug].astro +8 -2
  269. package/src/pages/blog/index.astro +11 -4
  270. package/src/pages/book/[resource].ics.ts +7 -1
  271. package/src/pages/embed/section/[...path].astro +42 -0
  272. package/src/pages/events/[slug].astro +7 -2
  273. package/src/pages/index.astro +11 -5
  274. package/src/pages/sitemap.xml.ts +8 -0
  275. package/src/styles/admin.css +117 -0
  276. package/src/styles/cards.css +5 -1
  277. package/src/styles/editor.css +57 -0
  278. package/src/styles/mobile-app.css +151 -0
  279. package/src/styles/schedule.css +325 -0
  280. package/src/styles/sections.css +21 -12
  281. package/src/styles/theme-editor.css +255 -0
  282. package/src/theme-sdk/contract.d.mts +2193 -89
  283. package/src/theme-sdk/contract.mjs +77 -20
  284. package/src/theme-sdk/fields.d.mts +88 -0
  285. package/src/theme-sdk/fields.mjs +330 -0
  286. package/src/theme-sdk/index.ts +14 -2
  287. package/src/theme-sdk/tokens.d.mts +11 -0
  288. package/src/virtual.d.ts +4 -0
  289. package/src/pages/admin/calendar/index.ts +0 -4
@@ -1,16 +1,24 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
2
+ import { DndContext, PointerSensor, KeyboardSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
3
+ import type { DragEndEvent } from '@dnd-kit/core';
4
+ import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
5
+ import { CSS as DndCSS } from '@dnd-kit/utilities';
2
6
  import { ImagePicker } from './editor/ImagePicker';
7
+ import { FieldInput, EMPTY_TARGETS, type FieldDef, type SectionTargets } from './sections/SectionFields';
8
+ import { SectionPicker, type PickerChoice } from './sections/SectionPicker';
9
+ import { checkLayouts } from './sections/checks';
10
+ import { TranslationFields, sectionSummary, withTranslation, confirmTranslation, unitsToDraft, requestDrafts, planMerge, type Unit } from './sections/TranslationPanel';
11
+ import { defaultValue, fieldVisible, initialData, normalizeData, normalizeValue, orphanKeys, valueIssues } from '../theme-sdk/fields.mjs';
3
12
 
4
13
  // Pages tab of the Design screen: lay out a page from the active theme's
5
14
  // section registry (builtins + the theme's own sections). Edits stay local
6
- // until "Save section draft" stages them into the design draft through
15
+ // autosave into the design draft (1.2s after the last edit) through
7
16
  // POST /api/design; nothing here publishes, and a page's own content is
8
17
  // never touched (switching a page back to "Page content" keeps its layout).
9
18
 
10
- type FieldType = 'text' | 'textarea' | 'image' | 'url' | 'select' | 'number';
11
- interface FieldDef { label: string; type: FieldType; default?: string | number; options?: string[] }
12
- interface SectionDef { name?: string; label?: string; version?: number; variants?: string[]; fields?: Record<string, FieldDef> }
13
- type SectionValue = string | number;
19
+ interface SectionDef { name?: string; label?: string; description?: string; version?: number; variants?: string[]; fields?: Record<string, FieldDef>; category?: string; limit?: number; example?: Record<string, unknown>; presets?: { name: string; description?: string; variant?: string; data: Record<string, unknown> }[] }
20
+ type SectionValue = unknown;
21
+ interface Preset { name: string; description?: string; sections: { type: string; variant?: string; hidden?: boolean; data: Record<string, SectionValue> }[] }
14
22
  export interface SectionInstance {
15
23
  id: string;
16
24
  type: string;
@@ -18,6 +26,10 @@ export interface SectionInstance {
18
26
  variant: string;
19
27
  hidden: boolean;
20
28
  data: Record<string, SectionValue>;
29
+ showFrom?: string;
30
+ showUntil?: string;
31
+ locked?: boolean;
32
+ translations?: Record<string, { data: Record<string, string | number | boolean>; meta: Record<string, { state: 'ai' | 'reviewed'; src: string }> }>;
21
33
  }
22
34
  export interface PageLayout { mode: 'sections' | 'content'; sections: SectionInstance[] }
23
35
  export type SectionPages = Record<string, PageLayout>;
@@ -27,8 +39,9 @@ export interface DesignApiState {
27
39
  live: DesignSide;
28
40
  draft: DesignSide | null;
29
41
  changes: { key: string; label: string; from: string; to: string; kind: string }[];
30
- themes: { slug: string; name: string; version: string | number; sections: Record<string, SectionDef> }[];
31
- pages: { id: string; title: string; slug: string; isHome: boolean }[];
42
+ themes: { slug: string; name: string; version: string | number; sections: Record<string, SectionDef>; coded?: string[]; migratable?: string[]; presets?: Record<string, Preset> }[];
43
+ targets?: SectionTargets;
44
+ pages: { id: string; title: string; slug: string; isHome: boolean; locale?: string }[];
32
45
  versions: { id: string; createdAt: number; author: string; summary: string }[];
33
46
  scopes: string[];
34
47
  }
@@ -46,6 +59,8 @@ export function useDesignApi() {
46
59
  const [data, setData] = useState<DesignApiState | null>(null);
47
60
  const [error, setError] = useState('');
48
61
  const [loading, setLoading] = useState(true);
62
+ // bumps only on a real (re)load, so local patches don't reset editors
63
+ const [loadCount, setLoadCount] = useState(0);
49
64
  const load = useCallback(async () => {
50
65
  setLoading(true);
51
66
  setError('');
@@ -53,6 +68,7 @@ export function useDesignApi() {
53
68
  const res = await fetch('/api/design', { headers: { accept: 'application/json' } });
54
69
  if (!res.ok) { setError(await errorText(res, `Couldn't load the design (${res.status}).`)); return; }
55
70
  setData((await res.json()) as DesignApiState);
71
+ setLoadCount((n) => n + 1);
56
72
  } catch {
57
73
  setError("Couldn't reach the server.");
58
74
  } finally {
@@ -60,7 +76,9 @@ export function useDesignApi() {
60
76
  }
61
77
  }, []);
62
78
  useEffect(() => { void load(); }, [load]);
63
- return { data, error, loading, reload: load };
79
+ /** update local state after our own write (e.g. an autosave) without a refetch */
80
+ const patch = useCallback((fn: (d: DesignApiState) => DesignApiState) => setData((d) => (d ? fn(d) : d)), []);
81
+ return { data, error, loading, loadCount, reload: load, patch };
64
82
  }
65
83
 
66
84
  const stagedPages = (d: DesignApiState): SectionPages =>
@@ -75,43 +93,150 @@ const newId = (type: string) => {
75
93
  return `${type}-${rand}`;
76
94
  };
77
95
 
78
- const defaultsFor = (def: SectionDef): Record<string, SectionValue> => {
79
- const out: Record<string, SectionValue> = {};
80
- for (const [key, f] of Object.entries(def.fields ?? {})) {
81
- if (f.default !== undefined) out[key] = f.default;
82
- }
83
- return out;
96
+ const defaultsFor = (def: SectionDef): Record<string, SectionValue> => initialData(def) as Record<string, SectionValue>;
97
+ const CLIP_KEY = 'acms-section-clipboard';
98
+ // schedules are stored as site wall-clock "YYYY-MM-DDTHH:MM"; datetime-local speaks the same format
99
+ const toLocalInput = (v?: string) => (v ? (v.length === 10 ? `${v}T00:00` : v) : '');
100
+ const fromLocalInput = (v: string) => (v ? v.slice(0, 16) : undefined);
101
+ const scheduleText = (s: SectionInstance) => {
102
+ const now = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
103
+ if (s.showFrom && now < toLocalInput(s.showFrom)) return 'Hidden now · appears on ' + toLocalInput(s.showFrom).replace('T', ' at ');
104
+ if (s.showUntil && now >= toLocalInput(s.showUntil)) return 'Ended · no longer shown';
105
+ if (s.showUntil) return 'Showing now · until ' + toLocalInput(s.showUntil).replace('T', ' at ');
106
+ return s.showFrom ? 'Showing now' : 'Pick dates in your site’s timezone';
107
+ };
108
+ /** the section's other filled-in text fields, as context for AI help */
109
+ const sectionContext = (def: SectionDef, s: SectionInstance, except: string) =>
110
+ Object.entries(def.fields ?? {}).filter(([k, f]) => k !== except && ['text', 'textarea', 'richtext'].includes(f.type) && typeof s.data[k] === 'string' && String(s.data[k]).trim())
111
+ .map(([k, f]) => `${f.label}: ${String(s.data[k]).replace(/<[^>]+>/g, ' ').slice(0, 300)}`).join('\n').slice(0, 3000);
112
+ /** validation messages per field key (lists report at the list) */
113
+ const fieldIssues = (def: SectionDef, s: SectionInstance): Record<string, string[]> => {
114
+ const data = normalizeData(def, s.data) as Record<string, unknown>;
115
+ return Object.fromEntries(Object.entries(def.fields ?? {}).map(([k, f]) => [k, fieldVisible(f, data) ? valueIssues(f, data[k]) : []]));
84
116
  };
117
+ const hasSource = (def: SectionDef | undefined) => Object.values(def?.fields ?? {}).some((f) => f.type === 'source');
85
118
 
86
119
  const pagePath = (p: { slug: string; isHome: boolean }) => (p.isHome ? '/' : `/${p.slug}`);
87
120
 
121
+ export interface SectionFocus { pageId: string; sectionId: string; n: number }
122
+
88
123
  interface EditorProps {
89
124
  api: ReturnType<typeof useDesignApi>;
90
125
  /** theme picked in the Theme tab (may be ahead of the staged draft) */
91
126
  activeTheme: string;
92
127
  unsplashEnabled: boolean;
128
+ /** open a section (click-to-edit in the preview, or ?page=&section= links) */
129
+ focus?: SectionFocus | null;
130
+ /** the open section changed: the preview highlights it */
131
+ onOpenSection?: (pageId: string, sectionId: string | null, path: string) => void;
132
+ /** a save or publish landed: refresh the preview */
133
+ onSaved?: () => void;
134
+ /** freshly rendered sections for the unsaved layout: swap them into the preview */
135
+ onLiveHtml?: (html: string) => void;
136
+ /** the Design screen's Publish flushes pending section edits through this */
137
+ flushRef?: { current: () => Promise<boolean> };
93
138
  }
94
139
 
95
- export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled }: EditorProps) {
96
- const { data, error: loadError, loading, reload } = api;
140
+ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled, focus, onOpenSection, onSaved, onLiveHtml, flushRef }: EditorProps) {
141
+ const { data, error: loadError, loading, reload, loadCount, patch } = api;
97
142
  const [pages, setPages] = useState<SectionPages>({});
98
143
  const [pageId, setPageId] = useState('');
99
144
  const [openId, setOpenId] = useState<string | null>(null);
100
- const [addType, setAddType] = useState('');
145
+ const [pickerOpen, setPickerOpen] = useState(false);
146
+ // translation mode: '' = the site's main language, else a locale code
147
+ const [lang, setLang] = useState('');
148
+ const [busyPaths, setBusyPaths] = useState<Set<string>>(new Set());
149
+ const [trError, setTrError] = useState('');
150
+ const loadedPages = useRef<SectionPages>({});
151
+ const isOwnerRef = useRef(true);
152
+ const [schedFor, setSchedFor] = useState<string | null>(null);
153
+ const [clip, setClip] = useState<SectionInstance | null>(() => {
154
+ try { const v = JSON.parse(localStorage.getItem(CLIP_KEY) ?? 'null'); return v?.acmsSection ? v.section : null; } catch { return null; }
155
+ });
101
156
  const [saving, setSaving] = useState(false);
102
157
  const [saveError, setSaveError] = useState('');
158
+ const [flash, setFlash] = useState('');
103
159
  const [conflict, setConflict] = useState(false);
104
- const [imageFor, setImageFor] = useState<{ sectionId: string; key: string } | null>(null);
160
+ const [imageApply, setImageApply] = useState<((src: string) => void) | null>(null);
161
+ const listRef = useRef<HTMLOListElement>(null);
162
+ // undo/redo: whole-layout snapshots; a burst of typing is one step
163
+ const past = useRef<SectionPages[]>([]);
164
+ const future = useRef<SectionPages[]>([]);
165
+ const lastEdit = useRef(0);
166
+ const [, setHistoryTick] = useState(0);
167
+ /** every user edit goes through here so it can be undone */
168
+ const edit = (fn: (all: SectionPages) => SectionPages) => {
169
+ setPages((prev) => {
170
+ const next = fn(prev);
171
+ if (next === prev) return prev;
172
+ const now = Date.now();
173
+ if (now - lastEdit.current > 800) {
174
+ past.current = [...past.current.slice(-49), prev];
175
+ future.current = [];
176
+ setHistoryTick((n) => n + 1);
177
+ }
178
+ lastEdit.current = now;
179
+ return next;
180
+ });
181
+ };
182
+ const undo = () => setPages((cur) => {
183
+ const prev = past.current.pop();
184
+ if (!prev) return cur;
185
+ future.current.push(cur);
186
+ lastEdit.current = 0;
187
+ setHistoryTick((n) => n + 1);
188
+ return prev;
189
+ });
190
+ const redo = () => setPages((cur) => {
191
+ const next = future.current.pop();
192
+ if (!next) return cur;
193
+ past.current.push(cur);
194
+ lastEdit.current = 0;
195
+ setHistoryTick((n) => n + 1);
196
+ return next;
197
+ });
105
198
 
106
- // rehydrate local edits whenever a fresh server state arrives
107
- const base = useMemo(() => (data ? stagedPages(data) : {}), [data]);
199
+ // `base` is what the server holds (the staged draft); edits autosave into
200
+ // it. A real reload rehydrates everything; our own autosaves only move
201
+ // `base` and the revision forward, so typing is never overwritten.
202
+ const [base, setBase] = useState<SectionPages>({});
203
+ const revRef = useRef(0);
108
204
  useEffect(() => {
109
205
  if (!data) return;
110
- setPages(structuredClone(base));
206
+ const fresh = stagedPages(data);
207
+ loadedPages.current = structuredClone(fresh);
208
+ revRef.current = data.revision;
209
+ setBase(fresh);
210
+ setPages(structuredClone(fresh));
211
+ past.current = [];
212
+ future.current = [];
111
213
  setConflict(false);
112
214
  setSaveError('');
113
- setPageId((cur) => (cur && data.pages.some((p) => p.id === cur) ? cur : (data.pages.find((p) => p.isHome) ?? data.pages[0])?.id ?? ''));
114
- }, [data, base]);
215
+ setPageId((cur) => {
216
+ if (cur && data.pages.some((p) => p.id === cur)) return cur;
217
+ const wanted = new URLSearchParams(location.search).get('page');
218
+ return (data.pages.find((p) => p.id === wanted) ?? data.pages.find((p) => p.isHome) ?? data.pages[0])?.id ?? '';
219
+ });
220
+ const wantedSection = new URLSearchParams(location.search).get('section');
221
+ if (wantedSection) setOpenId((cur) => cur ?? wantedSection);
222
+ // eslint-disable-next-line react-hooks/exhaustive-deps
223
+ }, [loadCount]);
224
+
225
+ // click-to-edit from the preview
226
+ useEffect(() => {
227
+ if (!focus || !data) return;
228
+ if (data.pages.some((p) => p.id === focus.pageId)) setPageId(focus.pageId);
229
+ setOpenId(focus.sectionId);
230
+ }, [focus, data]);
231
+ useEffect(() => {
232
+ const p = data?.pages.find((x) => x.id === pageId);
233
+ const base0 = p ? pagePath(p) : '/';
234
+ const path = lang ? (base0 === '/' ? `/${lang}` : `/${lang}${base0}`) : base0;
235
+ if (!openId) { onOpenSection?.(pageId, null, path); return; }
236
+ onOpenSection?.(pageId, openId, path);
237
+ requestAnimationFrame(() => listRef.current?.querySelector(`[data-sec="${CSS.escape(openId)}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
238
+ // eslint-disable-next-line react-hooks/exhaustive-deps
239
+ }, [openId, pageId, lang]);
115
240
 
116
241
  const dirty = useMemo(() => JSON.stringify(pages) !== JSON.stringify(base), [pages, base]);
117
242
  const dirtyRef = useRef(false);
@@ -122,15 +247,188 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled
122
247
  return () => removeEventListener('beforeunload', warn);
123
248
  }, []);
124
249
 
250
+ const baseRef = useRef(base);
251
+ baseRef.current = base;
252
+ const post = async (body: Record<string, unknown>, retried = false): Promise<{ revision: number } | null> => {
253
+ const res = await fetch('/api/design', {
254
+ method: 'POST',
255
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
256
+ body: JSON.stringify(body),
257
+ });
258
+ if (res.status === 409 && !retried && typeof body.revision === 'number') {
259
+ // the revision moved: if only other design settings changed (theme,
260
+ // colors autosave), our layouts are still current, so retry once
261
+ const fresh = await fetch('/api/design', { headers: { accept: 'application/json' } }).then((r) => (r.ok ? (r.json() as Promise<DesignApiState>) : null)).catch(() => null);
262
+ if (fresh && JSON.stringify(stagedPages(fresh)) === JSON.stringify(baseRef.current)) return post({ ...body, revision: fresh.revision }, true);
263
+ if (body.operation === 'stage') { setConflict(true); return null; }
264
+ }
265
+ if (!res.ok) { setSaveError(await errorText(res, `Couldn't save (${res.status}).`)); return null; }
266
+ return (await res.json()) as { revision: number };
267
+ };
268
+ // autosave: stage into the design draft 1.2s after the last edit
269
+ const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
270
+ const saving$ = useRef(false);
271
+ const staleRef = useRef(false);
272
+ const autosave = async () => {
273
+ if (saving$.current) return;
274
+ saving$.current = true;
275
+ setSaveState('saving');
276
+ const snapshot = pagesRef.current;
277
+ try {
278
+ const out = await post({ operation: 'stage', revision: revRef.current, patch: { sectionPages: snapshot } });
279
+ if (!out) { setSaveState('error'); return; }
280
+ revRef.current = out.revision;
281
+ setBase(snapshot);
282
+ setSaveState('saved');
283
+ patch((d) => ({ ...d, revision: out.revision, draft: { ...(d.draft ?? {}), sectionPages: snapshot } }));
284
+ // the preview couldn't swap (new layout / mode change): reload it now
285
+ if (staleRef.current) { staleRef.current = false; onSaved?.(); }
286
+ } catch {
287
+ setSaveState('error');
288
+ setSaveError("Couldn't reach the server. Your edits are kept here and will save when you're back online.");
289
+ } finally {
290
+ saving$.current = false;
291
+ }
292
+ };
293
+ const autosaveRef = useRef(autosave);
294
+ autosaveRef.current = autosave;
295
+ const pagesRef = useRef(pages);
296
+ pagesRef.current = pages;
297
+ useEffect(() => {
298
+ if (!data || !dirty || conflict) return;
299
+ const t = setTimeout(() => void autosaveRef.current(), 1200);
300
+ return () => clearTimeout(t);
301
+ }, [pages, dirty, conflict, data, saveState]);
302
+
303
+ // live preview: render the unsaved layout ~0.35s after you pause typing
304
+ const liveSent = useRef('');
305
+ const [liveNonce, setLiveNonce] = useState(0);
306
+ useEffect(() => {
307
+ const onMsg = (e: MessageEvent) => {
308
+ if (e.origin !== location.origin) return;
309
+ if (e.data?.type === 'acms-sections-ready') { liveSent.current = ''; setLiveNonce((n) => n + 1); }
310
+ if (e.data?.type === 'acms-preview-stale') staleRef.current = true;
311
+ };
312
+ addEventListener('message', onMsg);
313
+ return () => removeEventListener('message', onMsg);
314
+ }, []);
315
+ useEffect(() => {
316
+ if (!onLiveHtml || !pageId) return;
317
+ const layout = pages[pageId];
318
+ if (!layout || layout.mode !== 'sections') return;
319
+ const key = JSON.stringify([pageId, activeTheme, layout, lang]);
320
+ if (key === liveSent.current) return;
321
+ const ctrl = new AbortController();
322
+ const t = setTimeout(async () => {
323
+ try {
324
+ const res = await fetch('/admin/section-preview', { method: 'POST', signal: ctrl.signal, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pageId, layout, theme: activeTheme, locale: lang || undefined }) });
325
+ if (!res.ok) return;
326
+ const html = await res.text();
327
+ liveSent.current = key;
328
+ onLiveHtml(html);
329
+ } catch { /* aborted or offline: the next edit tries again */ }
330
+ }, 350);
331
+ return () => { clearTimeout(t); ctrl.abort(); };
332
+ }, [pages, pageId, activeTheme, onLiveHtml, liveNonce, lang]);
333
+
334
+ // machine drafts for translations: fills values, marks them "AI draft"
335
+ const draftUnits = async (targetLang: string, items: { sectionId: string; unit: Unit }[], quiet = false) => {
336
+ if (!items.length) return 0;
337
+ const keyOf = (x: { sectionId: string; unit: Unit }) => `${x.sectionId}::${x.unit.path}`;
338
+ setBusyPaths((b) => new Set([...b, ...items.map(keyOf)]));
339
+ setTrError('');
340
+ try {
341
+ const results = await requestDrafts(targetLang, items.map((x) => ({ id: keyOf(x), unit: x.unit })));
342
+ let applied = 0;
343
+ edit((all) => {
344
+ const next = { ...all };
345
+ for (const [pid, layout] of Object.entries(all)) {
346
+ const sections = layout.sections.map((sec) => {
347
+ let out = sec;
348
+ for (const x of items) if (x.sectionId === sec.id && results[keyOf(x)] !== undefined) {
349
+ const cur = out.translations?.[targetLang];
350
+ // never overwrite something a person reviewed meanwhile
351
+ if (cur?.meta?.[x.unit.path]?.state === 'reviewed' && cur.meta[x.unit.path].src === x.unit.hash) continue;
352
+ out = withTranslation(out, targetLang, x.unit, results[keyOf(x)], 'ai') as SectionInstance;
353
+ applied++;
354
+ }
355
+ return out;
356
+ });
357
+ next[pid] = { ...layout, sections };
358
+ }
359
+ return next;
360
+ });
361
+ return applied;
362
+ } catch (e) {
363
+ if (!quiet) setTrError(e instanceof Error ? e.message : 'Translation failed.');
364
+ return 0;
365
+ } finally {
366
+ setBusyPaths((b) => { const n = new Set(b); for (const x of items) n.delete(keyOf(x)); return n; });
367
+ }
368
+ };
369
+ // auto-draft: when English changes in sections you edited this session,
370
+ // the other languages redraft (missing, or outdated AI drafts) a few seconds later
371
+ const i18nInfo = data?.targets?.i18n;
372
+ const drafting = useRef(false);
373
+ useEffect(() => {
374
+ if (lang || !i18nInfo?.drafts || !i18nInfo.locales.length || !data) return;
375
+ const t = setTimeout(async () => {
376
+ if (drafting.current) return;
377
+ const defsNow = (data.themes.find((th) => th.slug === activeTheme) ?? data.themes[0])?.sections ?? {};
378
+ const work: { l: string; items: { sectionId: string; unit: Unit }[] }[] = [];
379
+ for (const { code } of i18nInfo.locales) {
380
+ const items: { sectionId: string; unit: Unit }[] = [];
381
+ for (const [pid, layout] of Object.entries(pagesRef.current)) {
382
+ for (const sec of layout.sections) {
383
+ const before = loadedPages.current[pid]?.sections.find((x) => x.id === sec.id);
384
+ if (before && JSON.stringify(before.data) === JSON.stringify(sec.data)) continue; // untouched this session
385
+ const def = defsNow[sec.type];
386
+ if (!def || sec.locked && !isOwnerRef.current) continue;
387
+ for (const unit of unitsToDraft(def, sec, code)) items.push({ sectionId: sec.id, unit });
388
+ }
389
+ }
390
+ if (items.length) work.push({ l: code, items: items.slice(0, 120) });
391
+ }
392
+ if (!work.length) return;
393
+ drafting.current = true;
394
+ try { for (const w of work) await draftUnits(w.l, w.items, true); } finally { drafting.current = false; }
395
+ }, 4000);
396
+ return () => clearTimeout(t);
397
+ // eslint-disable-next-line react-hooks/exhaustive-deps
398
+ }, [pages, lang, i18nInfo?.drafts]);
399
+
400
+ // ⌘Z / ⌘⇧Z (Ctrl+Y) while editing sections; rich text keeps its own undo
401
+ const rootRef = useRef<HTMLDivElement>(null);
402
+ useEffect(() => {
403
+ const onKey = (e: KeyboardEvent) => {
404
+ if (!(e.metaKey || e.ctrlKey) || !rootRef.current || rootRef.current.offsetParent === null) return;
405
+ const t = e.target as HTMLElement | null;
406
+ if (t?.isContentEditable) return;
407
+ if (t && t !== document.body && !rootRef.current.contains(t)) return;
408
+ const k = e.key.toLowerCase();
409
+ if (k === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
410
+ else if ((k === 'z' && e.shiftKey) || k === 'y') { e.preventDefault(); redo(); }
411
+ };
412
+ addEventListener('keydown', onKey);
413
+ return () => removeEventListener('keydown', onKey);
414
+ // eslint-disable-next-line react-hooks/exhaustive-deps
415
+ }, []);
416
+
417
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
418
+
125
419
  const theme = data?.themes.find((t) => t.slug === activeTheme)
126
420
  ?? data?.themes.find((t) => t.slug === (data.draft?.activeTheme ?? data.live.activeTheme))
127
421
  ?? data?.themes[0];
128
422
  const defs: Record<string, SectionDef> = theme?.sections ?? {};
129
423
  const defTypes = Object.keys(defs);
130
- useEffect(() => {
131
- if (!defTypes.includes(addType)) setAddType(defTypes[0] ?? '');
132
- // eslint-disable-next-line react-hooks/exhaustive-deps
133
- }, [defTypes.join('|')]);
424
+ const coded = new Set(theme?.coded ?? []);
425
+ const migratable = new Set(theme?.migratable ?? []);
426
+ const presets = Object.entries(theme?.presets ?? {});
427
+ const targets = data?.targets ?? EMPTY_TARGETS;
428
+ const i18n = targets.i18n ?? EMPTY_TARGETS.i18n!;
429
+ // editors change content only; owners arrange layouts and can lock sections
430
+ const isOwner = (data as { role?: string } | null)?.role !== 'editor';
431
+ isOwnerRef.current = isOwner;
134
432
 
135
433
  if (loading && !data) return <p className="te-muted">Loading page layouts…</p>;
136
434
  if (!data) {
@@ -153,36 +451,76 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled
153
451
 
154
452
  const page = data.pages.find((p) => p.id === pageId) ?? data.pages[0];
155
453
  const layout: PageLayout | undefined = pages[page.id];
454
+ const liveLayout = (data.live.sectionPages ?? {})[page.id] as PageLayout | undefined;
156
455
 
157
456
  const setLayout = (fn: (l: PageLayout) => PageLayout) =>
158
- setPages((all) => ({ ...all, [page.id]: fn(all[page.id] ?? { mode: 'sections', sections: [] }) }));
457
+ edit((all) => ({ ...all, [page.id]: fn(all[page.id] ?? { mode: 'sections', sections: [] }) }));
159
458
  const setSections = (fn: (s: SectionInstance[]) => SectionInstance[]) =>
160
459
  setLayout((l) => ({ ...l, sections: fn(l.sections) }));
161
460
  const patchSection = (id: string, fn: (s: SectionInstance) => SectionInstance) =>
162
461
  setSections((list) => list.map((s) => (s.id === id ? fn(s) : s)));
163
462
 
164
- const addSection = () => {
165
- const def = defs[addType];
166
- if (!def) return;
167
- const s: SectionInstance = {
168
- id: newId(addType),
169
- type: addType,
170
- version: def.version ?? 1,
171
- variant: def.variants?.[0] ?? 'default',
172
- hidden: false,
173
- data: defaultsFor(def),
463
+ const instanceFor = (type: string, variant?: string, data?: Record<string, SectionValue>, hidden = false): SectionInstance => {
464
+ const def = defs[type];
465
+ return {
466
+ id: newId(type),
467
+ type,
468
+ version: def?.version ?? 1,
469
+ variant: variant && def?.variants?.includes(variant) ? variant : def?.variants?.[0] ?? 'default',
470
+ hidden,
471
+ data: { ...defaultsFor(def ?? {}), ...(data ?? {}) },
174
472
  };
473
+ };
474
+ const addSection = (choice: PickerChoice) => {
475
+ if (!defs[choice.type]) return;
476
+ const s = instanceFor(choice.type, choice.variant, choice.data ? structuredClone(choice.data) as Record<string, SectionValue> : undefined);
175
477
  setLayout((l) => ({ ...l, mode: 'sections', sections: [...l.sections, s] }));
176
478
  setOpenId(s.id);
177
479
  };
480
+ // copy / paste sections (also across tabs, via localStorage)
481
+ const copySection = (s: SectionInstance) => {
482
+ const json = JSON.stringify({ acmsSection: 1, section: s });
483
+ try { localStorage.setItem(CLIP_KEY, json); } catch { /* private mode */ }
484
+ void navigator.clipboard?.writeText(json).catch(() => {});
485
+ setClip(s);
486
+ setFlash(`Copied “${sectionName(defs[s.type], s.type)}”. Paste it on any page.`);
487
+ setTimeout(() => setFlash(''), 2600);
488
+ };
489
+ const pasteSection = () => {
490
+ if (!clip || !defs[clip.type]) return;
491
+ const s = { ...structuredClone(clip), id: newId(clip.type), locked: undefined };
492
+ setLayout((l) => ({ ...l, mode: 'sections', sections: [...l.sections, s] }));
493
+ setOpenId(s.id);
494
+ };
495
+ const copyToPage = (s: SectionInstance, targetId: string) => {
496
+ const target = data.pages.find((p) => p.id === targetId);
497
+ if (!target) return;
498
+ const copy = { ...structuredClone(s), id: newId(s.type) };
499
+ edit((all) => ({ ...all, [targetId]: { ...(all[targetId] ?? { mode: 'sections', sections: [] }), sections: [...(all[targetId]?.sections ?? []), copy] } }));
500
+ setFlash(`Copied to ${target.title || target.slug}. It saves with your draft.`);
501
+ setTimeout(() => setFlash(''), 2600);
502
+ };
503
+ const applyPreset = (key: string) => {
504
+ const preset = theme?.presets?.[key];
505
+ if (!preset) return;
506
+ if (layout?.sections.length && !window.confirm(`Replace the ${layout.sections.length} sections on ${page.title} with “${preset.name}”? This happens when you save the section draft.`)) return;
507
+ setLayout(() => ({ mode: 'sections', sections: preset.sections.map((p) => instanceFor(p.type, p.variant, structuredClone(p.data), p.hidden)) }));
508
+ setOpenId(null);
509
+ };
178
510
  const move = (i: number, dir: -1 | 1) =>
179
511
  setSections((list) => {
180
512
  const j = i + dir;
181
513
  if (j < 0 || j >= list.length) return list;
182
- const next = [...list];
183
- [next[i], next[j]] = [next[j], next[i]];
184
- return next;
514
+ return arrayMove(list, i, j);
515
+ });
516
+ const onDragEnd = (e: DragEndEvent) => {
517
+ if (!e.over || e.active.id === e.over.id) return;
518
+ setSections((list) => {
519
+ const from = list.findIndex((s) => s.id === e.active.id);
520
+ const to = list.findIndex((s) => s.id === e.over!.id);
521
+ return from < 0 || to < 0 ? list : arrayMove(list, from, to);
185
522
  });
523
+ };
186
524
  const duplicate = (i: number) =>
187
525
  setSections((list) => {
188
526
  const copy = { ...structuredClone(list[i]), id: newId(list[i].type) };
@@ -199,43 +537,115 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled
199
537
  return { ...s, data: d };
200
538
  });
201
539
 
202
- const save = async () => {
540
+ const finish = async (message: string) => {
541
+ setFlash(message);
542
+ setTimeout(() => setFlash(''), 2600);
543
+ await reload();
544
+ onSaved?.();
545
+ };
546
+ /** save now (the Save button, and before publishing/upgrading) */
547
+ const flush = async (): Promise<boolean> => {
548
+ if (!dirty) return true;
549
+ await autosaveRef.current();
550
+ return JSON.stringify(pagesRef.current) === JSON.stringify(baseRef.current);
551
+ };
552
+ if (flushRef) flushRef.current = flush;
553
+ const publishSection = async (s: SectionInstance) => {
554
+ const name = sectionName(defs[s.type], s.type);
555
+ const warns = checkLayouts({ [page.id]: { mode: 'sections', sections: [s] } }, defs as never, targets, { pageId: page.id, sectionId: s.id });
556
+ const note = warns.length ? `\n\nWorth a look first:\n${warns.slice(0, 5).map((w) => `• ${w.message}`).join('\n')}` : '';
557
+ if (!window.confirm(`Publish your changes to “${name}” now? Visitors see them right away. Everything else stays in the draft.${note}`)) return;
203
558
  setSaving(true);
204
559
  setSaveError('');
205
560
  try {
206
- const res = await fetch('/api/design', {
207
- method: 'POST',
208
- headers: { 'content-type': 'application/json', accept: 'application/json' },
209
- body: JSON.stringify({ operation: 'stage', revision: data.revision, patch: { sectionPages: { ...base, ...pages } } }),
210
- });
211
- if (res.status === 409) { setConflict(true); return; }
212
- if (!res.ok) { setSaveError(await errorText(res, `Couldn't save the section draft (${res.status}).`)); return; }
213
- dirtyRef.current = false;
214
- location.reload(); // refresh the draft status, change list and preview
561
+ if (!(await flush())) return;
562
+ if (await post({ operation: 'publishSection', revision: revRef.current, pageId: page.id, sectionId: s.id })) await finish(`“${name}” is live.`);
563
+ else await reload();
215
564
  } catch {
216
565
  setSaveError("Couldn't reach the server. Your edits are still here.");
217
566
  } finally {
218
567
  setSaving(false);
219
568
  }
220
569
  };
570
+ const upgrade = async () => {
571
+ setSaving(true);
572
+ setSaveError('');
573
+ try {
574
+ if (!(await flush())) return;
575
+ if (await post({ operation: 'migrate', revision: revRef.current })) await finish('Sections upgraded to the theme’s current version. Review and publish.');
576
+ } finally {
577
+ setSaving(false);
578
+ }
579
+ };
221
580
  const reloadLatest = () => {
222
581
  if (dirty && !window.confirm('Load the latest draft? Your unsaved section edits will be lost.')) return;
223
582
  void reload();
224
583
  };
225
- const discardEdits = () => {
226
- if (!window.confirm('Discard your unsaved section edits?')) return;
227
- setPages(structuredClone(base));
584
+
585
+ // ---- translation helpers (page level) ----
586
+ const mainLangName = i18n.defaultLocale ? ({ en: 'English', es: 'Español', fr: 'Français', pt: 'Português' } as Record<string, string>)[i18n.defaultLocale.split('-')[0]] ?? i18n.defaultLocale.toUpperCase() : 'English';
587
+ const pageSummary = (code: string) => {
588
+ const total = { missing: 0, ai: 0, reviewed: 0, outdated: 0 };
589
+ for (const x of layout?.mode === 'sections' ? layout.sections : []) {
590
+ const d = defs[x.type];
591
+ if (!d || x.hidden) continue;
592
+ const sm = sectionSummary(d, x, code);
593
+ total.missing += sm.missing; total.ai += sm.ai; total.reviewed += sm.reviewed; total.outdated += sm.outdated;
594
+ }
595
+ return total;
596
+ };
597
+ const draftAllMissing = async () => {
598
+ if (!lang || !layout) return;
599
+ const items = layout.sections.flatMap((x) => (defs[x.type] && !x.hidden && !(x.locked && !isOwner) ? unitsToDraft(defs[x.type], x, lang).map((unit) => ({ sectionId: x.id, unit })) : []));
600
+ await draftUnits(lang, items.slice(0, 200));
601
+ };
602
+ const jumpToStatus = (status: 'ai' | 'missing' | 'outdated') => {
603
+ const hit = layout?.sections.find((x) => defs[x.type] && sectionSummary(defs[x.type], x, lang)[status] > 0);
604
+ if (hit) setOpenId(hit.id);
605
+ };
606
+ // the older setup: a separately authored home page for this language
607
+ const mergeSource = lang && page.isHome
608
+ ? (() => {
609
+ const other = data.pages.find((p) => p.isHome && p.locale === lang && p.id !== page.id);
610
+ const otherLayout = other ? pages[other.id] : undefined;
611
+ const alreadyMerged = layout?.sections.some((x) => x.translations?.[lang]);
612
+ return other && otherLayout?.mode === 'sections' && otherLayout.sections.length && !alreadyMerged ? { ...other, layout: otherLayout } : null;
613
+ })()
614
+ : null;
615
+ const runMerge = () => {
616
+ if (!mergeSource || !layout) return;
617
+ const plan = planMerge(defs as never, layout.sections as never, mergeSource.layout.sections as never, lang);
618
+ if (!window.confirm(`Copy ${plan.copied} ${plan.copied === 1 ? 'text' : 'texts'} from “${mergeSource.title}” into this page's ${i18n.locales.find((l) => l.code === lang)?.name ?? lang} version?${plan.unmatched ? ` ${plan.unmatched} ${plan.unmatched === 1 ? 'section has' : 'sections have'} no match there; translate ${plan.unmatched === 1 ? 'it' : 'them'} with AI afterwards.` : ''}\n\nThe separate page isn't deleted; once this page has translations, /${lang} shows this one.`)) return;
619
+ setLayout((l) => ({ ...l, sections: plan.sections as SectionInstance[] }));
228
620
  };
229
621
 
622
+ const allIssues = (layout?.mode === 'sections' ? layout.sections : []).map((x) => {
623
+ const d = defs[x.type];
624
+ return { id: x.id, n: d && !x.hidden ? Object.values(fieldIssues(d, x)).reduce((n, l) => n + l.length, 0) : 0 };
625
+ });
626
+ const totalIssues = allIssues.reduce((n, x) => n + x.n, 0);
627
+ const firstIssueId = allIssues.find((x) => x.n > 0)?.id ?? null;
230
628
  const pageDirty = (id: string) => JSON.stringify(pages[id]) !== JSON.stringify(base[id]);
231
- const editingSection = imageFor ? layout?.sections.find((s) => s.id === imageFor.sectionId) : undefined;
629
+ const liveSection = (id: string) => liveLayout?.mode === 'sections' ? liveLayout.sections.find((x) => x.id === id) : undefined;
630
+ const canPublishAlone = data.scopes.includes('design:publish');
232
631
 
233
632
  return (
234
- <div className="te">
633
+ <div className="te" ref={rootRef}>
235
634
  <p className="te-intro">
236
- Build a page from your theme's sections. Edits stay on this screen until you save them to the private design
237
- draft; visitors see them only after <b>Publish</b>. The page's own content is never changed.
635
+ Build a page from your theme's sections. Sections marked <b>coded</b> come from your theme's code: their look
636
+ changes in code, their content changes here. Edits save to a private draft as you type; visitors see them after you <b>Publish</b>.
238
637
  </p>
638
+ <details className="te-help-panel">
639
+ <summary>How editing works</summary>
640
+ <ul>
641
+ <li><b>Click anything in the preview</b> to open its section here. The preview updates as you type.</li>
642
+ <li><b>Your changes save themselves</b> to a draft. Undo with ⌘Z (Ctrl+Z), redo with ⌘⇧Z.</li>
643
+ <li><b>Publish</b> (top of this screen) makes every change live. <b>Publish this section</b> sends just one section and leaves the rest in the draft.</li>
644
+ <li><b>Linked</b> sections (like upcoming events) fill themselves from your calendar or posts, so there's nothing to update here.</li>
645
+ <li>Fields marked in red need fixing before that section can be published.</li>
646
+ <li>Something look wrong? <b>History</b> (top right) restores any earlier published design as a draft.</li>
647
+ </ul>
648
+ </details>
239
649
 
240
650
  <div className="te-pagebar">
241
651
  <label className="field te-pagepick">
@@ -253,15 +663,75 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled
253
663
  </a>
254
664
  </div>
255
665
 
256
- {!layout ? (
666
+ {layout?.mode === 'sections' && i18n.locales.length > 0 && (
667
+ <div className="te-langbar">
668
+ <div className="seg" role="radiogroup" aria-label="Language">
669
+ <button type="button" role="radio" aria-checked={!lang} className={!lang ? 'on' : ''} onClick={() => setLang('')}>{mainLangName}</button>
670
+ {i18n.locales.map((l) => {
671
+ const need = pageSummary(l.code);
672
+ return (
673
+ <button key={l.code} type="button" role="radio" aria-checked={lang === l.code} className={lang === l.code ? 'on' : ''} onClick={() => setLang(l.code)}>
674
+ {l.name}{need.missing + need.outdated + need.ai > 0 && <span className="te-lang-count" aria-label={`${need.missing + need.outdated + need.ai} to check`}>{need.missing + need.outdated + need.ai}</span>}
675
+ </button>
676
+ );
677
+ })}
678
+ </div>
679
+ {lang ? (() => {
680
+ const sum = pageSummary(lang);
681
+ const name = i18n.locales.find((l) => l.code === lang)?.name ?? lang;
682
+ return (
683
+ <div className="te-lang-sum">
684
+ <span>
685
+ <b>{sum.reviewed}</b> reviewed · <span className="st-ai"><b>{sum.ai}</b> AI {sum.ai === 1 ? 'draft' : 'drafts'}</span> · <span className="st-old"><b>{sum.outdated}</b> English changed</span> · <span className="st-miss"><b>{sum.missing}</b> not translated</span>
686
+ </span>
687
+ <span className="te-lang-actions">
688
+ {i18n.drafts && (sum.missing + sum.outdated > 0) && (
689
+ <button className="btn sm" type="button" disabled={busyPaths.size > 0} onClick={() => void draftAllMissing()}>{busyPaths.size ? 'Translating…' : `Translate ${sum.missing + sum.outdated} with AI`}</button>
690
+ )}
691
+ {sum.ai > 0 && <button className="btn sm ghost" type="button" onClick={() => jumpToStatus('ai')}>Review AI drafts</button>}
692
+ </span>
693
+ <span className="te-muted te-lang-note">Times, photos, links and linked events are shared with {mainLangName}; only the words change. {name} visitors see AI drafts right away, and {mainLangName} wherever nothing is translated yet.</span>
694
+ {!i18n.drafts && <span className="te-muted te-lang-note">Turn on AI in Settings → AI &amp; agents and {name} drafts itself whenever the {mainLangName} changes.</span>}
695
+ {mergeSource && (
696
+ <div className="notice te-note te-merge">
697
+ <span>You also have a separate {name} home page, “{mergeSource.title}”. Bring its text in here? Matching sections keep your {name} words (marked reviewed); the rest can be drafted with AI. Nothing is deleted.</span>
698
+ <button className="btn sm" type="button" onClick={runMerge}>Bring in its text</button>
699
+ </div>
700
+ )}
701
+ </div>
702
+ );
703
+ })() : null}
704
+ {trError && <p className="flash error te-alert" role="alert">{trError}</p>}
705
+ </div>
706
+ )}
707
+
708
+ {!layout && !isOwner ? (
709
+ <div className="te-empty"><p><b>{page.title || page.slug}</b> shows its page content. Edit it in the page editor.</p></div>
710
+ ) : !layout ? (
257
711
  <div className="te-empty">
258
712
  <p><b>{page.title || page.slug}</b> shows its page content.</p>
259
- <p className="te-muted">Start a layout, then add Existing page content to keep your current text or widgets alongside new sections. You can switch back at any time.</p>
260
- <button className="btn" type="button" onClick={() => setLayout(() => ({ mode: 'sections', sections: [{ id: newId('page-content'), type: 'page-content', version: 1, variant: 'default', hidden: false, data: {} }] }))}>Keep content and add sections</button>
713
+ {presets.length > 0 ? (
714
+ <>
715
+ <p className="te-muted">{theme?.name} comes with ready-made layouts. Start from one, then change any text, list or image.</p>
716
+ <div className="te-presets">
717
+ {presets.map(([key, p]) => (
718
+ <button key={key} type="button" className="te-preset" onClick={() => applyPreset(key)}>
719
+ <b>{p.name}</b>
720
+ <span>{p.description || `${p.sections.length} sections`}</span>
721
+ </button>
722
+ ))}
723
+ </div>
724
+ <p className="te-muted">Or keep your current text and widgets and add sections around them:</p>
725
+ </>
726
+ ) : (
727
+ <p className="te-muted">Start a layout, then add Existing page content to keep your current text or widgets alongside new sections. You can switch back at any time.</p>
728
+ )}
729
+ <button className="btn" type="button" onClick={() => setLayout(() => ({ mode: 'sections', sections: [instanceFor('page-content')] }))}>Keep content and add sections</button>
261
730
  </div>
262
731
  ) : (
263
732
  <>
264
- <div className="te-mode">
733
+ {!isOwner && <p className="notice te-note">You can change the text, lists and pictures in these sections. Adding, moving or restyling sections is up to an owner.</p>}
734
+ <div className="te-mode" hidden={!isOwner}>
265
735
  <span className="te-mode-label" id="te-mode-label">Page shows</span>
266
736
  <div className="seg" role="radiogroup" aria-labelledby="te-mode-label">
267
737
  {(['sections', 'content'] as const).map((m) => (
@@ -284,98 +754,217 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled
284
754
  </p>
285
755
  )}
286
756
 
287
- <ol className={`te-list ${layout.mode === 'content' ? 'is-inactive' : ''}`} aria-label={`Sections on ${page.title || page.slug}`}>
288
- {layout.sections.length === 0 && <li className="te-muted te-none">No sections yet. Add one below.</li>}
289
- {layout.sections.map((s, i) => {
290
- const def = defs[s.type];
291
- const name = sectionName(def, s.type);
292
- const open = openId === s.id;
293
- const panelId = `te-sec-${s.id}`;
294
- return (
295
- <li key={s.id} className={`te-sec ${s.hidden ? 'is-hidden' : ''} ${open ? 'is-open' : ''}`}>
296
- <div className="te-sec-head">
297
- <button
298
- type="button"
299
- className="te-sec-toggle"
300
- aria-expanded={open}
301
- aria-controls={panelId}
302
- onClick={() => setOpenId(open ? null : s.id)}
303
- >
304
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
305
- <span className="te-sec-name">{name}</span>
306
- <span className="te-sec-meta">
307
- {s.variant}
308
- {s.hidden && ' · hidden'}
309
- {!def && ' · unavailable'}
310
- </span>
311
- </button>
312
- <div className="te-sec-actions">
313
- <button className="icon-btn" type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label={`Move ${name} up`} title="Move up">
314
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 15 6-6 6 6" /></svg>
315
- </button>
316
- <button className="icon-btn" type="button" onClick={() => move(i, 1)} disabled={i === layout.sections.length - 1} aria-label={`Move ${name} down`} title="Move down">
317
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
318
- </button>
319
- </div>
320
- </div>
321
-
322
- {open && (
323
- <div className="te-sec-body" id={panelId}>
324
- {!def ? (
325
- <p className="notice warn te-note">
326
- {theme?.name ?? 'This theme'} has no “{s.type}” section, so it won't render. Its {Object.keys(s.data).length} saved {Object.keys(s.data).length === 1 ? 'value is' : 'values are'} preserved and come back if you switch to a theme that has it.
327
- </p>
328
- ) : (
757
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
758
+ <SortableContext items={layout.sections.map((s) => s.id)} strategy={verticalListSortingStrategy}>
759
+ <ol ref={listRef} className={`te-list ${layout.mode === 'content' ? 'is-inactive' : ''}`} aria-label={`Sections on ${page.title || page.slug}`}>
760
+ {layout.sections.length === 0 && <li className="te-muted te-none">No sections yet. Add one below.</li>}
761
+ {layout.sections.map((s, i) => {
762
+ const def = defs[s.type];
763
+ const name = sectionName(def, s.type);
764
+ const open = openId === s.id;
765
+ const panelId = `te-sec-${s.id}`;
766
+ const orphans = def ? orphanKeys(def, s.data) : [];
767
+ const live = liveSection(s.id);
768
+ const savedCopy = (base[page.id]?.sections ?? []).find((x) => x.id === s.id);
769
+ const changedFromLive = Boolean(live) && JSON.stringify(live) !== JSON.stringify(s);
770
+ const unsaved = JSON.stringify(savedCopy) !== JSON.stringify(s);
771
+ const issues = def && !s.hidden ? fieldIssues(def, s) : {};
772
+ const issueCount = Object.values(issues).reduce((n, list) => n + list.length, 0);
773
+ return (
774
+ <SortableSection key={s.id} id={s.id} className={`te-sec ${s.hidden ? 'is-hidden' : ''} ${open ? 'is-open' : ''}`}>
775
+ {(handle) => (
329
776
  <>
330
- {def.version !== undefined && s.version !== def.version && (
331
- <p className="notice warn te-note">Saved with version {s.version} of this section; the theme ships version {def.version}.</p>
332
- )}
333
- {s.type === 'page-content' && <p className="te-muted">This section uses the page's published text and widgets. Edit them in the page editor; any pending content draft stays separate.</p>}
334
- {(def.variants?.length ?? 0) > 0 && (
335
- <label className="field">
336
- <span>Variant</span>
337
- <select value={s.variant} onChange={(e) => patchSection(s.id, (x) => ({ ...x, variant: e.target.value }))}>
338
- {!def.variants!.includes(s.variant) && <option value={s.variant}>{s.variant} (not in this theme)</option>}
339
- {def.variants!.map((v) => <option key={v} value={v}>{v}</option>)}
340
- </select>
341
- </label>
777
+ <div className="te-sec-head">
778
+ {isOwner && !lang ? <button className="te-grip" type="button" aria-label={`Reorder ${name}`} {...handle}>⋮⋮</button> : <span className="te-grip-space" />}
779
+ <button
780
+ type="button"
781
+ className="te-sec-toggle"
782
+ aria-expanded={open}
783
+ aria-controls={panelId}
784
+ onClick={() => setOpenId(open ? null : s.id)}
785
+ >
786
+ <span className="te-sec-name">{name}</span>
787
+ <span className="te-sec-meta">
788
+ {s.variant !== 'default' && s.variant}
789
+ {s.hidden && ' · hidden'}
790
+ {!def && ' · unavailable'}
791
+ {unsaved && ' · saving'}
792
+ {(s.showFrom || s.showUntil) && ' · scheduled'}
793
+ </span>
794
+ {issueCount > 0 && <span className="te-issue-mark">{issueCount} to fix</span>}
795
+ </button>
796
+ {lang && def ? (() => {
797
+ const sum = sectionSummary(def, s, lang);
798
+ return sum.total === 0 ? <span className="te-mark">shared</span>
799
+ : sum.missing ? <span className="te-mark st-miss">{sum.missing} to translate</span>
800
+ : sum.outdated ? <span className="te-mark st-old">{sum.outdated} changed</span>
801
+ : sum.ai ? <span className="te-mark st-ai">{sum.ai} AI {sum.ai === 1 ? 'draft' : 'drafts'}</span>
802
+ : <span className="te-mark is-linked">✓ translated</span>;
803
+ })() : def && hasSource(def) ? <span className="te-mark is-linked">linked</span> : coded.has(s.type) ? <span className="te-mark is-coded">coded</span> : null}
804
+ {s.locked && <span className="te-mark is-locked" title={isOwner ? 'Editors can’t change this section' : 'Locked by an owner'}>locked</span>}
805
+ <div className="te-sec-actions" hidden={!isOwner || Boolean(lang)}>
806
+ <button className="icon-btn" type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label={`Move ${name} up`} title="Move up">
807
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 15 6-6 6 6" /></svg>
808
+ </button>
809
+ <button className="icon-btn" type="button" onClick={() => move(i, 1)} disabled={i === layout.sections.length - 1} aria-label={`Move ${name} down`} title="Move down">
810
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
811
+ </button>
812
+ </div>
813
+ </div>
814
+
815
+ {open && lang && def ? (
816
+ <div className="te-sec-body" id={panelId}>
817
+ <TranslationFields
818
+ def={def}
819
+ section={s}
820
+ lang={lang}
821
+ langName={i18n.locales.find((l) => l.code === lang)?.name ?? lang}
822
+ readOnly={Boolean(s.locked && !isOwner)}
823
+ busyPaths={busyPaths}
824
+ onSet={(u, v) => patchSection(s.id, (x) => withTranslation(x, lang, u, v, 'reviewed') as SectionInstance)}
825
+ onConfirm={(u) => patchSection(s.id, (x) => confirmTranslation(x, lang, u) as SectionInstance)}
826
+ onDraft={i18n.drafts ? (units) => void draftUnits(lang, units.map((unit) => ({ sectionId: s.id, unit }))) : null}
827
+ />
828
+ </div>
829
+ ) : open && (
830
+ <div className="te-sec-body" id={panelId}>
831
+ {!def ? (
832
+ <p className="notice warn te-note">
833
+ {theme?.name ?? 'This theme'} has no “{s.type}” section, so it won't render. Its {Object.keys(s.data).length} saved {Object.keys(s.data).length === 1 ? 'value is' : 'values are'} preserved and come back if you switch to a theme that has it.
834
+ </p>
835
+ ) : (
836
+ <>
837
+ {def.version !== undefined && s.version !== def.version && (
838
+ <div className="notice warn te-note">
839
+ <span>Saved with version {s.version} of this section; the theme ships version {def.version}.{migratable.has(s.type) ? ' The theme can upgrade it.' : ' The theme has no upgrade for it, so it stays hidden until a developer adds one.'}</span>
840
+ {migratable.has(s.type) && <button className="btn sm" type="button" onClick={() => void upgrade()} disabled={saving}>Upgrade sections</button>}
841
+ </div>
842
+ )}
843
+ {def.description && <p className="te-muted te-desc">{def.description}</p>}
844
+ {s.type === 'page-content' && <p className="te-muted">This section uses the page's published text and widgets. Edit them in the page editor; any pending content draft stays separate.</p>}
845
+ {s.locked && !isOwner && <p className="notice te-note">An owner locked this section, so it can’t be changed here.</p>}
846
+ {(def.variants?.length ?? 0) > 1 && isOwner && (
847
+ <div className="field te-variants">
848
+ <span>Style</span>
849
+ <div className="seg" role="radiogroup" aria-label="Style">
850
+ {!def.variants!.includes(s.variant) && <button type="button" role="radio" aria-checked className="on">{s.variant} (not in this theme)</button>}
851
+ {def.variants!.map((v) => (
852
+ <button key={v} type="button" role="radio" aria-checked={s.variant === v} className={s.variant === v ? 'on' : ''} onClick={() => patchSection(s.id, (x) => ({ ...x, variant: v }))}>
853
+ {v.replace(/-/g, ' ')}
854
+ </button>
855
+ ))}
856
+ </div>
857
+ </div>
858
+ )}
859
+ {Object.entries(def.fields ?? {}).filter(([, f]) => fieldVisible(f, normalizeData(def, s.data))).map(([key, f]) => (
860
+ <fieldset key={key} disabled={!isOwner && (s.locked || Boolean((f as { locked?: boolean }).locked))} className={`te-field ${issues[key]?.length ? 'has-issue' : ''} ${!isOwner && (f as { locked?: boolean }).locked ? 'is-locked' : ''}`}>
861
+ {!isOwner && (f as { locked?: boolean }).locked && !s.locked && <small className="te-lock-note">Locked by an owner</small>}
862
+ {(isOwner || !(s.locked || (f as { locked?: boolean }).locked)) && JSON.stringify(normalizeValue(f, s.data[key])) !== JSON.stringify(defaultValue(f)) && f.type !== 'list' && f.type !== 'source' && (
863
+ <button type="button" className="te-reset" onClick={() => setField(s.id, key, defaultValue(f) as SectionValue)} title="Back to the theme’s default">Reset</button>
864
+ )}
865
+ <FieldInput
866
+ id={`${panelId}-${key}`}
867
+ field={f}
868
+ value={normalizeValue(f, s.data[key])}
869
+ onChange={(v) => setField(s.id, key, v)}
870
+ targets={targets}
871
+ pickImage={(apply) => setImageApply(() => apply)}
872
+ context={sectionContext(def, s, key)}
873
+ focal={f.type === 'image' ? String(s.data[`${key}__focus`] ?? '') : undefined}
874
+ onFocal={f.type === 'image' ? (v) => setField(s.id, `${key}__focus`, v) : undefined}
875
+ />
876
+ {issues[key]?.map((m) => <small key={m} className="te-error" role="alert">{m}</small>)}
877
+ </fieldset>
878
+ ))}
879
+ {orphans.length > 0 && isOwner && (
880
+ <div className="notice te-note te-orphans">
881
+ <span>{orphans.length} saved {orphans.length === 1 ? 'value' : 'values'} ({orphans.join(', ')}) no longer {orphans.length === 1 ? 'has' : 'have'} a field in this section. Kept in case the theme brings {orphans.length === 1 ? 'it' : 'them'} back.</span>
882
+ <button className="btn sm ghost" type="button" onClick={() => patchSection(s.id, (x) => {
883
+ const d = { ...x.data };
884
+ for (const k of orphans) delete d[k];
885
+ return { ...x, data: d };
886
+ })}>Remove</button>
887
+ </div>
888
+ )}
889
+ </>
890
+ )}
891
+ {(schedFor === s.id || s.showFrom || s.showUntil) && (
892
+ <div className="te-schedule">
893
+ <span className="te-schedule-title">Show on the site</span>
894
+ <label>From <input type="datetime-local" value={toLocalInput(s.showFrom)} onChange={(e) => patchSection(s.id, (x) => ({ ...x, showFrom: fromLocalInput(e.target.value) }))} /></label>
895
+ <label>Until <input type="datetime-local" value={toLocalInput(s.showUntil)} onChange={(e) => patchSection(s.id, (x) => ({ ...x, showUntil: fromLocalInput(e.target.value) }))} /></label>
896
+ <span className="te-muted te-schedule-state">{scheduleText(s)}</span>
897
+ {(s.showFrom || s.showUntil) && <button className="btn sm ghost" type="button" onClick={() => { patchSection(s.id, (x) => ({ ...x, showFrom: undefined, showUntil: undefined })); setSchedFor(null); }}>Always show</button>}
898
+ </div>
899
+ )}
900
+ <div className="te-sec-foot">
901
+ {isOwner && <>
902
+ <button className="btn sm" type="button" aria-pressed={s.hidden} onClick={() => patchSection(s.id, (x) => ({ ...x, hidden: !x.hidden }))}>
903
+ {s.hidden ? 'Show section' : 'Hide section'}
904
+ </button>
905
+ {!s.showFrom && !s.showUntil && schedFor !== s.id && <button className="btn sm" type="button" onClick={() => setSchedFor(s.id)}>Schedule…</button>}
906
+ <details className="te-more">
907
+ <summary className="btn sm">More</summary>
908
+ <div className="te-more-menu">
909
+ <button type="button" onClick={() => duplicate(i)}>Duplicate</button>
910
+ <button type="button" onClick={() => copySection(s)}>Copy section</button>
911
+ {data.pages.length > 1 && (
912
+ <label>Copy to page
913
+ <select value="" onChange={(e) => { if (e.target.value) copyToPage(s, e.target.value); }}>
914
+ <option value="">Choose…</option>
915
+ {data.pages.filter((p) => p.id !== page.id).map((p) => <option key={p.id} value={p.id}>{p.title || p.slug}</option>)}
916
+ </select>
917
+ </label>
918
+ )}
919
+ {def && Object.keys(def.fields ?? {}).length > 0 && <button type="button" onClick={() => { if (window.confirm(`Reset every field in “${name}” to its default?`)) patchSection(s.id, (x) => ({ ...x, data: defaultsFor(def) })); }}>Reset all fields</button>}
920
+ <button type="button" onClick={() => patchSection(s.id, (x) => ({ ...x, locked: x.locked ? undefined : true }))}>{s.locked ? 'Unlock for editors' : 'Lock for editors'}</button>
921
+ </div>
922
+ </details>
923
+ <button className="btn sm ghost danger te-delete" type="button" onClick={() => remove(s)}>Delete section</button>
924
+ </>}
925
+ {canPublishAlone && changedFromLive && layout.mode === 'sections' && liveLayout?.mode === 'sections' && (
926
+ <button className="btn sm primary te-publish-one" type="button" onClick={() => void publishSection(s)} disabled={saving || conflict || issueCount > 0} title={issueCount ? 'Fix the fields marked below first' : undefined}>
927
+ {unsaved ? 'Save & publish this section' : 'Publish this section'}
928
+ </button>
929
+ )}
930
+ </div>
931
+ </div>
342
932
  )}
343
- {Object.entries(def.fields ?? {}).map(([key, f]) => (
344
- <FieldInput
345
- key={key}
346
- id={`${panelId}-${key}`}
347
- field={f}
348
- value={s.data[key]}
349
- onChange={(v) => setField(s.id, key, v)}
350
- onPickImage={() => setImageFor({ sectionId: s.id, key })}
351
- />
352
- ))}
353
933
  </>
354
934
  )}
355
- <div className="te-sec-foot">
356
- <button className="btn sm" type="button" aria-pressed={s.hidden} onClick={() => patchSection(s.id, (x) => ({ ...x, hidden: !x.hidden }))}>
357
- {s.hidden ? 'Show section' : 'Hide section'}
358
- </button>
359
- <button className="btn sm" type="button" onClick={() => duplicate(i)}>Duplicate</button>
360
- <button className="btn sm ghost danger te-delete" type="button" onClick={() => remove(s)}>Delete section</button>
361
- </div>
362
- </div>
363
- )}
364
- </li>
365
- );
366
- })}
367
- </ol>
368
-
369
- <div className="te-add">
370
- <label className="field">
371
- <span>Add a section</span>
372
- <select aria-label="Add a section" value={addType} onChange={(e) => setAddType(e.target.value)} disabled={defTypes.length === 0}>
373
- {defTypes.length === 0 && <option value="">This theme has no sections</option>}
374
- {defTypes.map((t) => <option key={t} value={t}>{sectionName(defs[t], t)}</option>)}
375
- </select>
376
- </label>
377
- <button className="btn" type="button" onClick={addSection} disabled={!defs[addType]}>Add</button>
935
+ </SortableSection>
936
+ );
937
+ })}
938
+ </ol>
939
+ </SortableContext>
940
+ </DndContext>
941
+
942
+ <div className="te-add" hidden={!isOwner || Boolean(lang)}>
943
+ <button className="btn" type="button" onClick={() => setPickerOpen(true)} disabled={defTypes.length === 0}>+ Add section</button>
944
+ {clip && defs[clip.type] && <button className="btn ghost" type="button" onClick={pasteSection}>Paste “{sectionName(defs[clip.type], clip.type)}”</button>}
378
945
  </div>
946
+ <SectionPicker
947
+ open={pickerOpen}
948
+ defs={defs}
949
+ coded={coded}
950
+ counts={Object.fromEntries(defTypes.map((t) => [t, layout.sections.filter((x) => x.type === t).length]))}
951
+ theme={theme?.slug ?? activeTheme}
952
+ onClose={() => setPickerOpen(false)}
953
+ onPick={addSection}
954
+ />
955
+ {presets.length > 0 && (
956
+ <details className="te-preset-more" hidden={!isOwner}>
957
+ <summary>Start over from a {theme?.name} layout</summary>
958
+ <div className="te-presets">
959
+ {presets.map(([key, p]) => (
960
+ <button key={key} type="button" className="te-preset" onClick={() => applyPreset(key)}>
961
+ <b>{p.name}</b>
962
+ <span>{p.description || `${p.sections.length} sections`}</span>
963
+ </button>
964
+ ))}
965
+ </div>
966
+ </details>
967
+ )}
379
968
  </>
380
969
  )}
381
970
 
@@ -388,97 +977,36 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled
388
977
  {saveError && <p className="flash error te-alert" role="alert">{saveError}</p>}
389
978
 
390
979
  <div className="te-savebar">
391
- <span className={`te-savestate ${dirty ? 'is-dirty' : ''}`} aria-live="polite">
392
- {dirty ? 'Unsaved section edits' : 'No unsaved section edits'}
980
+ <span className={`te-savestate ${saveState === 'error' ? 'is-dirty' : ''}`} aria-live="polite">
981
+ {flash || (saveState === 'error' ? 'Not saved yet' : dirty || saveState === 'saving' ? 'Saving draft…' : saveState === 'saved' ? 'Draft saved · publish when ready' : 'All changes saved')}
982
+ {totalIssues > 0 && <> · <button type="button" className="te-issue-link" onClick={() => setOpenId(firstIssueId)}>{totalIssues} {totalIssues === 1 ? 'field needs' : 'fields need'} fixing before publishing</button></>}
393
983
  </span>
394
- {dirty && <button className="btn sm ghost" type="button" onClick={discardEdits} disabled={saving}>Discard edits</button>}
395
- <button className="btn sm" type="button" onClick={() => void save()} disabled={!dirty || saving || conflict}>
396
- {saving ? 'Saving…' : 'Save section draft'}
397
- </button>
984
+ <button className="btn sm ghost" type="button" onClick={undo} disabled={!past.current.length} title="Undo (⌘Z)">Undo</button>
985
+ <button className="btn sm ghost" type="button" onClick={redo} disabled={!future.current.length} title="Redo (⌘⇧Z)">Redo</button>
986
+ {saveState === 'error' && <button className="btn sm" type="button" onClick={() => void autosaveRef.current()} disabled={conflict}>Try saving again</button>}
398
987
  </div>
399
988
 
400
989
  <ImagePicker
401
- open={Boolean(imageFor && editingSection)}
990
+ open={imageApply !== null}
402
991
  unsplashEnabled={unsplashEnabled}
403
- onClose={() => setImageFor(null)}
992
+ onClose={() => setImageApply(null)}
404
993
  onSelect={(img) => {
405
- if (imageFor) setField(imageFor.sectionId, imageFor.key, img.src);
406
- setImageFor(null);
994
+ imageApply?.(img.src);
995
+ setImageApply(null);
407
996
  }}
408
997
  />
409
998
  </div>
410
999
  );
411
1000
  }
412
1001
 
413
- function FieldInput({ id, field, value, onChange, onPickImage }: {
414
- id: string;
415
- field: FieldDef;
416
- value: SectionValue | undefined;
417
- onChange: (v: SectionValue | undefined) => void;
418
- onPickImage: () => void;
419
- }) {
420
- const shown = value ?? field.default ?? '';
421
- switch (field.type) {
422
- case 'textarea':
423
- return (
424
- <label className="field" htmlFor={id}>
425
- <span>{field.label}</span>
426
- <textarea id={id} rows={4} value={String(shown)} onChange={(e) => onChange(e.target.value)} />
427
- </label>
428
- );
429
- case 'number':
430
- return (
431
- <label className="field" htmlFor={id}>
432
- <span>{field.label}</span>
433
- <input
434
- id={id}
435
- type="number"
436
- value={shown === '' ? '' : String(shown)}
437
- onChange={(e) => {
438
- const n = e.target.valueAsNumber;
439
- onChange(e.target.value === '' || !Number.isFinite(n) ? undefined : n);
440
- }}
441
- />
442
- </label>
443
- );
444
- case 'select': {
445
- const opts = field.options ?? [];
446
- return (
447
- <label className="field" htmlFor={id}>
448
- <span>{field.label}</span>
449
- <select id={id} value={String(shown)} onChange={(e) => onChange(e.target.value)}>
450
- {!opts.includes(String(shown)) && <option value={String(shown)}>{shown === '' ? 'Choose…' : `${shown} (not an option)`}</option>}
451
- {opts.map((o) => <option key={o} value={o}>{o}</option>)}
452
- </select>
453
- </label>
454
- );
455
- }
456
- case 'image':
457
- return (
458
- <div className="field te-image">
459
- <label htmlFor={id}><span>{field.label}</span></label>
460
- <div className="te-image-row">
461
- {shown !== '' && <img className="te-thumb" src={String(shown)} alt="" />}
462
- <input id={id} className="mono" type="url" placeholder="https://" value={String(shown)} onChange={(e) => onChange(e.target.value)} />
463
- <button className="btn sm" type="button" onClick={onPickImage}>Choose…</button>
464
- </div>
465
- </div>
466
- );
467
- case 'url':
468
- return (
469
- <label className="field" htmlFor={id}>
470
- <span>{field.label}</span>
471
- <input id={id} className="mono" type="url" placeholder="https:// or /path" value={String(shown)} onChange={(e) => onChange(e.target.value)} />
472
- </label>
473
- );
474
- default:
475
- return (
476
- <label className="field" htmlFor={id}>
477
- <span>{field.label}</span>
478
- <input id={id} type="text" value={String(shown)} onChange={(e) => onChange(e.target.value)} />
479
- </label>
480
- );
481
- }
1002
+ type DragHandle = Record<string, unknown>;
1003
+ function SortableSection({ id, className, children }: { id: string; className: string; children: (handle: DragHandle) => ReactNode }) {
1004
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
1005
+ return (
1006
+ <li ref={setNodeRef} data-sec={id} className={`${className} ${isDragging ? 'is-dragging' : ''}`} style={{ transform: DndCSS.Transform.toString(transform), transition }}>
1007
+ {children({ ...attributes, ...listeners })}
1008
+ </li>
1009
+ );
482
1010
  }
483
1011
 
484
1012
  /** Review tab: which pages the staged draft re-lays out, vs the live site */