@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.
- package/cli.mjs +75 -0
- package/integration.mjs +286 -0
- package/package.json +89 -0
- package/src/actions/index.ts +502 -0
- package/src/components/DesignManager.tsx +542 -0
- package/src/components/LinksManager.tsx +694 -0
- package/src/components/Modal.astro +65 -0
- package/src/components/NavManager.tsx +168 -0
- package/src/components/WidgetRenderer.astro +84 -0
- package/src/components/builder/PageBuilder.tsx +575 -0
- package/src/components/editor/ImagePicker.tsx +215 -0
- package/src/components/editor/PostEditor.tsx +1011 -0
- package/src/components/editor/PreviewOverlay.tsx +77 -0
- package/src/components/editor/extensions/calendar.tsx +230 -0
- package/src/components/editor/extensions/cards.tsx +488 -0
- package/src/components/editor/extensions/codeBlock.tsx +70 -0
- package/src/components/editor/extensions/infoCards.tsx +270 -0
- package/src/components/editor/extensions/mediaCards.tsx +376 -0
- package/src/components/editor/extensions/postList.tsx +130 -0
- package/src/components/editor/extensions/slash.tsx +250 -0
- package/src/components/editor/extensions/tableTyping.ts +63 -0
- package/src/components/editor/imaging.ts +146 -0
- package/src/components/media/CropModal.tsx +229 -0
- package/src/components/media/MediaLibrary.tsx +295 -0
- package/src/layouts/Admin.astro +119 -0
- package/src/layouts/Site.astro +218 -0
- package/src/lib/auth.ts +94 -0
- package/src/lib/backup/unzip.ts +38 -0
- package/src/lib/backup/zip.ts +105 -0
- package/src/lib/blocksToHtml.ts +68 -0
- package/src/lib/brand.ts +26 -0
- package/src/lib/calendar/ics.ts +402 -0
- package/src/lib/calendar/render.ts +275 -0
- package/src/lib/calendar/src.ts +68 -0
- package/src/lib/content.ts +53 -0
- package/src/lib/dates.ts +34 -0
- package/src/lib/dynamicBlocks.ts +115 -0
- package/src/lib/env.ts +6 -0
- package/src/lib/highlightCode.ts +44 -0
- package/src/lib/i18n.ts +40 -0
- package/src/lib/markdown.ts +7 -0
- package/src/lib/media/store.ts +95 -0
- package/src/lib/net.ts +51 -0
- package/src/lib/preview.ts +28 -0
- package/src/lib/repo/d1.ts +44 -0
- package/src/lib/repo/index.ts +23 -0
- package/src/lib/repo/migrations.ts +231 -0
- package/src/lib/repo/search.ts +14 -0
- package/src/lib/repo/sql.ts +721 -0
- package/src/lib/repo/sqlite.ts +43 -0
- package/src/lib/repo/types.ts +132 -0
- package/src/lib/schema.ts +286 -0
- package/src/lib/seo.ts +37 -0
- package/src/lib/theme.ts +206 -0
- package/src/lib/widgets.ts +108 -0
- package/src/middleware.ts +109 -0
- package/src/pages/[...slug].astro +43 -0
- package/src/pages/admin/c/[collection]/[id].astro +56 -0
- package/src/pages/admin/c/[collection]/index.astro +163 -0
- package/src/pages/admin/design.astro +42 -0
- package/src/pages/admin/index.astro +145 -0
- package/src/pages/admin/links.astro +21 -0
- package/src/pages/admin/login.astro +83 -0
- package/src/pages/admin/logout.ts +7 -0
- package/src/pages/admin/media.astro +13 -0
- package/src/pages/admin/navigation.astro +17 -0
- package/src/pages/admin/pages/[id].astro +49 -0
- package/src/pages/admin/pages/index.astro +100 -0
- package/src/pages/admin/posts/[id].astro +6 -0
- package/src/pages/admin/posts/index.astro +7 -0
- package/src/pages/admin/settings.astro +512 -0
- package/src/pages/admin/tags.astro +101 -0
- package/src/pages/admin/users.astro +149 -0
- package/src/pages/api/backup.ts +63 -0
- package/src/pages/api/drafts.ts +106 -0
- package/src/pages/api/media/upload.ts +89 -0
- package/src/pages/api/restore.ts +102 -0
- package/src/pages/author/[slug].astro +66 -0
- package/src/pages/blog/[slug].astro +240 -0
- package/src/pages/blog/index.astro +51 -0
- package/src/pages/blog/tag/[slug].astro +83 -0
- package/src/pages/embed/[pageId]/[blockId].ts +45 -0
- package/src/pages/go/[id].ts +10 -0
- package/src/pages/index.astro +52 -0
- package/src/pages/links.astro +110 -0
- package/src/pages/llms.txt.ts +47 -0
- package/src/pages/media/[...key].ts +18 -0
- package/src/pages/preview/[type]/[id].astro +52 -0
- package/src/pages/robots.txt.ts +28 -0
- package/src/pages/rss.xml.ts +58 -0
- package/src/pages/search.astro +83 -0
- package/src/pages/sitemap.xml.ts +55 -0
- package/src/styles/admin.css +652 -0
- package/src/styles/builder.css +121 -0
- package/src/styles/cards.css +176 -0
- package/src/styles/editor.css +596 -0
- package/src/styles/site.css +427 -0
- package/src/themes/editorial/templates/home.astro +127 -0
- package/src/themes/editorial/templates/post-minimal.astro +50 -0
- package/src/themes/editorial/theme.css +108 -0
- package/src/themes/editorial/theme.json +20 -0
- package/src/themes/field/theme.json +17 -0
- package/src/themes/ink/theme.json +17 -0
- package/src/themes/night/theme.css +7 -0
- package/src/themes/night/theme.json +17 -0
- package/src/virtual.d.ts +25 -0
- 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
|
+
}
|