@acms-subir/core 0.1.0 → 0.3.0

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 (237) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -315
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +193 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +35 -12
  19. package/src/actions/index.ts +744 -38
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +455 -47
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +216 -97
  30. package/src/components/NavManager.tsx +83 -38
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +826 -271
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/cards.tsx +17 -1
  45. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  46. package/src/components/editor/extensions/events.tsx +153 -0
  47. package/src/components/editor/extensions/infoCards.tsx +16 -1
  48. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  49. package/src/components/editor/extensions/slash.tsx +77 -9
  50. package/src/components/editor/extensions/toc.tsx +89 -0
  51. package/src/components/editor/extensions/toggle.tsx +70 -0
  52. package/src/components/site/Pager.astro +24 -0
  53. package/src/components/site/PostCard.astro +64 -0
  54. package/src/components/site/TagLabel.astro +11 -0
  55. package/src/layouts/Admin.astro +289 -49
  56. package/src/layouts/Site.astro +331 -57
  57. package/src/lib/agent-api.ts +13 -0
  58. package/src/lib/agent-discovery.ts +40 -0
  59. package/src/lib/agent.ts +114 -0
  60. package/src/lib/ai-config.ts +58 -0
  61. package/src/lib/ai-schema.ts +22 -0
  62. package/src/lib/ai.ts +90 -0
  63. package/src/lib/analytics.ts +121 -0
  64. package/src/lib/api-keys.ts +65 -0
  65. package/src/lib/api.ts +52 -0
  66. package/src/lib/auth.ts +25 -5
  67. package/src/lib/backup/content.ts +36 -0
  68. package/src/lib/backup/unzip.ts +12 -1
  69. package/src/lib/backup/zip.ts +1 -1
  70. package/src/lib/booking/agent.ts +108 -0
  71. package/src/lib/booking/availability.ts +221 -0
  72. package/src/lib/booking/emails.ts +115 -0
  73. package/src/lib/booking/forms.ts +77 -0
  74. package/src/lib/booking/service.ts +277 -0
  75. package/src/lib/booking/view.ts +54 -0
  76. package/src/lib/calendar/agent-schema.ts +48 -0
  77. package/src/lib/calendar/agent.ts +136 -0
  78. package/src/lib/calendar/api.ts +81 -0
  79. package/src/lib/calendar/cards.ts +102 -0
  80. package/src/lib/calendar/config.ts +52 -0
  81. package/src/lib/calendar/data.ts +130 -0
  82. package/src/lib/calendar/events.ts +261 -0
  83. package/src/lib/calendar/feeds.ts +43 -0
  84. package/src/lib/calendar/hooks.ts +38 -0
  85. package/src/lib/calendar/http.ts +30 -0
  86. package/src/lib/calendar/ics-write.ts +120 -0
  87. package/src/lib/calendar/ics.ts +15 -5
  88. package/src/lib/calendar/import.ts +203 -0
  89. package/src/lib/calendar/render.ts +120 -37
  90. package/src/lib/calendar/rrule.ts +149 -0
  91. package/src/lib/calendar/seo.ts +48 -0
  92. package/src/lib/design-service.ts +58 -0
  93. package/src/lib/designDiff.ts +145 -0
  94. package/src/lib/designPreview.ts +112 -0
  95. package/src/lib/documents/cleanup.ts +31 -0
  96. package/src/lib/documents/extract.ts +154 -0
  97. package/src/lib/documents/vendor.d.ts +4 -0
  98. package/src/lib/drafts.ts +131 -0
  99. package/src/lib/dynamicBlocks.ts +79 -7
  100. package/src/lib/email/accounts.ts +55 -0
  101. package/src/lib/email/config.ts +86 -0
  102. package/src/lib/email/migration.ts +24 -0
  103. package/src/lib/email/providers.ts +89 -0
  104. package/src/lib/email/service.ts +78 -0
  105. package/src/lib/email/store.ts +167 -0
  106. package/src/lib/email/templates.ts +18 -0
  107. package/src/lib/email/types.ts +32 -0
  108. package/src/lib/email/webhooks.ts +87 -0
  109. package/src/lib/email.ts +15 -0
  110. package/src/lib/env.ts +1 -1
  111. package/src/lib/legal/index.ts +25 -0
  112. package/src/lib/mobile/bookings.ts +73 -0
  113. package/src/lib/mobile/calendar.ts +85 -0
  114. package/src/lib/mobile/content.ts +85 -0
  115. package/src/lib/mobile/http.ts +63 -0
  116. package/src/lib/mobile/route.ts +20 -0
  117. package/src/lib/ops.ts +8 -0
  118. package/src/lib/preview.ts +21 -1
  119. package/src/lib/repo/migrations.ts +254 -0
  120. package/src/lib/repo/sql.ts +956 -46
  121. package/src/lib/repo/types.ts +140 -3
  122. package/src/lib/scheduler.ts +25 -0
  123. package/src/lib/schema.ts +362 -0
  124. package/src/lib/search-i18n.ts +3 -0
  125. package/src/lib/socialIcons.ts +32 -0
  126. package/src/lib/tagColor.ts +11 -0
  127. package/src/lib/theme.ts +59 -40
  128. package/src/lib/themeTokens.ts +174 -0
  129. package/src/lib/umami.ts +108 -0
  130. package/src/lib/webhooks.ts +139 -0
  131. package/src/middleware.ts +36 -3
  132. package/src/pages/404.astro +23 -0
  133. package/src/pages/[...slug].astro +10 -5
  134. package/src/pages/account/[action].astro +53 -0
  135. package/src/pages/admin/ai.astro +12 -0
  136. package/src/pages/admin/c/[collection]/[id].astro +14 -4
  137. package/src/pages/admin/c/[collection]/index.astro +127 -91
  138. package/src/pages/admin/calendar/bookings.astro +400 -0
  139. package/src/pages/admin/calendar/calendars.astro +265 -0
  140. package/src/pages/admin/calendar/events/[id].astro +56 -0
  141. package/src/pages/admin/calendar/events/index.astro +408 -0
  142. package/src/pages/admin/calendar/index.ts +4 -0
  143. package/src/pages/admin/calendar/integrations.astro +221 -0
  144. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  145. package/src/pages/admin/calendar/resources/index.astro +106 -0
  146. package/src/pages/admin/calendar/resources/link.astro +196 -0
  147. package/src/pages/admin/design.astro +16 -4
  148. package/src/pages/admin/email.astro +13 -0
  149. package/src/pages/admin/index.astro +237 -87
  150. package/src/pages/admin/links.astro +3 -3
  151. package/src/pages/admin/login.astro +14 -10
  152. package/src/pages/admin/navigation.astro +8 -2
  153. package/src/pages/admin/pages/[id].astro +14 -3
  154. package/src/pages/admin/pages/index.astro +41 -47
  155. package/src/pages/admin/settings.astro +475 -276
  156. package/src/pages/admin/tags.astro +4 -4
  157. package/src/pages/admin/users.astro +38 -28
  158. package/src/pages/api/agent/docs.ts +33 -0
  159. package/src/pages/api/agent/index.ts +21 -0
  160. package/src/pages/api/ai/clean.ts +13 -0
  161. package/src/pages/api/ai/draft.ts +14 -0
  162. package/src/pages/api/ai/settings.ts +53 -0
  163. package/src/pages/api/ai/test.ts +12 -0
  164. package/src/pages/api/backup.ts +8 -21
  165. package/src/pages/api/calendar/availability.ts +35 -0
  166. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  167. package/src/pages/api/calendar/bookings/index.ts +73 -0
  168. package/src/pages/api/calendar/calendars.ts +35 -0
  169. package/src/pages/api/calendar/docs.ts +64 -0
  170. package/src/pages/api/calendar/events/[id].ts +63 -0
  171. package/src/pages/api/calendar/events/index.ts +112 -0
  172. package/src/pages/api/calendar/resources.ts +34 -0
  173. package/src/pages/api/design/capabilities.ts +10 -0
  174. package/src/pages/api/design/index.ts +58 -0
  175. package/src/pages/api/design/keys.ts +24 -0
  176. package/src/pages/api/design/media.ts +32 -0
  177. package/src/pages/api/email/run.ts +11 -0
  178. package/src/pages/api/email/settings.ts +61 -0
  179. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  180. package/src/pages/api/mcp.ts +59 -0
  181. package/src/pages/api/media/upload.ts +2 -2
  182. package/src/pages/api/ops/status.ts +39 -0
  183. package/src/pages/api/restore.ts +27 -37
  184. package/src/pages/api/search.ts +18 -4
  185. package/src/pages/api/v1/availability.ts +7 -0
  186. package/src/pages/api/v1/bookings.ts +17 -0
  187. package/src/pages/api/v1/calendars.ts +7 -0
  188. package/src/pages/api/v1/content/[id].ts +7 -0
  189. package/src/pages/api/v1/content/index.ts +7 -0
  190. package/src/pages/api/v1/events.ts +7 -0
  191. package/src/pages/api/v1/resources.ts +7 -0
  192. package/src/pages/author/[slug].astro +43 -35
  193. package/src/pages/blog/[slug].astro +140 -66
  194. package/src/pages/blog/index.astro +95 -30
  195. package/src/pages/blog/tag/[slug].astro +45 -40
  196. package/src/pages/book/[resource]/[link].astro +70 -0
  197. package/src/pages/book/[resource]/index.astro +63 -0
  198. package/src/pages/book/[resource].ics.ts +38 -0
  199. package/src/pages/book/index.astro +47 -0
  200. package/src/pages/book/manage/[token].astro +108 -0
  201. package/src/pages/calendar/[slug].ics.ts +27 -0
  202. package/src/pages/calendar/feed.ics.ts +15 -0
  203. package/src/pages/calendar/index.astro +191 -0
  204. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  205. package/src/pages/embed/calendar/[slug].astro +43 -0
  206. package/src/pages/events/[slug].astro +74 -0
  207. package/src/pages/events/[slug].ics.ts +25 -0
  208. package/src/pages/index.astro +9 -3
  209. package/src/pages/legal/privacy.astro +4 -0
  210. package/src/pages/legal/terms.astro +4 -0
  211. package/src/pages/links.astro +33 -7
  212. package/src/pages/llms.txt.ts +4 -0
  213. package/src/pages/preview/[type]/[id].astro +34 -8
  214. package/src/pages/search.astro +40 -20
  215. package/src/pages/sitemap.xml.ts +16 -1
  216. package/src/styles/admin.css +928 -105
  217. package/src/styles/cards.css +101 -60
  218. package/src/styles/editor.css +532 -102
  219. package/src/styles/email-settings.css +117 -0
  220. package/src/styles/sections.css +12 -0
  221. package/src/styles/site.css +938 -278
  222. package/src/styles/theme-editor.css +135 -0
  223. package/src/theme-sdk/contract.d.mts +619 -0
  224. package/src/theme-sdk/contract.mjs +80 -0
  225. package/src/theme-sdk/index.ts +15 -0
  226. package/src/theme-sdk/tokens.d.mts +148 -0
  227. package/src/theme-sdk/tokens.mjs +52 -0
  228. package/src/themes/editorial/templates/home.astro +4 -4
  229. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  230. package/src/themes/editorial/theme.css +22 -37
  231. package/src/themes/editorial/theme.json +23 -3
  232. package/src/themes/field/theme.json +26 -9
  233. package/src/themes/ink/theme.json +30 -12
  234. package/src/themes/night/theme.css +4 -7
  235. package/src/themes/night/theme.json +27 -10
  236. package/src/virtual.d.ts +14 -0
  237. package/src/worker.ts +5 -1
@@ -1,12 +1,20 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
2
2
  import { actions } from 'astro:actions';
3
3
  import type { Settings } from '../lib/schema';
4
+ import type { DesignChange } from '../lib/designDiff';
4
5
  import { ImagePicker } from './editor/ImagePicker';
6
+ import ThemeSectionsEditor, { SectionsReview, useDesignApi } from './ThemeSectionsEditor';
7
+ import DesignAccess from './DesignAccess';
8
+ import '../styles/theme-editor.css';
5
9
 
6
10
  // Design screen: pick a code-first theme (src/themes/<slug>/), layer sparse
7
11
  // token overrides on top (saved to settings.designOverrides — the theme files
8
12
  // are never written to), set the site logo, and export the effective design
9
13
  // as a committable theme.json.
14
+ //
15
+ // Every change stages into settings.draftDesign — the preview iframe renders
16
+ // the staged design (?designPreview=1, admin-only) while visitors keep the
17
+ // live one until "Publish design" applies it.
10
18
 
11
19
  type SaveState = 'saved' | 'saving' | 'error';
12
20
 
@@ -14,9 +22,12 @@ interface ThemeInfo {
14
22
  slug: string;
15
23
  name: string;
16
24
  description: string;
17
- tokens: Record<string, string | number>;
25
+ tokens: Record<string, string | number> & { dark?: Record<string, string> };
18
26
  post: Record<string, string | boolean>;
19
27
  hasHome: boolean;
28
+ version: string;
29
+ templateCount: number;
30
+ sectionCount: number;
20
31
  }
21
32
 
22
33
  // post-page toggles: designOverrides key + which theme default backs it
@@ -27,19 +38,41 @@ const POST_TOGGLE_ROWS: { key: string; field: string; label: string; hint: strin
27
38
  { key: 'postAuthorCard', field: 'authorCard', label: 'Author card', hint: 'Avatar + bio block under the post' },
28
39
  { key: 'postShare', field: 'share', label: 'Share buttons', hint: 'X, LinkedIn and copy-link' },
29
40
  { key: 'postRelated', field: 'related', label: 'Related reading', hint: 'Three related posts at the bottom' },
41
+ { key: 'postProgress', field: 'progress', label: 'Reading progress', hint: 'Thin accent bar across the top while reading' },
42
+ { key: 'postToc', field: 'toc', label: 'On this page', hint: 'Sticky section list beside long posts (wide screens)' },
43
+ ];
44
+
45
+ // shape / layout tokens (token v2): designOverrides key = token key
46
+ const LOOK_ROWS: { key: string; label: string; hint: string; options: [string, string][] }[] = [
47
+ { key: 'scheme', label: 'Light & dark', hint: 'Auto follows the visitor’s device and adds a toggle to the header', options: [['auto', 'Auto + toggle'], ['light', 'Light only'], ['dark', 'Dark only']] },
48
+ { key: 'button', label: 'Buttons', hint: 'Fill for primary buttons', options: [['ink', 'Ink (text color)'], ['accent', 'Accent color']] },
49
+ { key: 'radius', label: 'Corners', hint: 'Roundness of cards, images and controls', options: [['sharp', 'Sharp'], ['soft', 'Soft'], ['round', 'Round'], ['smooth', 'Extra round']] },
50
+ { key: 'elevation', label: 'Depth', hint: 'Shadows under cards and buttons', options: [['flat', 'Flat (borders only)'], ['soft', 'Soft'], ['lifted', 'Lifted']] },
51
+ { key: 'density', label: 'Spacing', hint: 'Vertical rhythm of pages and sections', options: [['compact', 'Compact'], ['cozy', 'Cozy'], ['airy', 'Airy']] },
52
+ { key: 'header', label: 'Header', hint: 'Style of the site header', options: [['floating', 'Floating bar'], ['bar', 'Full-width bar'], ['minimal', 'Minimal (scrolls away)'], ['centered', 'Centered logo']] },
53
+ { key: 'footer', label: 'Footer', hint: 'Columns show links, socials and a Read column', options: [['columns', 'Columns'], ['simple', 'Simple row']] },
54
+ { key: 'blogLayout', label: 'Post lists', hint: 'Auto uses cards when most posts have a cover', options: [['auto', 'Auto'], ['cards', 'Cards'], ['list', 'List']] },
30
55
  ];
31
56
  const COVER_LABEL: Record<string, string> = { normal: 'Content width', full: 'Full bleed', overlay: 'Overlay (title on image)', hidden: 'Hidden' };
32
57
  interface Pairing { key: string; label: string; headingFont: string; bodyFont: string; googleFonts: string }
33
58
 
34
59
  interface Props {
35
60
  settings: Settings;
61
+ // true when settings.draftDesign held staged changes at load time
62
+ pendingDesign: boolean;
36
63
  themes: ThemeInfo[];
37
64
  pairings: Pairing[];
38
65
  unsplashEnabled: boolean;
39
66
  /** public path of the newest published post, or null when nothing is published */
40
67
  postPreviewPath: string | null;
68
+ /** staged draft vs live, computed server-side at load */
69
+ changes: DesignChange[];
41
70
  }
42
71
 
72
+ interface VersionRow { id: string; createdAt: number; author: string; summary: string; theme: string }
73
+ const when = (ts: number) =>
74
+ new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
75
+
43
76
  const COLOR_FIELDS: { key: string; label: string; hint: string }[] = [
44
77
  { key: 'bg', label: 'Background', hint: 'Page background' },
45
78
  { key: 'ink', label: 'Text', hint: 'Body and heading text' },
@@ -47,7 +80,7 @@ const COLOR_FIELDS: { key: string; label: string; hint: string }[] = [
47
80
  { key: 'muted', label: 'Muted text', hint: 'Bylines, captions, metadata' },
48
81
  { key: 'line', label: 'Borders', hint: 'Dividers and card borders' },
49
82
  { key: 'panel', label: 'Panels', hint: 'Cards and link buttons' },
50
- { key: 'soft', label: 'Soft fill', hint: 'Tag pills, inline code, table heads' },
83
+ { key: 'soft', label: 'Soft fill', hint: 'Inline code, quotes, table heads, empty images' },
51
84
  ];
52
85
  const WIDTHS = [
53
86
  { label: 'Narrow', px: 640 },
@@ -80,7 +113,23 @@ const DEVICES = [
80
113
  ] as const;
81
114
  type DeviceKey = (typeof DEVICES)[number]['key'];
82
115
 
83
- export default function DesignManager({ settings, themes, pairings, unsplashEnabled, postPreviewPath }: Props) {
116
+ // control tabs; the active one lives in the URL hash so reloads (after a
117
+ // section save, discard or restore) land back on the same tab
118
+ const TABS = [
119
+ { key: 'theme', label: 'Theme' },
120
+ { key: 'brand', label: 'Brand' },
121
+ { key: 'pages', label: 'Pages' },
122
+ { key: 'review', label: 'Review' },
123
+ { key: 'access', label: 'Access' },
124
+ ] as const;
125
+ type TabKey = (typeof TABS)[number]['key'];
126
+ const tabFromHash = (): TabKey => {
127
+ const h = typeof location === 'undefined' ? '' : location.hash.slice(1);
128
+ return (TABS.find((t) => t.key === h)?.key ?? 'theme');
129
+ };
130
+
131
+ export default function DesignManager({ settings, pendingDesign, themes, pairings, unsplashEnabled, postPreviewPath, changes: initialChanges }: Props) {
132
+ const [pending, setPending] = useState(pendingDesign);
84
133
  const [activeTheme, setActiveTheme] = useState(settings.activeTheme || 'ink');
85
134
  const [overrides, setOverrides] = useState<Record<string, unknown>>(
86
135
  (settings.designOverrides as Record<string, unknown>) ?? {}
@@ -89,6 +138,7 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
89
138
  const [siteFavicon, setSiteFavicon] = useState(settings.siteFavicon);
90
139
  const [siteCover, setSiteCover] = useState(settings.siteCover);
91
140
  const [saveState, setSaveState] = useState<SaveState>('saved');
141
+ const [saveMessage, setSaveMessage] = useState('');
92
142
  const [logoPickerOpen, setLogoPickerOpen] = useState(false);
93
143
  const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
94
144
  const [coverPickerOpen, setCoverPickerOpen] = useState(false);
@@ -96,6 +146,57 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
96
146
  const [tick, setTick] = useState(0);
97
147
  const [previewPage, setPreviewPage] = useState<'home' | 'post'>('home');
98
148
  const [previewDevice, setPreviewDevice] = useState<DeviceKey>('mobile');
149
+ const [previewScheme, setPreviewScheme] = useState<'light' | 'dark'>('light');
150
+ // "What changes" + preview compare / share / history
151
+ const [changes, setChanges] = useState<DesignChange[]>(initialChanges);
152
+ const [compare, setCompare] = useState<'draft' | 'live'>('draft');
153
+ const [previewPath, setPreviewPath] = useState('/');
154
+ const pathRef = useRef('/');
155
+ const shareRef = useRef<HTMLDialogElement>(null);
156
+ const histRef = useRef<HTMLDialogElement>(null);
157
+ const [shareDays, setShareDays] = useState(7);
158
+ const [shareUrl, setShareUrl] = useState('');
159
+ const [shareMsg, setShareMsg] = useState('');
160
+ const [versions, setVersions] = useState<VersionRow[] | null>(null);
161
+ const [tab, setTabState] = useState<TabKey>(tabFromHash);
162
+ const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
163
+ const setTab = (key: TabKey, focus = false) => {
164
+ setTabState(key);
165
+ history.replaceState(null, '', key === 'theme' ? location.pathname + location.search : `#${key}`);
166
+ if (focus) tabRefs.current[key]?.focus();
167
+ };
168
+ const onTabKey = (e: ReactKeyboardEvent) => {
169
+ const i = TABS.findIndex((t) => t.key === tab);
170
+ const to = e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowLeft' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? TABS.length - 1 : null;
171
+ if (to === null) return;
172
+ e.preventDefault();
173
+ setTab(TABS[(to + TABS.length) % TABS.length].key, true);
174
+ };
175
+ // keys load on first visit to Access, not on every Design page load
176
+ const accessSeen = useRef(false);
177
+ if (tab === 'access') accessSeen.current = true;
178
+ // shared GET /api/design for page layouts (Pages + Review)
179
+ const designApi = useDesignApi();
180
+ // staged page layouts count as a draft even if the change list omits them
181
+ const sectionsStaged = useMemo(() => {
182
+ const d = designApi.data;
183
+ if (!d?.draft?.sectionPages) return false;
184
+ return JSON.stringify(d.draft.sectionPages) !== JSON.stringify(d.live.sectionPages ?? {});
185
+ }, [designApi.data]);
186
+ const sectionsStagedRef = useRef(false);
187
+ sectionsStagedRef.current = sectionsStaged;
188
+
189
+ // the preview iframe reports where it navigated (preview-mode links keep the
190
+ // draft), so reloads after a change land on the same page
191
+ useEffect(() => {
192
+ const onMsg = (e: MessageEvent) => {
193
+ if (e.origin !== location.origin || e.data?.type !== 'acms-preview-nav') return;
194
+ pathRef.current = String(e.data.path || '/');
195
+ setPreviewPath(pathRef.current);
196
+ };
197
+ addEventListener('message', onMsg);
198
+ return () => removeEventListener('message', onMsg);
199
+ }, []);
99
200
  const frameRef = useRef<HTMLDivElement>(null);
100
201
  const [frameBox, setFrameBox] = useState({ w: 0, h: 0 });
101
202
  useEffect(() => {
@@ -112,23 +213,101 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
112
213
  latest.current = { activeTheme, overrides, siteLogo, siteFavicon, siteCover };
113
214
 
114
215
  const theme = themes.find((t) => t.slug === activeTheme) ?? themes[0];
216
+ const latestChanges = useRef(changes);
217
+ latestChanges.current = changes;
218
+
219
+ // saves stage into settings.draftDesign — the live site is untouched
220
+ const doSave = async () => {
221
+ const { activeTheme, overrides, siteLogo, siteFavicon, siteCover } = latest.current;
222
+ const { data, error } = await actions.settings.saveDesignDraft({
223
+ activeTheme,
224
+ designOverrides: overrides,
225
+ siteLogo,
226
+ siteFavicon,
227
+ siteCover,
228
+ });
229
+ if (error) { setSaveMessage(error.message); setSaveState('error'); return false; }
230
+ setSaveState('saved');
231
+ // edits that land back on the live design leave nothing staged
232
+ const next = (data?.changes ?? []) as DesignChange[];
233
+ setChanges(next);
234
+ setPending(next.length > 0);
235
+ setTick((t) => t + 1);
236
+ return true;
237
+ };
115
238
 
116
239
  const scheduleSave = () => {
117
240
  setSaveState('saving');
118
241
  if (timer.current) clearTimeout(timer.current);
119
- timer.current = setTimeout(async () => {
120
- const { activeTheme, overrides, siteLogo, siteFavicon, siteCover } = latest.current;
121
- const { error } = await actions.settings.update({
122
- activeTheme,
123
- designOverrides: overrides,
124
- siteLogo,
125
- siteFavicon,
126
- siteCover,
127
- });
128
- if (error) { setSaveState('error'); return; }
242
+ timer.current = setTimeout(doSave, 500);
243
+ };
244
+
245
+ // Publish design: flush any queued save into the staged blob, then apply it
246
+ const publishDesign = async () => {
247
+ if (timer.current) { clearTimeout(timer.current); timer.current = null; }
248
+ if (!(await doSave())) return;
249
+ const n = latestChanges.current.length;
250
+ if (!n && !sectionsStagedRef.current) return;
251
+ const what = n ? `${n} ${n === 1 ? 'change' : 'changes'}` : 'the staged page layouts';
252
+ if (!window.confirm(`Publish ${what}? Visitors see them right away. The current design is saved to History.`)) return;
253
+ const { error } = await actions.settings.applyDesign({});
254
+ if (error) { setSaveMessage(error.message); setSaveState('error'); return; }
255
+ setPending(false);
256
+ setChanges([]);
257
+ setVersions(null);
258
+ setTick((t) => t + 1);
259
+ void designApi.reload();
260
+ };
261
+
262
+ // undo one row of the change list: the server rewrites the draft, the
263
+ // controls rehydrate from it (or from the live design when nothing's left)
264
+ const revertChange = async (key: string) => {
265
+ if (timer.current) { clearTimeout(timer.current); timer.current = null; }
266
+ setSaveState('saving');
267
+ const { data, error } = await actions.settings.revertDesignChange({ key });
268
+ if (error) { setSaveMessage(error.message); setSaveState('error'); return; }
269
+ if (key === 'nav') {
270
+ setChanges(data.changes as DesignChange[]);
271
+ setPending(data.changes.length > 0);
129
272
  setSaveState('saved');
130
273
  setTick((t) => t + 1);
131
- }, 500);
274
+ return;
275
+ }
276
+ location.reload();
277
+ };
278
+
279
+ const openShare = () => { setShareUrl(''); setShareMsg(''); shareRef.current?.showModal(); };
280
+ const createShare = async () => {
281
+ if (timer.current) { clearTimeout(timer.current); timer.current = null; await doSave(); }
282
+ const { data, error } = await actions.settings.createShareLink({ days: shareDays });
283
+ if (error) { setShareMsg(error.message); return; }
284
+ setShareUrl(data.url);
285
+ try { await navigator.clipboard.writeText(data.url); setShareMsg('Link copied.'); } catch { setShareMsg(''); }
286
+ };
287
+ const revokeShares = async () => {
288
+ const { error } = await actions.settings.revokeShareLinks({});
289
+ setShareUrl('');
290
+ setShareMsg(error ? error.message : 'Every earlier share link has stopped working.');
291
+ };
292
+
293
+ const openHistory = async () => {
294
+ histRef.current?.showModal();
295
+ const { data } = await actions.settings.listDesignVersions({});
296
+ setVersions((data?.versions ?? []) as VersionRow[]);
297
+ };
298
+ const restoreVersion = async (id: string) => {
299
+ if (pending && !window.confirm('Replace your current draft with this version? Your unpublished changes will be lost.')) return;
300
+ const { error } = await actions.settings.restoreDesignVersion({ id });
301
+ if (error) { window.alert(error.message); return; }
302
+ location.reload();
303
+ };
304
+
305
+ const discardDesign = async () => {
306
+ if (!window.confirm('Discard the staged design changes and go back to the live design? This cannot be undone.')) return;
307
+ if (timer.current) { clearTimeout(timer.current); timer.current = null; }
308
+ const { error } = await actions.settings.discardDesign({});
309
+ if (error) { setSaveMessage(error.message); setSaveState('error'); return; }
310
+ location.reload(); // rehydrate the controls from the live design
132
311
  };
133
312
 
134
313
  const setOv = (key: string, value: unknown) => {
@@ -149,10 +328,13 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
149
328
 
150
329
  const copyThemeJson = async () => {
151
330
  const tokens: Record<string, string | number> = { ...theme.tokens };
152
- for (const f of [...COLOR_FIELDS.map((c) => c.key), 'width']) {
331
+ for (const f of [...COLOR_FIELDS.map((c) => c.key), 'width', ...LOOK_ROWS.map((r) => r.key)]) {
153
332
  const v = overrides[f];
154
333
  if (v !== undefined && v !== null && v !== '') tokens[f] = v as string | number;
155
334
  }
335
+ if (typeof overrides.darkAccent === 'string' && overrides.darkAccent) {
336
+ (tokens as Record<string, unknown>).dark = { ...(theme.tokens.dark ?? {}), accent: overrides.darkAccent };
337
+ }
156
338
  const pairing = pairings.find((p) => p.key === overrides.fontPairing);
157
339
  if (pairing) {
158
340
  tokens.headingFont = pairing.headingFont;
@@ -169,6 +351,14 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
169
351
  setTimeout(() => setCopied(false), 1800);
170
352
  };
171
353
 
354
+ // rebuilt only when something that should reload the preview changes —
355
+ // not when the iframe merely reports a new path
356
+ const iframeSrc = useMemo(() => {
357
+ const path = pathRef.current.split('?')[0] || '/';
358
+ return `${path}?designPreview=${compare === 'live' ? 'live' : '1'}&design=${tick}&scheme=${previewScheme}`;
359
+ // eslint-disable-next-line react-hooks/exhaustive-deps
360
+ }, [tick, compare, previewScheme, previewPage]);
361
+
172
362
  const overrideCount = Object.keys(overrides).filter((k) => overrides[k] !== undefined && overrides[k] !== '').length;
173
363
 
174
364
  // "you changed it · Reset to theme" hint line, per the plan mock
@@ -179,50 +369,79 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
179
369
  </>
180
370
  );
181
371
 
372
+ const hasDraft = (pending && changes.length > 0) || sectionsStaged;
373
+
182
374
  return (
183
375
  <div className="dm-layout">
184
376
  <div className="dm-controls">
185
377
  <div className="card card-pad">
186
378
  <div className="dm-cardhead">
187
379
  <p className="dm-sub">
188
- Active theme: <b>{theme.name}</b> · loaded from <code>src/themes/{theme.slug}/</code> —
189
- values below override it. Overrides apply instantly.
380
+ Active theme: <b>{theme.name}</b> · loaded from <code>src/themes/{theme.slug}/</code>. Changes stay in a
381
+ private draft until you publish; visitors keep the live design.
190
382
  </p>
191
383
  <div className="dm-headactions">
192
- {overrideCount > 0 && (
193
- <button className="btn sm ghost" type="button" onClick={() => { setOverrides({}); scheduleSave(); }}>
194
- Reset all
195
- </button>
196
- )}
197
384
  <span className={`lm-savestate ${saveState}`}>
198
- {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : 'Saved'}
385
+ {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? (saveMessage || 'Save failed') : null}
199
386
  </span>
387
+ <button className="btn sm ghost" type="button" onClick={() => void openHistory()}>History</button>
200
388
  </div>
201
389
  </div>
202
390
 
203
- <div className="dm-themes">
204
- {themes.map((t) => (
391
+ <div className="dm-status">
392
+ <div className="dm-status-line">
393
+ {hasDraft ? (
394
+ <><span className="dm-st is-draft">Draft</span><span className="dm-st-sub">· {changes.length > 0 ? `${changes.length} ${changes.length === 1 ? 'change' : 'changes'}` : 'page layouts'}, not live</span></>
395
+ ) : (
396
+ <><span className="dm-st is-live">Live</span><span className="dm-st-sub">· no unpublished changes</span></>
397
+ )}
398
+ <span className="dm-status-actions">
399
+ {overrideCount > 0 && (
400
+ <button className="btn sm ghost" type="button" onClick={() => { setOverrides({}); scheduleSave(); }}>Reset all</button>
401
+ )}
402
+ {hasDraft && (
403
+ <>
404
+ <button className="btn sm ghost" type="button" onClick={discardDesign}>Discard</button>
405
+ <button className="btn sm primary" type="button" onClick={() => void publishDesign()}>Publish…</button>
406
+ </>
407
+ )}
408
+ </span>
409
+ </div>
410
+ <div className="dm-status-links">
411
+ {hasDraft && tab !== 'review' && (
412
+ <button className="btn sm" type="button" onClick={() => setTab('review')}>Review changes</button>
413
+ )}
414
+ <a className="btn sm" href="/?designPreview=1" target="_blank" rel="noopener">
415
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" /></svg>
416
+ Preview site
417
+ </a>
418
+ <button className="btn sm" type="button" onClick={openShare} disabled={!hasDraft} title={hasDraft ? undefined : 'Make a change first'}>
419
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1.5 1.5M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1.5-1.5" /></svg>
420
+ Share preview
421
+ </button>
422
+ </div>
423
+ </div>
424
+
425
+ <div className="seg te-tabs" role="tablist" aria-label="Design settings" onKeyDown={onTabKey}>
426
+ {TABS.map((t) => (
205
427
  <button
206
- key={t.slug}
428
+ key={t.key}
429
+ ref={(el) => { tabRefs.current[t.key] = el; }}
207
430
  type="button"
208
- className={`dm-theme ${t.slug === activeTheme ? 'active' : ''}`}
209
- title={t.description}
210
- onClick={() => pickTheme(t.slug)}
431
+ role="tab"
432
+ id={`dm-tab-${t.key}`}
433
+ aria-selected={tab === t.key}
434
+ aria-controls={`dm-panel-${t.key}`}
435
+ tabIndex={tab === t.key ? 0 : -1}
436
+ onClick={() => setTab(t.key)}
211
437
  >
212
- <span
213
- className="dm-swatch"
214
- style={{ background: String(t.tokens.bg), borderColor: String(t.tokens.line) }}
215
- >
216
- <span style={{ color: String(t.tokens.ink), fontFamily: String(t.tokens.headingFont) }}>Aa</span>
217
- <i style={{ background: String(t.tokens.accent) }} />
218
- </span>
219
- <span className="dm-theme-name">{t.name}</span>
220
- <span className="dm-theme-desc">{t.description.split(' — ')[0]}</span>
221
- {t.hasHome && <span className="dm-chip">coded homepage</span>}
438
+ {t.label}
439
+ {t.key === 'review' && pending && changes.length > 0 && <span className="te-tab-count" aria-label={`${changes.length} staged`}>{changes.length}</span>}
222
440
  </button>
223
441
  ))}
224
442
  </div>
225
443
 
444
+ <div className="te-panel" role="tabpanel" id="dm-panel-brand" aria-labelledby="dm-tab-brand" hidden={tab !== 'brand'}>
226
445
  <div className="dm-row">
227
446
  <div className="dm-lab">
228
447
  <span>Logo</span>
@@ -324,6 +543,33 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
324
543
  </div>
325
544
  );
326
545
  })}
546
+ </div>
547
+
548
+ <div className="te-panel" role="tabpanel" id="dm-panel-theme" aria-labelledby="dm-tab-theme" hidden={tab !== 'theme'}>
549
+ <div className="dm-themes">
550
+ {themes.map((t) => (
551
+ <button
552
+ key={t.slug}
553
+ type="button"
554
+ className={`dm-theme ${t.slug === activeTheme ? 'active' : ''}`}
555
+ title={t.description}
556
+ aria-pressed={t.slug === activeTheme}
557
+ onClick={() => pickTheme(t.slug)}
558
+ >
559
+ <span
560
+ className="dm-swatch"
561
+ style={{ background: String(t.tokens.bg), borderColor: String(t.tokens.line) }}
562
+ >
563
+ <span style={{ color: String(t.tokens.ink), fontFamily: String(t.tokens.headingFont) }}>Aa</span>
564
+ <i style={{ background: String(t.tokens.accent) }} />
565
+ </span>
566
+ <span className="dm-theme-name">{t.name}</span>
567
+ <span className="dm-theme-desc">{t.description.split(' — ')[0]}</span>
568
+ <span className="dm-chip">v{t.version} · {t.sectionCount} sections</span>
569
+ <span className="dm-theme-desc">{t.templateCount} custom templates · installed</span>
570
+ </button>
571
+ ))}
572
+ </div>
327
573
 
328
574
  <div className="dm-row">
329
575
  <div className="dm-lab">
@@ -331,7 +577,7 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
331
577
  <small>
332
578
  {overrides.width !== undefined
333
579
  ? overriddenHint(`${Number(theme.tokens.width)}px`, 'width')
334
- : `Desktop max width of the whole layout — theme default is ${Number(theme.tokens.width)}px`}
580
+ : `Reading column width for posts and pages; theme default is ${Number(theme.tokens.width)}px`}
335
581
  </small>
336
582
  </div>
337
583
  <div className="dm-ctl">
@@ -382,6 +628,53 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
382
628
  </div>
383
629
  )}
384
630
 
631
+ <p className="card-title dm-group">Look</p>
632
+ <p className="dm-grouphint">
633
+ Shape, depth and layout. Every theme has defaults; changes here apply on top of it.
634
+ </p>
635
+
636
+ {LOOK_ROWS.map((r) => (
637
+ <div className="dm-row" key={r.key}>
638
+ <div className="dm-lab">
639
+ <span>{r.label}{overrides[r.key] !== undefined && <em className="dm-ovr">override</em>}</span>
640
+ <small>{r.hint}</small>
641
+ </div>
642
+ <div className="dm-ctl">
643
+ <select
644
+ className="lm-select"
645
+ value={String(overrides[r.key] ?? '')}
646
+ onChange={(e) => (e.target.value ? setOv(r.key, e.target.value) : clearOv(r.key))}
647
+ >
648
+ <option value="">Theme default ({r.options.find(([v]) => v === String(theme.tokens[r.key]))?.[1] ?? String(theme.tokens[r.key] ?? '')})</option>
649
+ {r.options.map(([v, label]) => <option key={v} value={v}>{label}</option>)}
650
+ </select>
651
+ </div>
652
+ </div>
653
+ ))}
654
+
655
+ {String(effective('scheme')) !== 'light' && (
656
+ <div className="dm-row">
657
+ <div className="dm-lab">
658
+ <span>Dark accent{overrides.darkAccent !== undefined && <em className="dm-ovr">override</em>}</span>
659
+ <small>
660
+ {theme.tokens.dark && Object.keys(theme.tokens.dark).length
661
+ ? 'Accent used in dark mode, the theme ships its own dark palette'
662
+ : 'This theme generates its dark palette; check it in the preview'}
663
+ </small>
664
+ </div>
665
+ <div className="dm-ctl">
666
+ <input
667
+ type="color"
668
+ value={String(overrides.darkAccent ?? theme.tokens.dark?.accent ?? '#7d93ff')}
669
+ onChange={(e) => setOv('darkAccent', e.target.value)}
670
+ />
671
+ {overrides.darkAccent !== undefined && (
672
+ <button className="btn sm ghost" type="button" onClick={() => clearOv('darkAccent')}>Reset</button>
673
+ )}
674
+ </div>
675
+ </div>
676
+ )}
677
+
385
678
  <p className="card-title dm-group">Posts</p>
386
679
  <p className="dm-grouphint">
387
680
  How individual posts render. The theme sets the defaults; anything you change here overrides it site-wide.
@@ -438,6 +731,49 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
438
731
  </button>
439
732
  </div>
440
733
  </div>
734
+ </div>
735
+
736
+ <div className="te-panel" role="tabpanel" id="dm-panel-pages" aria-labelledby="dm-tab-pages" hidden={tab !== 'pages'}>
737
+ <ThemeSectionsEditor api={designApi} activeTheme={activeTheme} unsplashEnabled={unsplashEnabled} />
738
+ </div>
739
+
740
+ <div className="te-panel" role="tabpanel" id="dm-panel-review" aria-labelledby="dm-tab-review" hidden={tab !== 'review'}>
741
+ <p className="card-title dm-group te-first">Staged changes</p>
742
+ {pending && changes.length > 0 ? (
743
+ <ul className="dm-changes">
744
+ {changes.map((c) => (
745
+ <li key={c.key}>
746
+ <span className="dm-ch-label">{c.label}</span>
747
+ <span className="dm-ch-val">
748
+ {c.kind === 'color' ? (
749
+ <><i className="dm-ch-sw" style={{ background: c.from || 'transparent' }} title={c.from || 'Theme default'} />→<i className="dm-ch-sw" style={{ background: c.to || 'transparent' }} title={c.to || 'Theme default'} /></>
750
+ ) : c.kind === 'image' ? (
751
+ <>{c.from ? 'Changed' : 'Added'}{!c.to && ' (removed)'}</>
752
+ ) : c.kind === 'nav' ? (
753
+ <>{c.to}</>
754
+ ) : (
755
+ <>{c.from} → <b>{c.to}</b></>
756
+ )}
757
+ </span>
758
+ <button type="button" className="dm-ch-undo" onClick={() => void revertChange(c.key)} aria-label={`Undo ${c.label}`}>Undo</button>
759
+ </li>
760
+ ))}
761
+ </ul>
762
+ ) : (
763
+ <p className="te-muted">
764
+ {hasDraft ? 'No theme or brand changes staged.' : 'Nothing staged. The live design is what visitors see.'}
765
+ </p>
766
+ )}
767
+ <p className="card-title dm-group">Page layouts</p>
768
+ <SectionsReview api={designApi} />
769
+ <p className="dm-grouphint te-reviewhint">
770
+ Publish from the bar above to make every staged change live at once, or share a preview link first.
771
+ </p>
772
+ </div>
773
+
774
+ <div className="te-panel" role="tabpanel" id="dm-panel-access" aria-labelledby="dm-tab-access" hidden={tab !== 'access'}>
775
+ {accessSeen.current && <DesignAccess />}
776
+ </div>
441
777
  </div>
442
778
  </div>
443
779
 
@@ -459,19 +795,29 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
459
795
  </button>
460
796
  ))}
461
797
  </div>
798
+ <div className="dm-ptabs" title="Compare with the live design (D in the full preview)">
799
+ <button type="button" className={compare === 'live' ? 'active' : ''} onClick={() => setCompare('live')}>Live</button>
800
+ <button type="button" className={compare === 'draft' ? 'active' : ''} onClick={() => setCompare('draft')}>Draft</button>
801
+ </div>
802
+ {String(effective('scheme')) === 'auto' && (
803
+ <div className="dm-ptabs">
804
+ <button type="button" className={previewScheme === 'light' ? 'active' : ''} onClick={() => setPreviewScheme('light')}>Light</button>
805
+ <button type="button" className={previewScheme === 'dark' ? 'active' : ''} onClick={() => setPreviewScheme('dark')}>Dark</button>
806
+ </div>
807
+ )}
462
808
  {postPreviewPath && (
463
809
  <div className="dm-ptabs">
464
810
  <button
465
811
  type="button"
466
812
  className={previewPage === 'home' ? 'active' : ''}
467
- onClick={() => setPreviewPage('home')}
813
+ onClick={() => { pathRef.current = '/'; setPreviewPath('/'); setPreviewPage('home'); }}
468
814
  >
469
815
  Home
470
816
  </button>
471
817
  <button
472
818
  type="button"
473
819
  className={previewPage === 'post' ? 'active' : ''}
474
- onClick={() => setPreviewPage('post')}
820
+ onClick={() => { pathRef.current = postPreviewPath ?? "/"; setPreviewPath(postPreviewPath ?? "/"); setPreviewPage("post"); }}
475
821
  >
476
822
  Post
477
823
  </button>
@@ -479,11 +825,12 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
479
825
  )}
480
826
  <a
481
827
  className="btn sm ghost"
482
- href={previewPage === 'post' && postPreviewPath ? postPreviewPath : '/'}
828
+ href={`${previewPath.split('?')[0]}?designPreview=1`}
483
829
  target="_blank"
484
830
  rel="noopener"
831
+ title={`Open ${previewPath} in preview mode`}
485
832
  >
486
- Open ↗
833
+ Open draft ↗
487
834
  </a>
488
835
  </div>
489
836
  </div>
@@ -493,8 +840,8 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
493
840
  return (
494
841
  <div className="dm-frame" ref={frameRef}>
495
842
  <iframe
496
- key={`${previewPage}-${tick}`}
497
- src={`${previewPage === 'post' && postPreviewPath ? postPreviewPath : '/'}?design=${tick}`}
843
+ key={iframeSrc}
844
+ src={iframeSrc}
498
845
  title="Site preview"
499
846
  style={{
500
847
  width: device.width,
@@ -507,6 +854,67 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
507
854
  })()}
508
855
  </div>
509
856
 
857
+ <dialog className="modal dm-dialog" ref={shareRef} onClick={(e) => { if (e.target === e.currentTarget) shareRef.current?.close(); }}>
858
+ <div className="modal-head">
859
+ <span>Share a preview</span>
860
+ <button className="modal-x" type="button" onClick={() => shareRef.current?.close()} aria-label="Close">✕</button>
861
+ </div>
862
+ <div className="modal-body">
863
+ <p className="dm-dialog-sub">Anyone with the link can browse the site with your draft design. They can't edit or publish, and the link stops working when you publish or discard the draft.</p>
864
+ {shareUrl ? (
865
+ <div className="dm-share-url">
866
+ <input readOnly value={shareUrl} onFocus={(e) => e.currentTarget.select()} aria-label="Preview link" />
867
+ <button className="btn sm primary" type="button" onClick={() => void navigator.clipboard.writeText(shareUrl).then(() => setShareMsg('Link copied.'))}>Copy</button>
868
+ </div>
869
+ ) : (
870
+ <div className="dm-share-row">
871
+ <label>Expires
872
+ <select className="lm-select" value={shareDays} onChange={(e) => setShareDays(Number(e.target.value))}>
873
+ <option value={1}>In 24 hours</option>
874
+ <option value={7}>In 7 days</option>
875
+ <option value={30}>In 30 days</option>
876
+ </select>
877
+ </label>
878
+ <button className="btn sm primary" type="button" onClick={() => void createShare()}>Create link</button>
879
+ </div>
880
+ )}
881
+ {shareMsg && <p className="dm-dialog-msg">{shareMsg}</p>}
882
+ <div className="dm-share-foot">
883
+ <button className="btn sm ghost dm-danger" type="button" onClick={() => void revokeShares()}>Revoke all links</button>
884
+ </div>
885
+ </div>
886
+ </dialog>
887
+
888
+ <dialog className="modal dm-dialog" ref={histRef} onClick={(e) => { if (e.target === e.currentTarget) histRef.current?.close(); }}>
889
+ <div className="modal-head">
890
+ <span>Design history</span>
891
+ <button className="modal-x" type="button" onClick={() => histRef.current?.close()} aria-label="Close">✕</button>
892
+ </div>
893
+ <div className="modal-body">
894
+ <p className="dm-dialog-sub">Every publish saves the design it replaced. Restoring loads a version as your draft, so you can preview it before publishing.</p>
895
+ {versions === null ? (
896
+ <p className="dm-dialog-msg">Loading…</p>
897
+ ) : versions.length === 0 ? (
898
+ <p className="dm-dialog-msg">No earlier versions yet. The first publish creates one.</p>
899
+ ) : (
900
+ <ul className="dm-hist">
901
+ {versions.map((v) => (
902
+ <li key={v.id}>
903
+ <div>
904
+ <b>{v.theme}{v.summary ? ` · replaced by: ${v.summary}` : ''}</b>
905
+ <small>Saved {when(v.createdAt)}{v.author ? ` by ${v.author}` : ''}</small>
906
+ </div>
907
+ <span className="dm-hist-actions">
908
+ <a className="btn sm ghost" href={`/?designVersion=${v.id}`} target="_blank" rel="noopener">Preview</a>
909
+ <button className="btn sm" type="button" onClick={() => void restoreVersion(v.id)}>Restore as draft</button>
910
+ </span>
911
+ </li>
912
+ ))}
913
+ </ul>
914
+ )}
915
+ </div>
916
+ </dialog>
917
+
510
918
  <ImagePicker
511
919
  open={logoPickerOpen}
512
920
  unsplashEnabled={unsplashEnabled}