@acms-subir/core 0.2.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 (230) hide show
  1. package/CHANGELOG.md +78 -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 -369
  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 +182 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +27 -5
  19. package/src/actions/index.ts +575 -47
  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 +418 -46
  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 +114 -57
  30. package/src/components/NavManager.tsx +42 -43
  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 +605 -223
  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/events.tsx +153 -0
  45. package/src/components/editor/extensions/slash.tsx +7 -1
  46. package/src/components/site/Pager.astro +24 -0
  47. package/src/components/site/PostCard.astro +64 -0
  48. package/src/components/site/TagLabel.astro +11 -0
  49. package/src/layouts/Admin.astro +289 -49
  50. package/src/layouts/Site.astro +324 -59
  51. package/src/lib/agent-api.ts +13 -0
  52. package/src/lib/agent-discovery.ts +40 -0
  53. package/src/lib/agent.ts +114 -0
  54. package/src/lib/ai-config.ts +58 -0
  55. package/src/lib/ai-schema.ts +22 -0
  56. package/src/lib/ai.ts +90 -0
  57. package/src/lib/api-keys.ts +65 -0
  58. package/src/lib/api.ts +52 -0
  59. package/src/lib/auth.ts +25 -5
  60. package/src/lib/backup/content.ts +36 -0
  61. package/src/lib/backup/unzip.ts +12 -1
  62. package/src/lib/backup/zip.ts +1 -1
  63. package/src/lib/booking/agent.ts +108 -0
  64. package/src/lib/booking/availability.ts +221 -0
  65. package/src/lib/booking/emails.ts +115 -0
  66. package/src/lib/booking/forms.ts +77 -0
  67. package/src/lib/booking/service.ts +277 -0
  68. package/src/lib/booking/view.ts +54 -0
  69. package/src/lib/calendar/agent-schema.ts +48 -0
  70. package/src/lib/calendar/agent.ts +136 -0
  71. package/src/lib/calendar/api.ts +81 -0
  72. package/src/lib/calendar/cards.ts +102 -0
  73. package/src/lib/calendar/config.ts +52 -0
  74. package/src/lib/calendar/data.ts +130 -0
  75. package/src/lib/calendar/events.ts +261 -0
  76. package/src/lib/calendar/feeds.ts +43 -0
  77. package/src/lib/calendar/hooks.ts +38 -0
  78. package/src/lib/calendar/http.ts +30 -0
  79. package/src/lib/calendar/ics-write.ts +120 -0
  80. package/src/lib/calendar/ics.ts +15 -5
  81. package/src/lib/calendar/import.ts +203 -0
  82. package/src/lib/calendar/render.ts +120 -37
  83. package/src/lib/calendar/rrule.ts +149 -0
  84. package/src/lib/calendar/seo.ts +48 -0
  85. package/src/lib/design-service.ts +58 -0
  86. package/src/lib/designDiff.ts +145 -0
  87. package/src/lib/designPreview.ts +110 -12
  88. package/src/lib/documents/cleanup.ts +31 -0
  89. package/src/lib/documents/extract.ts +154 -0
  90. package/src/lib/documents/vendor.d.ts +4 -0
  91. package/src/lib/drafts.ts +60 -6
  92. package/src/lib/dynamicBlocks.ts +40 -8
  93. package/src/lib/email/accounts.ts +55 -0
  94. package/src/lib/email/config.ts +86 -0
  95. package/src/lib/email/migration.ts +24 -0
  96. package/src/lib/email/providers.ts +89 -0
  97. package/src/lib/email/service.ts +78 -0
  98. package/src/lib/email/store.ts +167 -0
  99. package/src/lib/email/templates.ts +18 -0
  100. package/src/lib/email/types.ts +32 -0
  101. package/src/lib/email/webhooks.ts +87 -0
  102. package/src/lib/email.ts +15 -0
  103. package/src/lib/env.ts +1 -1
  104. package/src/lib/legal/index.ts +25 -0
  105. package/src/lib/mobile/bookings.ts +73 -0
  106. package/src/lib/mobile/calendar.ts +85 -0
  107. package/src/lib/mobile/content.ts +85 -0
  108. package/src/lib/mobile/http.ts +63 -0
  109. package/src/lib/mobile/route.ts +20 -0
  110. package/src/lib/ops.ts +8 -0
  111. package/src/lib/preview.ts +21 -1
  112. package/src/lib/repo/migrations.ts +217 -0
  113. package/src/lib/repo/sql.ts +853 -60
  114. package/src/lib/repo/types.ts +122 -5
  115. package/src/lib/scheduler.ts +25 -0
  116. package/src/lib/schema.ts +309 -0
  117. package/src/lib/search-i18n.ts +3 -0
  118. package/src/lib/socialIcons.ts +32 -0
  119. package/src/lib/tagColor.ts +11 -0
  120. package/src/lib/theme.ts +59 -40
  121. package/src/lib/themeTokens.ts +174 -0
  122. package/src/lib/umami.ts +108 -0
  123. package/src/lib/webhooks.ts +139 -0
  124. package/src/middleware.ts +36 -3
  125. package/src/pages/404.astro +23 -0
  126. package/src/pages/[...slug].astro +8 -5
  127. package/src/pages/account/[action].astro +53 -0
  128. package/src/pages/admin/ai.astro +12 -0
  129. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  130. package/src/pages/admin/c/[collection]/index.astro +127 -92
  131. package/src/pages/admin/calendar/bookings.astro +400 -0
  132. package/src/pages/admin/calendar/calendars.astro +265 -0
  133. package/src/pages/admin/calendar/events/[id].astro +56 -0
  134. package/src/pages/admin/calendar/events/index.astro +408 -0
  135. package/src/pages/admin/calendar/index.ts +4 -0
  136. package/src/pages/admin/calendar/integrations.astro +221 -0
  137. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  138. package/src/pages/admin/calendar/resources/index.astro +106 -0
  139. package/src/pages/admin/calendar/resources/link.astro +196 -0
  140. package/src/pages/admin/design.astro +10 -3
  141. package/src/pages/admin/email.astro +13 -0
  142. package/src/pages/admin/index.astro +232 -85
  143. package/src/pages/admin/links.astro +3 -3
  144. package/src/pages/admin/login.astro +14 -10
  145. package/src/pages/admin/navigation.astro +8 -2
  146. package/src/pages/admin/pages/[id].astro +4 -1
  147. package/src/pages/admin/pages/index.astro +41 -48
  148. package/src/pages/admin/settings.astro +418 -367
  149. package/src/pages/admin/tags.astro +4 -4
  150. package/src/pages/admin/users.astro +38 -28
  151. package/src/pages/api/agent/docs.ts +33 -0
  152. package/src/pages/api/agent/index.ts +21 -0
  153. package/src/pages/api/ai/clean.ts +13 -0
  154. package/src/pages/api/ai/draft.ts +14 -0
  155. package/src/pages/api/ai/settings.ts +53 -0
  156. package/src/pages/api/ai/test.ts +12 -0
  157. package/src/pages/api/backup.ts +8 -21
  158. package/src/pages/api/calendar/availability.ts +35 -0
  159. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  160. package/src/pages/api/calendar/bookings/index.ts +73 -0
  161. package/src/pages/api/calendar/calendars.ts +35 -0
  162. package/src/pages/api/calendar/docs.ts +64 -0
  163. package/src/pages/api/calendar/events/[id].ts +63 -0
  164. package/src/pages/api/calendar/events/index.ts +112 -0
  165. package/src/pages/api/calendar/resources.ts +34 -0
  166. package/src/pages/api/design/capabilities.ts +10 -0
  167. package/src/pages/api/design/index.ts +58 -0
  168. package/src/pages/api/design/keys.ts +24 -0
  169. package/src/pages/api/design/media.ts +32 -0
  170. package/src/pages/api/email/run.ts +11 -0
  171. package/src/pages/api/email/settings.ts +61 -0
  172. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  173. package/src/pages/api/mcp.ts +59 -0
  174. package/src/pages/api/media/upload.ts +2 -2
  175. package/src/pages/api/ops/status.ts +39 -0
  176. package/src/pages/api/restore.ts +27 -37
  177. package/src/pages/api/search.ts +18 -4
  178. package/src/pages/api/v1/availability.ts +7 -0
  179. package/src/pages/api/v1/bookings.ts +17 -0
  180. package/src/pages/api/v1/calendars.ts +7 -0
  181. package/src/pages/api/v1/content/[id].ts +7 -0
  182. package/src/pages/api/v1/content/index.ts +7 -0
  183. package/src/pages/api/v1/events.ts +7 -0
  184. package/src/pages/api/v1/resources.ts +7 -0
  185. package/src/pages/author/[slug].astro +43 -35
  186. package/src/pages/blog/[slug].astro +137 -66
  187. package/src/pages/blog/index.astro +95 -30
  188. package/src/pages/blog/tag/[slug].astro +45 -40
  189. package/src/pages/book/[resource]/[link].astro +70 -0
  190. package/src/pages/book/[resource]/index.astro +63 -0
  191. package/src/pages/book/[resource].ics.ts +38 -0
  192. package/src/pages/book/index.astro +47 -0
  193. package/src/pages/book/manage/[token].astro +108 -0
  194. package/src/pages/calendar/[slug].ics.ts +27 -0
  195. package/src/pages/calendar/feed.ics.ts +15 -0
  196. package/src/pages/calendar/index.astro +191 -0
  197. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  198. package/src/pages/embed/calendar/[slug].astro +43 -0
  199. package/src/pages/events/[slug].astro +74 -0
  200. package/src/pages/events/[slug].ics.ts +25 -0
  201. package/src/pages/index.astro +6 -3
  202. package/src/pages/legal/privacy.astro +4 -0
  203. package/src/pages/legal/terms.astro +4 -0
  204. package/src/pages/links.astro +23 -3
  205. package/src/pages/llms.txt.ts +4 -0
  206. package/src/pages/preview/[type]/[id].astro +27 -6
  207. package/src/pages/search.astro +40 -20
  208. package/src/pages/sitemap.xml.ts +16 -1
  209. package/src/styles/admin.css +924 -105
  210. package/src/styles/cards.css +92 -74
  211. package/src/styles/editor.css +492 -114
  212. package/src/styles/email-settings.css +117 -0
  213. package/src/styles/sections.css +12 -0
  214. package/src/styles/site.css +938 -281
  215. package/src/styles/theme-editor.css +135 -0
  216. package/src/theme-sdk/contract.d.mts +619 -0
  217. package/src/theme-sdk/contract.mjs +80 -0
  218. package/src/theme-sdk/index.ts +15 -0
  219. package/src/theme-sdk/tokens.d.mts +148 -0
  220. package/src/theme-sdk/tokens.mjs +52 -0
  221. package/src/themes/editorial/templates/home.astro +4 -4
  222. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  223. package/src/themes/editorial/theme.css +22 -37
  224. package/src/themes/editorial/theme.json +23 -3
  225. package/src/themes/field/theme.json +26 -9
  226. package/src/themes/ink/theme.json +30 -12
  227. package/src/themes/night/theme.css +4 -7
  228. package/src/themes/night/theme.json +27 -10
  229. package/src/virtual.d.ts +14 -0
  230. package/src/worker.ts +5 -1
@@ -1,7 +1,11 @@
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
@@ -18,9 +22,12 @@ interface ThemeInfo {
18
22
  slug: string;
19
23
  name: string;
20
24
  description: string;
21
- tokens: Record<string, string | number>;
25
+ tokens: Record<string, string | number> & { dark?: Record<string, string> };
22
26
  post: Record<string, string | boolean>;
23
27
  hasHome: boolean;
28
+ version: string;
29
+ templateCount: number;
30
+ sectionCount: number;
24
31
  }
25
32
 
26
33
  // post-page toggles: designOverrides key + which theme default backs it
@@ -31,6 +38,20 @@ const POST_TOGGLE_ROWS: { key: string; field: string; label: string; hint: strin
31
38
  { key: 'postAuthorCard', field: 'authorCard', label: 'Author card', hint: 'Avatar + bio block under the post' },
32
39
  { key: 'postShare', field: 'share', label: 'Share buttons', hint: 'X, LinkedIn and copy-link' },
33
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']] },
34
55
  ];
35
56
  const COVER_LABEL: Record<string, string> = { normal: 'Content width', full: 'Full bleed', overlay: 'Overlay (title on image)', hidden: 'Hidden' };
36
57
  interface Pairing { key: string; label: string; headingFont: string; bodyFont: string; googleFonts: string }
@@ -44,8 +65,14 @@ interface Props {
44
65
  unsplashEnabled: boolean;
45
66
  /** public path of the newest published post, or null when nothing is published */
46
67
  postPreviewPath: string | null;
68
+ /** staged draft vs live, computed server-side at load */
69
+ changes: DesignChange[];
47
70
  }
48
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
+
49
76
  const COLOR_FIELDS: { key: string; label: string; hint: string }[] = [
50
77
  { key: 'bg', label: 'Background', hint: 'Page background' },
51
78
  { key: 'ink', label: 'Text', hint: 'Body and heading text' },
@@ -53,7 +80,7 @@ const COLOR_FIELDS: { key: string; label: string; hint: string }[] = [
53
80
  { key: 'muted', label: 'Muted text', hint: 'Bylines, captions, metadata' },
54
81
  { key: 'line', label: 'Borders', hint: 'Dividers and card borders' },
55
82
  { key: 'panel', label: 'Panels', hint: 'Cards and link buttons' },
56
- { 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' },
57
84
  ];
58
85
  const WIDTHS = [
59
86
  { label: 'Narrow', px: 640 },
@@ -86,7 +113,22 @@ const DEVICES = [
86
113
  ] as const;
87
114
  type DeviceKey = (typeof DEVICES)[number]['key'];
88
115
 
89
- export default function DesignManager({ settings, pendingDesign, 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) {
90
132
  const [pending, setPending] = useState(pendingDesign);
91
133
  const [activeTheme, setActiveTheme] = useState(settings.activeTheme || 'ink');
92
134
  const [overrides, setOverrides] = useState<Record<string, unknown>>(
@@ -96,6 +138,7 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
96
138
  const [siteFavicon, setSiteFavicon] = useState(settings.siteFavicon);
97
139
  const [siteCover, setSiteCover] = useState(settings.siteCover);
98
140
  const [saveState, setSaveState] = useState<SaveState>('saved');
141
+ const [saveMessage, setSaveMessage] = useState('');
99
142
  const [logoPickerOpen, setLogoPickerOpen] = useState(false);
100
143
  const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
101
144
  const [coverPickerOpen, setCoverPickerOpen] = useState(false);
@@ -103,6 +146,57 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
103
146
  const [tick, setTick] = useState(0);
104
147
  const [previewPage, setPreviewPage] = useState<'home' | 'post'>('home');
105
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
+ }, []);
106
200
  const frameRef = useRef<HTMLDivElement>(null);
107
201
  const [frameBox, setFrameBox] = useState({ w: 0, h: 0 });
108
202
  useEffect(() => {
@@ -119,20 +213,25 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
119
213
  latest.current = { activeTheme, overrides, siteLogo, siteFavicon, siteCover };
120
214
 
121
215
  const theme = themes.find((t) => t.slug === activeTheme) ?? themes[0];
216
+ const latestChanges = useRef(changes);
217
+ latestChanges.current = changes;
122
218
 
123
219
  // saves stage into settings.draftDesign — the live site is untouched
124
220
  const doSave = async () => {
125
221
  const { activeTheme, overrides, siteLogo, siteFavicon, siteCover } = latest.current;
126
- const { error } = await actions.settings.saveDesignDraft({
222
+ const { data, error } = await actions.settings.saveDesignDraft({
127
223
  activeTheme,
128
224
  designOverrides: overrides,
129
225
  siteLogo,
130
226
  siteFavicon,
131
227
  siteCover,
132
228
  });
133
- if (error) { setSaveState('error'); return false; }
229
+ if (error) { setSaveMessage(error.message); setSaveState('error'); return false; }
134
230
  setSaveState('saved');
135
- setPending(true);
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);
136
235
  setTick((t) => t + 1);
137
236
  return true;
138
237
  };
@@ -147,17 +246,67 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
147
246
  const publishDesign = async () => {
148
247
  if (timer.current) { clearTimeout(timer.current); timer.current = null; }
149
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;
150
253
  const { error } = await actions.settings.applyDesign({});
151
- if (error) { setSaveState('error'); return; }
254
+ if (error) { setSaveMessage(error.message); setSaveState('error'); return; }
152
255
  setPending(false);
256
+ setChanges([]);
257
+ setVersions(null);
153
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);
272
+ setSaveState('saved');
273
+ setTick((t) => t + 1);
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();
154
303
  };
155
304
 
156
305
  const discardDesign = async () => {
157
306
  if (!window.confirm('Discard the staged design changes and go back to the live design? This cannot be undone.')) return;
158
307
  if (timer.current) { clearTimeout(timer.current); timer.current = null; }
159
308
  const { error } = await actions.settings.discardDesign({});
160
- if (error) { setSaveState('error'); return; }
309
+ if (error) { setSaveMessage(error.message); setSaveState('error'); return; }
161
310
  location.reload(); // rehydrate the controls from the live design
162
311
  };
163
312
 
@@ -179,10 +328,13 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
179
328
 
180
329
  const copyThemeJson = async () => {
181
330
  const tokens: Record<string, string | number> = { ...theme.tokens };
182
- 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)]) {
183
332
  const v = overrides[f];
184
333
  if (v !== undefined && v !== null && v !== '') tokens[f] = v as string | number;
185
334
  }
335
+ if (typeof overrides.darkAccent === 'string' && overrides.darkAccent) {
336
+ (tokens as Record<string, unknown>).dark = { ...(theme.tokens.dark ?? {}), accent: overrides.darkAccent };
337
+ }
186
338
  const pairing = pairings.find((p) => p.key === overrides.fontPairing);
187
339
  if (pairing) {
188
340
  tokens.headingFont = pairing.headingFont;
@@ -199,6 +351,14 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
199
351
  setTimeout(() => setCopied(false), 1800);
200
352
  };
201
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
+
202
362
  const overrideCount = Object.keys(overrides).filter((k) => overrides[k] !== undefined && overrides[k] !== '').length;
203
363
 
204
364
  // "you changed it · Reset to theme" hint line, per the plan mock
@@ -209,56 +369,79 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
209
369
  </>
210
370
  );
211
371
 
372
+ const hasDraft = (pending && changes.length > 0) || sectionsStaged;
373
+
212
374
  return (
213
375
  <div className="dm-layout">
214
376
  <div className="dm-controls">
215
377
  <div className="card card-pad">
216
378
  <div className="dm-cardhead">
217
379
  <p className="dm-sub">
218
- Active theme: <b>{theme.name}</b> · loaded from <code>src/themes/{theme.slug}/</code> —
219
- values below override it. Changes stage privately in the preview; press <b>Publish design</b> to go live.
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.
220
382
  </p>
221
383
  <div className="dm-headactions">
222
- {overrideCount > 0 && (
223
- <button className="btn sm ghost" type="button" onClick={() => { setOverrides({}); scheduleSave(); }}>
224
- Reset all
225
- </button>
226
- )}
227
384
  <span className={`lm-savestate ${saveState}`}>
228
- {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : pending ? 'Staged — not live yet' : 'Live design'}
385
+ {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? (saveMessage || 'Save failed') : null}
386
+ </span>
387
+ <button className="btn sm ghost" type="button" onClick={() => void openHistory()}>History</button>
388
+ </div>
389
+ </div>
390
+
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
+ )}
229
408
  </span>
230
- {pending && (
231
- <>
232
- <button className="btn sm ghost" type="button" onClick={discardDesign}>Discard</button>
233
- <button className="btn sm primary" type="button" onClick={publishDesign}>Publish design</button>
234
- </>
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>
235
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>
236
422
  </div>
237
423
  </div>
238
424
 
239
- <div className="dm-themes">
240
- {themes.map((t) => (
425
+ <div className="seg te-tabs" role="tablist" aria-label="Design settings" onKeyDown={onTabKey}>
426
+ {TABS.map((t) => (
241
427
  <button
242
- key={t.slug}
428
+ key={t.key}
429
+ ref={(el) => { tabRefs.current[t.key] = el; }}
243
430
  type="button"
244
- className={`dm-theme ${t.slug === activeTheme ? 'active' : ''}`}
245
- title={t.description}
246
- 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)}
247
437
  >
248
- <span
249
- className="dm-swatch"
250
- style={{ background: String(t.tokens.bg), borderColor: String(t.tokens.line) }}
251
- >
252
- <span style={{ color: String(t.tokens.ink), fontFamily: String(t.tokens.headingFont) }}>Aa</span>
253
- <i style={{ background: String(t.tokens.accent) }} />
254
- </span>
255
- <span className="dm-theme-name">{t.name}</span>
256
- <span className="dm-theme-desc">{t.description.split(' — ')[0]}</span>
257
- {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>}
258
440
  </button>
259
441
  ))}
260
442
  </div>
261
443
 
444
+ <div className="te-panel" role="tabpanel" id="dm-panel-brand" aria-labelledby="dm-tab-brand" hidden={tab !== 'brand'}>
262
445
  <div className="dm-row">
263
446
  <div className="dm-lab">
264
447
  <span>Logo</span>
@@ -360,6 +543,33 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
360
543
  </div>
361
544
  );
362
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>
363
573
 
364
574
  <div className="dm-row">
365
575
  <div className="dm-lab">
@@ -367,7 +577,7 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
367
577
  <small>
368
578
  {overrides.width !== undefined
369
579
  ? overriddenHint(`${Number(theme.tokens.width)}px`, 'width')
370
- : `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`}
371
581
  </small>
372
582
  </div>
373
583
  <div className="dm-ctl">
@@ -418,6 +628,53 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
418
628
  </div>
419
629
  )}
420
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
+
421
678
  <p className="card-title dm-group">Posts</p>
422
679
  <p className="dm-grouphint">
423
680
  How individual posts render. The theme sets the defaults; anything you change here overrides it site-wide.
@@ -474,6 +731,49 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
474
731
  </button>
475
732
  </div>
476
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>
477
777
  </div>
478
778
  </div>
479
779
 
@@ -495,19 +795,29 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
495
795
  </button>
496
796
  ))}
497
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
+ )}
498
808
  {postPreviewPath && (
499
809
  <div className="dm-ptabs">
500
810
  <button
501
811
  type="button"
502
812
  className={previewPage === 'home' ? 'active' : ''}
503
- onClick={() => setPreviewPage('home')}
813
+ onClick={() => { pathRef.current = '/'; setPreviewPath('/'); setPreviewPage('home'); }}
504
814
  >
505
815
  Home
506
816
  </button>
507
817
  <button
508
818
  type="button"
509
819
  className={previewPage === 'post' ? 'active' : ''}
510
- onClick={() => setPreviewPage('post')}
820
+ onClick={() => { pathRef.current = postPreviewPath ?? "/"; setPreviewPath(postPreviewPath ?? "/"); setPreviewPage("post"); }}
511
821
  >
512
822
  Post
513
823
  </button>
@@ -515,11 +825,12 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
515
825
  )}
516
826
  <a
517
827
  className="btn sm ghost"
518
- href={previewPage === 'post' && postPreviewPath ? postPreviewPath : '/'}
828
+ href={`${previewPath.split('?')[0]}?designPreview=1`}
519
829
  target="_blank"
520
830
  rel="noopener"
831
+ title={`Open ${previewPath} in preview mode`}
521
832
  >
522
- Open ↗
833
+ Open draft ↗
523
834
  </a>
524
835
  </div>
525
836
  </div>
@@ -529,8 +840,8 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
529
840
  return (
530
841
  <div className="dm-frame" ref={frameRef}>
531
842
  <iframe
532
- key={`${previewPage}-${tick}`}
533
- src={`${previewPage === 'post' && postPreviewPath ? postPreviewPath : '/'}?designPreview=1&design=${tick}`}
843
+ key={iframeSrc}
844
+ src={iframeSrc}
534
845
  title="Site preview"
535
846
  style={{
536
847
  width: device.width,
@@ -543,6 +854,67 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
543
854
  })()}
544
855
  </div>
545
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
+
546
918
  <ImagePicker
547
919
  open={logoPickerOpen}
548
920
  unsplashEnabled={unsplashEnabled}