@acms-subir/core 0.0.1

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 (107) hide show
  1. package/cli.mjs +75 -0
  2. package/integration.mjs +286 -0
  3. package/package.json +89 -0
  4. package/src/actions/index.ts +502 -0
  5. package/src/components/DesignManager.tsx +542 -0
  6. package/src/components/LinksManager.tsx +694 -0
  7. package/src/components/Modal.astro +65 -0
  8. package/src/components/NavManager.tsx +168 -0
  9. package/src/components/WidgetRenderer.astro +84 -0
  10. package/src/components/builder/PageBuilder.tsx +575 -0
  11. package/src/components/editor/ImagePicker.tsx +215 -0
  12. package/src/components/editor/PostEditor.tsx +1011 -0
  13. package/src/components/editor/PreviewOverlay.tsx +77 -0
  14. package/src/components/editor/extensions/calendar.tsx +230 -0
  15. package/src/components/editor/extensions/cards.tsx +488 -0
  16. package/src/components/editor/extensions/codeBlock.tsx +70 -0
  17. package/src/components/editor/extensions/infoCards.tsx +270 -0
  18. package/src/components/editor/extensions/mediaCards.tsx +376 -0
  19. package/src/components/editor/extensions/postList.tsx +130 -0
  20. package/src/components/editor/extensions/slash.tsx +250 -0
  21. package/src/components/editor/extensions/tableTyping.ts +63 -0
  22. package/src/components/editor/imaging.ts +146 -0
  23. package/src/components/media/CropModal.tsx +229 -0
  24. package/src/components/media/MediaLibrary.tsx +295 -0
  25. package/src/layouts/Admin.astro +119 -0
  26. package/src/layouts/Site.astro +218 -0
  27. package/src/lib/auth.ts +94 -0
  28. package/src/lib/backup/unzip.ts +38 -0
  29. package/src/lib/backup/zip.ts +105 -0
  30. package/src/lib/blocksToHtml.ts +68 -0
  31. package/src/lib/brand.ts +26 -0
  32. package/src/lib/calendar/ics.ts +402 -0
  33. package/src/lib/calendar/render.ts +275 -0
  34. package/src/lib/calendar/src.ts +68 -0
  35. package/src/lib/content.ts +53 -0
  36. package/src/lib/dates.ts +34 -0
  37. package/src/lib/dynamicBlocks.ts +115 -0
  38. package/src/lib/env.ts +6 -0
  39. package/src/lib/highlightCode.ts +44 -0
  40. package/src/lib/i18n.ts +40 -0
  41. package/src/lib/markdown.ts +7 -0
  42. package/src/lib/media/store.ts +95 -0
  43. package/src/lib/net.ts +51 -0
  44. package/src/lib/preview.ts +28 -0
  45. package/src/lib/repo/d1.ts +44 -0
  46. package/src/lib/repo/index.ts +23 -0
  47. package/src/lib/repo/migrations.ts +231 -0
  48. package/src/lib/repo/search.ts +14 -0
  49. package/src/lib/repo/sql.ts +721 -0
  50. package/src/lib/repo/sqlite.ts +43 -0
  51. package/src/lib/repo/types.ts +132 -0
  52. package/src/lib/schema.ts +286 -0
  53. package/src/lib/seo.ts +37 -0
  54. package/src/lib/theme.ts +206 -0
  55. package/src/lib/widgets.ts +108 -0
  56. package/src/middleware.ts +109 -0
  57. package/src/pages/[...slug].astro +43 -0
  58. package/src/pages/admin/c/[collection]/[id].astro +56 -0
  59. package/src/pages/admin/c/[collection]/index.astro +163 -0
  60. package/src/pages/admin/design.astro +42 -0
  61. package/src/pages/admin/index.astro +145 -0
  62. package/src/pages/admin/links.astro +21 -0
  63. package/src/pages/admin/login.astro +83 -0
  64. package/src/pages/admin/logout.ts +7 -0
  65. package/src/pages/admin/media.astro +13 -0
  66. package/src/pages/admin/navigation.astro +17 -0
  67. package/src/pages/admin/pages/[id].astro +49 -0
  68. package/src/pages/admin/pages/index.astro +100 -0
  69. package/src/pages/admin/posts/[id].astro +6 -0
  70. package/src/pages/admin/posts/index.astro +7 -0
  71. package/src/pages/admin/settings.astro +512 -0
  72. package/src/pages/admin/tags.astro +101 -0
  73. package/src/pages/admin/users.astro +149 -0
  74. package/src/pages/api/backup.ts +63 -0
  75. package/src/pages/api/drafts.ts +106 -0
  76. package/src/pages/api/media/upload.ts +89 -0
  77. package/src/pages/api/restore.ts +102 -0
  78. package/src/pages/author/[slug].astro +66 -0
  79. package/src/pages/blog/[slug].astro +240 -0
  80. package/src/pages/blog/index.astro +51 -0
  81. package/src/pages/blog/tag/[slug].astro +83 -0
  82. package/src/pages/embed/[pageId]/[blockId].ts +45 -0
  83. package/src/pages/go/[id].ts +10 -0
  84. package/src/pages/index.astro +52 -0
  85. package/src/pages/links.astro +110 -0
  86. package/src/pages/llms.txt.ts +47 -0
  87. package/src/pages/media/[...key].ts +18 -0
  88. package/src/pages/preview/[type]/[id].astro +52 -0
  89. package/src/pages/robots.txt.ts +28 -0
  90. package/src/pages/rss.xml.ts +58 -0
  91. package/src/pages/search.astro +83 -0
  92. package/src/pages/sitemap.xml.ts +55 -0
  93. package/src/styles/admin.css +652 -0
  94. package/src/styles/builder.css +121 -0
  95. package/src/styles/cards.css +176 -0
  96. package/src/styles/editor.css +596 -0
  97. package/src/styles/site.css +427 -0
  98. package/src/themes/editorial/templates/home.astro +127 -0
  99. package/src/themes/editorial/templates/post-minimal.astro +50 -0
  100. package/src/themes/editorial/theme.css +108 -0
  101. package/src/themes/editorial/theme.json +20 -0
  102. package/src/themes/field/theme.json +17 -0
  103. package/src/themes/ink/theme.json +17 -0
  104. package/src/themes/night/theme.css +7 -0
  105. package/src/themes/night/theme.json +17 -0
  106. package/src/virtual.d.ts +25 -0
  107. package/src/worker.ts +28 -0
@@ -0,0 +1,542 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { actions } from 'astro:actions';
3
+ import type { Settings } from '../lib/schema';
4
+ import { ImagePicker } from './editor/ImagePicker';
5
+
6
+ // Design screen: pick a code-first theme (src/themes/<slug>/), layer sparse
7
+ // token overrides on top (saved to settings.designOverrides — the theme files
8
+ // are never written to), set the site logo, and export the effective design
9
+ // as a committable theme.json.
10
+
11
+ type SaveState = 'saved' | 'saving' | 'error';
12
+
13
+ interface ThemeInfo {
14
+ slug: string;
15
+ name: string;
16
+ description: string;
17
+ tokens: Record<string, string | number>;
18
+ post: Record<string, string | boolean>;
19
+ hasHome: boolean;
20
+ }
21
+
22
+ // post-page toggles: designOverrides key + which theme default backs it
23
+ const POST_TOGGLE_ROWS: { key: string; field: string; label: string; hint: string }[] = [
24
+ { key: 'postDate', field: 'showDate', label: 'Date', hint: 'Publish date in the post header' },
25
+ { key: 'postReadingTime', field: 'showReadingTime', label: 'Reading time', hint: '“4 min read” next to the date' },
26
+ { key: 'postByline', field: 'byline', label: 'Author byline', hint: '“by Name” in the post header' },
27
+ { key: 'postAuthorCard', field: 'authorCard', label: 'Author card', hint: 'Avatar + bio block under the post' },
28
+ { key: 'postShare', field: 'share', label: 'Share buttons', hint: 'X, LinkedIn and copy-link' },
29
+ { key: 'postRelated', field: 'related', label: 'Related reading', hint: 'Three related posts at the bottom' },
30
+ ];
31
+ const COVER_LABEL: Record<string, string> = { normal: 'Content width', full: 'Full bleed', overlay: 'Overlay (title on image)', hidden: 'Hidden' };
32
+ interface Pairing { key: string; label: string; headingFont: string; bodyFont: string; googleFonts: string }
33
+
34
+ interface Props {
35
+ settings: Settings;
36
+ themes: ThemeInfo[];
37
+ pairings: Pairing[];
38
+ unsplashEnabled: boolean;
39
+ /** public path of the newest published post, or null when nothing is published */
40
+ postPreviewPath: string | null;
41
+ }
42
+
43
+ const COLOR_FIELDS: { key: string; label: string; hint: string }[] = [
44
+ { key: 'bg', label: 'Background', hint: 'Page background' },
45
+ { key: 'ink', label: 'Text', hint: 'Body and heading text' },
46
+ { key: 'accent', label: 'Accent', hint: 'Links and buttons' },
47
+ { key: 'muted', label: 'Muted text', hint: 'Bylines, captions, metadata' },
48
+ { key: 'line', label: 'Borders', hint: 'Dividers and card borders' },
49
+ { key: 'panel', label: 'Panels', hint: 'Cards and link buttons' },
50
+ { key: 'soft', label: 'Soft fill', hint: 'Tag pills, inline code, table heads' },
51
+ ];
52
+ const WIDTHS = [
53
+ { label: 'Narrow', px: 640 },
54
+ { label: 'Classic', px: 760 },
55
+ { label: 'Wide', px: 920 },
56
+ { label: 'Extra wide', px: 1140 },
57
+ ];
58
+
59
+ // preview device sizes: the iframe renders at the real device width and is
60
+ // scaled down to fit the panel, so responsive breakpoints actually kick in
61
+ const DEVICES = [
62
+ {
63
+ key: 'mobile',
64
+ label: 'Mobile · 390px',
65
+ width: 390,
66
+ icon: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="4.75" y="1.75" width="6.5" height="12.5" rx="1.5" /><path d="M7 12.25h2" /></svg>,
67
+ },
68
+ {
69
+ key: 'tablet',
70
+ label: 'Tablet · 768px',
71
+ width: 768,
72
+ icon: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.5" /><path d="M6.5 12.25h3" /></svg>,
73
+ },
74
+ {
75
+ key: 'desktop',
76
+ label: 'Desktop · 1280px',
77
+ width: 1280,
78
+ icon: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="1.75" y="2.75" width="12.5" height="8.5" rx="1.5" /><path d="M5.5 14.25h5M8 11.5v2.5" /></svg>,
79
+ },
80
+ ] as const;
81
+ type DeviceKey = (typeof DEVICES)[number]['key'];
82
+
83
+ export default function DesignManager({ settings, themes, pairings, unsplashEnabled, postPreviewPath }: Props) {
84
+ const [activeTheme, setActiveTheme] = useState(settings.activeTheme || 'ink');
85
+ const [overrides, setOverrides] = useState<Record<string, unknown>>(
86
+ (settings.designOverrides as Record<string, unknown>) ?? {}
87
+ );
88
+ const [siteLogo, setSiteLogo] = useState(settings.siteLogo);
89
+ const [siteFavicon, setSiteFavicon] = useState(settings.siteFavicon);
90
+ const [siteCover, setSiteCover] = useState(settings.siteCover);
91
+ const [saveState, setSaveState] = useState<SaveState>('saved');
92
+ const [logoPickerOpen, setLogoPickerOpen] = useState(false);
93
+ const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
94
+ const [coverPickerOpen, setCoverPickerOpen] = useState(false);
95
+ const [copied, setCopied] = useState(false);
96
+ const [tick, setTick] = useState(0);
97
+ const [previewPage, setPreviewPage] = useState<'home' | 'post'>('home');
98
+ const [previewDevice, setPreviewDevice] = useState<DeviceKey>('mobile');
99
+ const frameRef = useRef<HTMLDivElement>(null);
100
+ const [frameBox, setFrameBox] = useState({ w: 0, h: 0 });
101
+ useEffect(() => {
102
+ const el = frameRef.current;
103
+ if (!el) return;
104
+ const measure = () => setFrameBox({ w: el.clientWidth, h: el.clientHeight });
105
+ measure();
106
+ const ro = new ResizeObserver(measure);
107
+ ro.observe(el);
108
+ return () => ro.disconnect();
109
+ }, []);
110
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
111
+ const latest = useRef({ activeTheme, overrides, siteLogo, siteFavicon, siteCover });
112
+ latest.current = { activeTheme, overrides, siteLogo, siteFavicon, siteCover };
113
+
114
+ const theme = themes.find((t) => t.slug === activeTheme) ?? themes[0];
115
+
116
+ const scheduleSave = () => {
117
+ setSaveState('saving');
118
+ 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; }
129
+ setSaveState('saved');
130
+ setTick((t) => t + 1);
131
+ }, 500);
132
+ };
133
+
134
+ const setOv = (key: string, value: unknown) => {
135
+ setOverrides((o) => ({ ...o, [key]: value }));
136
+ scheduleSave();
137
+ };
138
+ const clearOv = (key: string) => {
139
+ setOverrides((o) => { const n = { ...o }; delete n[key]; return n; });
140
+ scheduleSave();
141
+ };
142
+ const pickTheme = (slug: string) => {
143
+ setActiveTheme(slug);
144
+ scheduleSave();
145
+ };
146
+
147
+ const effective = (key: string): string | number =>
148
+ (overrides[key] as string | number | undefined) ?? theme.tokens[key];
149
+
150
+ const copyThemeJson = async () => {
151
+ const tokens: Record<string, string | number> = { ...theme.tokens };
152
+ for (const f of [...COLOR_FIELDS.map((c) => c.key), 'width']) {
153
+ const v = overrides[f];
154
+ if (v !== undefined && v !== null && v !== '') tokens[f] = v as string | number;
155
+ }
156
+ const pairing = pairings.find((p) => p.key === overrides.fontPairing);
157
+ if (pairing) {
158
+ tokens.headingFont = pairing.headingFont;
159
+ tokens.bodyFont = pairing.bodyFont;
160
+ tokens.googleFonts = pairing.googleFonts;
161
+ }
162
+ const json = {
163
+ name: `${theme.name} (customized)`,
164
+ description: `Exported from the Design screen — drop into src/themes/<slug>/theme.json.`,
165
+ tokens,
166
+ };
167
+ await navigator.clipboard.writeText(JSON.stringify(json, null, 2));
168
+ setCopied(true);
169
+ setTimeout(() => setCopied(false), 1800);
170
+ };
171
+
172
+ const overrideCount = Object.keys(overrides).filter((k) => overrides[k] !== undefined && overrides[k] !== '').length;
173
+
174
+ // "you changed it · Reset to theme" hint line, per the plan mock
175
+ const overriddenHint = (themeValue: string | number, key: string) => (
176
+ <>
177
+ Theme says {String(themeValue)} — you changed it ·{' '}
178
+ <button className="dm-resetlink" type="button" onClick={() => clearOv(key)}>Reset to theme</button>
179
+ </>
180
+ );
181
+
182
+ return (
183
+ <div className="dm-layout">
184
+ <div className="dm-controls">
185
+ <div className="card card-pad">
186
+ <div className="dm-cardhead">
187
+ <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.
190
+ </p>
191
+ <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
+ <span className={`lm-savestate ${saveState}`}>
198
+ {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : 'Saved'}
199
+ </span>
200
+ </div>
201
+ </div>
202
+
203
+ <div className="dm-themes">
204
+ {themes.map((t) => (
205
+ <button
206
+ key={t.slug}
207
+ type="button"
208
+ className={`dm-theme ${t.slug === activeTheme ? 'active' : ''}`}
209
+ title={t.description}
210
+ onClick={() => pickTheme(t.slug)}
211
+ >
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>}
222
+ </button>
223
+ ))}
224
+ </div>
225
+
226
+ <div className="dm-row">
227
+ <div className="dm-lab">
228
+ <span>Logo</span>
229
+ <small>Shown in the header instead of the site title</small>
230
+ </div>
231
+ <div className="dm-ctl">
232
+ {siteLogo && <img className="dm-logo" src={siteLogo} alt="Logo" />}
233
+ <button className="btn sm" type="button" onClick={() => setLogoPickerOpen(true)}>
234
+ {siteLogo ? 'Replace…' : 'Upload…'}
235
+ </button>
236
+ {siteLogo && (
237
+ <button className="btn sm ghost" type="button" onClick={() => { setSiteLogo(''); scheduleSave(); }}>
238
+ Remove
239
+ </button>
240
+ )}
241
+ </div>
242
+ </div>
243
+
244
+ <div className="dm-row">
245
+ <div className="dm-lab">
246
+ <span>Favicon</span>
247
+ <small>The browser-tab icon — square images work best</small>
248
+ </div>
249
+ <div className="dm-ctl">
250
+ {siteFavicon && <img className="dm-logo" src={siteFavicon} alt="Favicon" style={{ width: 24, height: 24, objectFit: 'contain' }} />}
251
+ <button className="btn sm" type="button" onClick={() => setFaviconPickerOpen(true)}>
252
+ {siteFavicon ? 'Replace…' : 'Upload…'}
253
+ </button>
254
+ {siteFavicon && (
255
+ <button className="btn sm ghost" type="button" onClick={() => { setSiteFavicon(''); scheduleSave(); }}>
256
+ Remove
257
+ </button>
258
+ )}
259
+ </div>
260
+ </div>
261
+
262
+ <div className="dm-row">
263
+ <div className="dm-lab">
264
+ <span>Share image</span>
265
+ <small>Default social-preview image for pages and posts without a cover</small>
266
+ </div>
267
+ <div className="dm-ctl">
268
+ {siteCover && <img className="dm-logo" src={siteCover} alt="Share image" />}
269
+ <button className="btn sm" type="button" onClick={() => setCoverPickerOpen(true)}>
270
+ {siteCover ? 'Replace…' : 'Upload…'}
271
+ </button>
272
+ {siteCover && (
273
+ <button className="btn sm ghost" type="button" onClick={() => { setSiteCover(''); scheduleSave(); }}>
274
+ Remove
275
+ </button>
276
+ )}
277
+ </div>
278
+ </div>
279
+
280
+ <div className="dm-row">
281
+ <div className="dm-lab">
282
+ <span>
283
+ Fonts
284
+ {overrides.fontPairing !== undefined && <em className="dm-ovr">override</em>}
285
+ </span>
286
+ <small>Heading + body pairing — the theme sets its own by default</small>
287
+ </div>
288
+ <div className="dm-ctl">
289
+ <select
290
+ className="lm-select"
291
+ value={String(overrides.fontPairing ?? '')}
292
+ onChange={(e) => (e.target.value ? setOv('fontPairing', e.target.value) : clearOv('fontPairing'))}
293
+ >
294
+ <option value="">Theme default</option>
295
+ {pairings.map((p) => <option key={p.key} value={p.key}>{p.label}</option>)}
296
+ </select>
297
+ </div>
298
+ </div>
299
+
300
+ {COLOR_FIELDS.map((f) => {
301
+ const ov = overrides[f.key] !== undefined;
302
+ return (
303
+ <div className="dm-row" key={f.key}>
304
+ <div className="dm-lab">
305
+ <span>
306
+ {f.label}
307
+ {ov && <em className="dm-ovr">override</em>}
308
+ </span>
309
+ <small>{ov ? overriddenHint(theme.tokens[f.key], f.key) : f.hint}</small>
310
+ </div>
311
+ <div className="dm-ctl">
312
+ <input
313
+ type="color"
314
+ value={String(effective(f.key))}
315
+ onChange={(e) => setOv(f.key, e.target.value)}
316
+ />
317
+ <input
318
+ className="dm-hex"
319
+ type="text"
320
+ value={String(effective(f.key))}
321
+ onChange={(e) => { if (/^#[0-9a-fA-F]{6}$/.test(e.target.value)) setOv(f.key, e.target.value); }}
322
+ />
323
+ </div>
324
+ </div>
325
+ );
326
+ })}
327
+
328
+ <div className="dm-row">
329
+ <div className="dm-lab">
330
+ <span>Content width{overrides.width !== undefined && <em className="dm-ovr">override</em>}</span>
331
+ <small>
332
+ {overrides.width !== undefined
333
+ ? overriddenHint(`${Number(theme.tokens.width)}px`, 'width')
334
+ : `Desktop max width of the whole layout — theme default is ${Number(theme.tokens.width)}px`}
335
+ </small>
336
+ </div>
337
+ <div className="dm-ctl">
338
+ <div className="dm-width">
339
+ {WIDTHS.map((w) => (
340
+ <button
341
+ key={w.px}
342
+ type="button"
343
+ className={`dm-width-chip ${Number(effective('width')) === w.px ? 'active' : ''}`}
344
+ title={`${w.px}px`}
345
+ onClick={() => setOv('width', w.px)}
346
+ >
347
+ {w.label}
348
+ </button>
349
+ ))}
350
+ <input
351
+ type="number"
352
+ min={520}
353
+ max={1600}
354
+ step={10}
355
+ value={Number(effective('width'))}
356
+ onChange={(e) => {
357
+ const v = Number(e.target.value);
358
+ if (v >= 520 && v <= 1600) setOv('width', v);
359
+ }}
360
+ />
361
+ <span className="dm-width-unit">px</span>
362
+ </div>
363
+ </div>
364
+ </div>
365
+
366
+ {theme.hasHome && (
367
+ <div className="dm-row">
368
+ <div className="dm-lab">
369
+ <span>Homepage</span>
370
+ <small>Theme ships a coded template — or use your content page</small>
371
+ </div>
372
+ <div className="dm-ctl">
373
+ <select
374
+ className="lm-select"
375
+ value={overrides.homeMode === 'content' ? 'content' : 'auto'}
376
+ onChange={(e) => (e.target.value === 'content' ? setOv('homeMode', 'content') : clearOv('homeMode'))}
377
+ >
378
+ <option value="auto">Coded · {theme.slug}/home.astro</option>
379
+ <option value="content">My content page (marked “home”)</option>
380
+ </select>
381
+ </div>
382
+ </div>
383
+ )}
384
+
385
+ <p className="card-title dm-group">Posts</p>
386
+ <p className="dm-grouphint">
387
+ How individual posts render. The theme sets the defaults; anything you change here overrides it site-wide.
388
+ </p>
389
+
390
+ <div className="dm-row">
391
+ <div className="dm-lab">
392
+ <span>Feature image{overrides.postCover !== undefined && <em className="dm-ovr">override</em>}</span>
393
+ <small>The post's cover at the top of the page</small>
394
+ </div>
395
+ <div className="dm-ctl">
396
+ <select
397
+ className="lm-select"
398
+ value={String(overrides.postCover ?? '')}
399
+ onChange={(e) => (e.target.value ? setOv('postCover', e.target.value) : clearOv('postCover'))}
400
+ >
401
+ <option value="">Theme default ({COVER_LABEL[String(theme.post.cover)] ?? 'Content width'})</option>
402
+ <option value="normal">Content width</option>
403
+ <option value="full">Full bleed</option>
404
+ <option value="overlay">Overlay — title & info on the image</option>
405
+ <option value="hidden">Hidden</option>
406
+ </select>
407
+ </div>
408
+ </div>
409
+
410
+ {POST_TOGGLE_ROWS.map((r) => (
411
+ <div className="dm-row" key={r.key}>
412
+ <div className="dm-lab">
413
+ <span>{r.label}{overrides[r.key] !== undefined && <em className="dm-ovr">override</em>}</span>
414
+ <small>{r.hint}</small>
415
+ </div>
416
+ <div className="dm-ctl">
417
+ <select
418
+ className="lm-select"
419
+ value={String(overrides[r.key] ?? '')}
420
+ onChange={(e) => (e.target.value ? setOv(r.key, e.target.value) : clearOv(r.key))}
421
+ >
422
+ <option value="">Theme default ({theme.post[r.field] === false ? 'Hidden' : 'Shown'})</option>
423
+ <option value="show">Show</option>
424
+ <option value="hide">Hide</option>
425
+ </select>
426
+ </div>
427
+ </div>
428
+ ))}
429
+
430
+ <div className="dm-row">
431
+ <div className="dm-lab">
432
+ <span>Export</span>
433
+ <small>Effective design (theme + your overrides) as a committable <code>theme.json</code></small>
434
+ </div>
435
+ <div className="dm-ctl">
436
+ <button className="btn sm" type="button" onClick={copyThemeJson}>
437
+ {copied ? 'Copied ✓' : 'Copy as theme.json'}
438
+ </button>
439
+ </div>
440
+ </div>
441
+ </div>
442
+ </div>
443
+
444
+ <div className="dm-preview">
445
+ <div className="dm-preview-bar">
446
+ <span className="lm-preview-label">Preview</span>
447
+ <div className="dm-preview-actions">
448
+ <div className="dm-ptabs">
449
+ {DEVICES.map((d) => (
450
+ <button
451
+ key={d.key}
452
+ type="button"
453
+ title={d.label}
454
+ aria-label={d.label}
455
+ className={`dm-devbtn ${previewDevice === d.key ? 'active' : ''}`}
456
+ onClick={() => setPreviewDevice(d.key)}
457
+ >
458
+ {d.icon}
459
+ </button>
460
+ ))}
461
+ </div>
462
+ {postPreviewPath && (
463
+ <div className="dm-ptabs">
464
+ <button
465
+ type="button"
466
+ className={previewPage === 'home' ? 'active' : ''}
467
+ onClick={() => setPreviewPage('home')}
468
+ >
469
+ Home
470
+ </button>
471
+ <button
472
+ type="button"
473
+ className={previewPage === 'post' ? 'active' : ''}
474
+ onClick={() => setPreviewPage('post')}
475
+ >
476
+ Post
477
+ </button>
478
+ </div>
479
+ )}
480
+ <a
481
+ className="btn sm ghost"
482
+ href={previewPage === 'post' && postPreviewPath ? postPreviewPath : '/'}
483
+ target="_blank"
484
+ rel="noopener"
485
+ >
486
+ Open ↗
487
+ </a>
488
+ </div>
489
+ </div>
490
+ {(() => {
491
+ const device = DEVICES.find((d) => d.key === previewDevice) ?? DEVICES[0];
492
+ const scale = frameBox.w ? Math.min(1, frameBox.w / device.width) : 1;
493
+ return (
494
+ <div className="dm-frame" ref={frameRef}>
495
+ <iframe
496
+ key={`${previewPage}-${tick}`}
497
+ src={`${previewPage === 'post' && postPreviewPath ? postPreviewPath : '/'}?design=${tick}`}
498
+ title="Site preview"
499
+ style={{
500
+ width: device.width,
501
+ height: frameBox.h ? frameBox.h / scale : '100%',
502
+ transform: `scale(${scale})`,
503
+ }}
504
+ />
505
+ </div>
506
+ );
507
+ })()}
508
+ </div>
509
+
510
+ <ImagePicker
511
+ open={logoPickerOpen}
512
+ unsplashEnabled={unsplashEnabled}
513
+ onClose={() => setLogoPickerOpen(false)}
514
+ onSelect={(img) => {
515
+ setSiteLogo(img.src);
516
+ setLogoPickerOpen(false);
517
+ scheduleSave();
518
+ }}
519
+ />
520
+ <ImagePicker
521
+ open={faviconPickerOpen}
522
+ unsplashEnabled={unsplashEnabled}
523
+ onClose={() => setFaviconPickerOpen(false)}
524
+ onSelect={(img) => {
525
+ setSiteFavicon(img.src);
526
+ setFaviconPickerOpen(false);
527
+ scheduleSave();
528
+ }}
529
+ />
530
+ <ImagePicker
531
+ open={coverPickerOpen}
532
+ unsplashEnabled={unsplashEnabled}
533
+ onClose={() => setCoverPickerOpen(false)}
534
+ onSelect={(img) => {
535
+ setSiteCover(img.src);
536
+ setCoverPickerOpen(false);
537
+ scheduleSave();
538
+ }}
539
+ />
540
+ </div>
541
+ );
542
+ }