@acms-subir/core 0.1.0 → 0.2.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.
- package/CHANGELOG.md +28 -0
- package/cli.mjs +63 -9
- package/integration.mjs +11 -0
- package/package.json +10 -9
- package/src/actions/index.ts +184 -6
- package/src/components/DesignManager.tsx +53 -17
- package/src/components/LinksManager.tsx +110 -48
- package/src/components/NavManager.tsx +83 -37
- package/src/components/editor/PostEditor.tsx +228 -55
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +70 -8
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- package/src/layouts/Site.astro +10 -1
- package/src/lib/analytics.ts +121 -0
- package/src/lib/designPreview.ts +14 -0
- package/src/lib/drafts.ts +77 -0
- package/src/lib/dynamicBlocks.ts +43 -3
- package/src/lib/repo/migrations.ts +37 -0
- package/src/lib/repo/sql.ts +127 -10
- package/src/lib/repo/types.ts +21 -1
- package/src/lib/schema.ts +53 -0
- package/src/pages/[...slug].astro +3 -1
- package/src/pages/admin/c/[collection]/[id].astro +10 -3
- package/src/pages/admin/c/[collection]/index.astro +1 -0
- package/src/pages/admin/design.astro +6 -1
- package/src/pages/admin/index.astro +5 -2
- package/src/pages/admin/pages/[id].astro +10 -2
- package/src/pages/admin/pages/index.astro +1 -0
- package/src/pages/admin/settings.astro +148 -0
- package/src/pages/blog/[slug].astro +4 -1
- package/src/pages/index.astro +4 -1
- package/src/pages/links.astro +10 -4
- package/src/pages/preview/[type]/[id].astro +9 -4
- package/src/styles/admin.css +4 -0
- package/src/styles/cards.css +23 -0
- package/src/styles/editor.css +53 -1
- package/src/styles/site.css +3 -0
|
@@ -5,6 +5,8 @@ import { createPreviewToken } from '../../../lib/preview';
|
|
|
5
5
|
import { resolveDesign } from '../../../lib/theme';
|
|
6
6
|
import { env } from '../../../lib/env';
|
|
7
7
|
import { BRAND } from '../../../lib/brand';
|
|
8
|
+
import { mergePageDraft } from '../../../lib/drafts';
|
|
9
|
+
import { localeHomeUrl } from '../../../lib/i18n';
|
|
8
10
|
import PostEditor from '../../../components/editor/PostEditor';
|
|
9
11
|
import '../../../styles/admin.css';
|
|
10
12
|
import '../../../styles/editor.css';
|
|
@@ -14,9 +16,14 @@ const id = Astro.params.id!;
|
|
|
14
16
|
const page = await repo.getPage(id);
|
|
15
17
|
if (!page) return new Response(null, { status: 404 });
|
|
16
18
|
|
|
19
|
+
// The editor hydrates from the pending draft when one exists (live pages
|
|
20
|
+
// stage their edits until Update); the live URL stays on the live slug.
|
|
21
|
+
const editable = mergePageDraft(page);
|
|
22
|
+
const livePath = page.isHome ? localeHomeUrl(page.locale) : `/${page.slug}`;
|
|
23
|
+
|
|
17
24
|
// Canonical content is the TipTap doc (as HTML). Legacy widget-block pages
|
|
18
25
|
// are converted on first open and become rich content on next save.
|
|
19
|
-
const initialHTML =
|
|
26
|
+
const initialHTML = editable.bodyHtml || blocksToHtml(editable.blocks);
|
|
20
27
|
const previewPath = `/preview/page/${page.id}`;
|
|
21
28
|
const sharePath = `${previewPath}?token=${createPreviewToken('page', page.id)}`;
|
|
22
29
|
const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
@@ -38,11 +45,12 @@ const templates = Object.keys(resolveDesign(settings).theme.pageTemplates);
|
|
|
38
45
|
<PostEditor
|
|
39
46
|
client:only="react"
|
|
40
47
|
kind="page"
|
|
41
|
-
page={
|
|
48
|
+
page={editable}
|
|
42
49
|
initialHTML={initialHTML}
|
|
43
50
|
unsplashEnabled={unsplashEnabled}
|
|
44
51
|
previewPath={previewPath}
|
|
45
52
|
sharePath={sharePath}
|
|
53
|
+
livePath={livePath}
|
|
46
54
|
templates={templates}
|
|
47
55
|
/>
|
|
48
56
|
</body>
|
|
@@ -70,6 +70,7 @@ const fmt = (ts: number) => new Date(ts).toLocaleDateString('en-US', { month: 's
|
|
|
70
70
|
</td>
|
|
71
71
|
<td>
|
|
72
72
|
<span class={`pill ${p.status}`}>{p.status}</span>
|
|
73
|
+
{p.draft && <span class="pill edited" title="Has pending edits — press Update in the editor to push them live">edited</span>}
|
|
73
74
|
{p.isHome && <span class="pill home">home</span>}
|
|
74
75
|
{I18N.enabled && <span class="pill lang">{(p.locale || I18N.defaultLocale).toUpperCase()}</span>}
|
|
75
76
|
</td>
|
|
@@ -4,6 +4,7 @@ import { getRepo } from '../../lib/repo';
|
|
|
4
4
|
import { verifySessionToken, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
|
|
5
5
|
import { invalidateRedirectCache } from '../../middleware';
|
|
6
6
|
import { CONTENT } from '../../lib/content';
|
|
7
|
+
import { UMAMI_ID_RE, META_PIXEL_ID_RE, TIKTOK_PIXEL_ID_RE, normalizeScriptUrl } from '../../lib/analytics';
|
|
7
8
|
|
|
8
9
|
const repo = await getRepo(Astro.locals);
|
|
9
10
|
|
|
@@ -77,6 +78,57 @@ if (Astro.request.method === 'POST') {
|
|
|
77
78
|
});
|
|
78
79
|
flash = 'Code injection saved.';
|
|
79
80
|
}
|
|
81
|
+
} else if (action === 'analytics') {
|
|
82
|
+
if (!isOwner) {
|
|
83
|
+
flashError = 'Only owners can edit analytics.';
|
|
84
|
+
} else {
|
|
85
|
+
// replay sample rate is edited as a percent, stored as a fraction
|
|
86
|
+
const replayPct = Number(String(form.get('umamiReplaySampleRate') ?? '15').trim());
|
|
87
|
+
const replayDuration = Number(String(form.get('umamiReplayMaxDuration') ?? '300000').trim());
|
|
88
|
+
const umami = {
|
|
89
|
+
enabled: form.get('umamiEnabled') === 'on',
|
|
90
|
+
websiteId: String(form.get('umamiWebsiteId') ?? '').trim(),
|
|
91
|
+
scriptUrl: String(form.get('umamiScriptUrl') ?? '').trim() || 'https://cloud.umami.is/script.js',
|
|
92
|
+
replay: {
|
|
93
|
+
enabled: form.get('umamiReplayEnabled') === 'on',
|
|
94
|
+
sampleRate: Math.round(replayPct) / 100,
|
|
95
|
+
maskLevel: (form.get('umamiReplayMaskLevel') === 'strict' ? 'strict' : 'moderate') as 'strict' | 'moderate',
|
|
96
|
+
maxDuration: replayDuration,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const metaPixel = {
|
|
100
|
+
enabled: form.get('metaEnabled') === 'on',
|
|
101
|
+
pixelId: String(form.get('metaPixelId') ?? '').trim(),
|
|
102
|
+
};
|
|
103
|
+
const tiktokPixel = {
|
|
104
|
+
enabled: form.get('tiktokEnabled') === 'on',
|
|
105
|
+
pixelId: String(form.get('tiktokPixelId') ?? '').trim(),
|
|
106
|
+
};
|
|
107
|
+
// reject bad values even while disabled — a stored ID must always be
|
|
108
|
+
// renderable, since these end up in set:html on every public page
|
|
109
|
+
if (umami.websiteId && !UMAMI_ID_RE.test(umami.websiteId)) {
|
|
110
|
+
flashError = 'Umami website ID should be a UUID — copy it from your Umami dashboard (Settings → Websites → Edit).';
|
|
111
|
+
} else if (!normalizeScriptUrl(umami.scriptUrl)) {
|
|
112
|
+
flashError = 'Umami script URL must be an https:// URL, e.g. https://cloud.umami.is/script.js';
|
|
113
|
+
} else if (!Number.isFinite(replayPct) || replayPct < 1 || replayPct > 100) {
|
|
114
|
+
flashError = 'Replay sample rate must be between 1 and 100 percent.';
|
|
115
|
+
} else if (!Number.isInteger(replayDuration) || replayDuration <= 0) {
|
|
116
|
+
flashError = 'Replay max duration is invalid.';
|
|
117
|
+
} else if (metaPixel.pixelId && !META_PIXEL_ID_RE.test(metaPixel.pixelId)) {
|
|
118
|
+
flashError = 'Meta Pixel ID should be numbers only — find it in Meta Events Manager.';
|
|
119
|
+
} else if (tiktokPixel.pixelId && !TIKTOK_PIXEL_ID_RE.test(tiktokPixel.pixelId)) {
|
|
120
|
+
flashError = 'TikTok Pixel ID should be letters and numbers only — find it in TikTok Events Manager.';
|
|
121
|
+
} else if (umami.enabled && !umami.websiteId) {
|
|
122
|
+
flashError = 'Enter the Umami website ID to enable it.';
|
|
123
|
+
} else if (metaPixel.enabled && !metaPixel.pixelId) {
|
|
124
|
+
flashError = 'Enter the Meta Pixel ID to enable it.';
|
|
125
|
+
} else if (tiktokPixel.enabled && !tiktokPixel.pixelId) {
|
|
126
|
+
flashError = 'Enter the TikTok Pixel ID to enable it.';
|
|
127
|
+
} else {
|
|
128
|
+
await repo.updateSettings({ analytics: { umami, metaPixel, tiktokPixel } });
|
|
129
|
+
flash = 'Analytics saved.';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
80
132
|
} else if (action === 'announcement') {
|
|
81
133
|
// link must be a relative path or http(s) URL — never javascript: etc.
|
|
82
134
|
const rawLink = String(form.get('annLink') ?? '').trim();
|
|
@@ -314,6 +366,102 @@ const remainingPresets = ORIGIN_PRESETS.filter((p) => !settings.draftsAllowedOri
|
|
|
314
366
|
</div>
|
|
315
367
|
)}
|
|
316
368
|
|
|
369
|
+
{isOwner && (
|
|
370
|
+
<div class="card card-pad">
|
|
371
|
+
<p class="card-title">Analytics</p>
|
|
372
|
+
<p style="margin:0">
|
|
373
|
+
Traffic and ad tracking on every public page. Only the ID is stored — the tracking code itself
|
|
374
|
+
is rendered by the CMS. The admin is never tracked. Mind visitor-consent rules (GDPR) if you
|
|
375
|
+
enable the ad pixels.
|
|
376
|
+
</p>
|
|
377
|
+
<form method="post">
|
|
378
|
+
<input type="hidden" name="action" value="analytics" />
|
|
379
|
+
|
|
380
|
+
<div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
|
|
381
|
+
<label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
|
|
382
|
+
<input type="checkbox" name="umamiEnabled" checked={settings.analytics.umami.enabled} />
|
|
383
|
+
<span style="font-weight:600">Umami</span>
|
|
384
|
+
</label>
|
|
385
|
+
<div class="field-row">
|
|
386
|
+
<label class="field">
|
|
387
|
+
<span>Website ID</span>
|
|
388
|
+
<input type="text" name="umamiWebsiteId" value={settings.analytics.umami.websiteId} placeholder="94db1cb1-74f4-4a40-ad6c-962362670409" style="font-family:monospace;font-size:12.5px" />
|
|
389
|
+
</label>
|
|
390
|
+
<label class="field">
|
|
391
|
+
<span>Script URL</span>
|
|
392
|
+
<input type="text" name="umamiScriptUrl" value={settings.analytics.umami.scriptUrl} placeholder="https://cloud.umami.is/script.js" style="font-family:monospace;font-size:12.5px" />
|
|
393
|
+
</label>
|
|
394
|
+
</div>
|
|
395
|
+
<p class="hint" style="margin:0">
|
|
396
|
+
Privacy-friendly stats: pageviews, referrers, devices, countries, visit duration.
|
|
397
|
+
Umami Cloud by default — point Script URL at your own instance if self-hosting.
|
|
398
|
+
</p>
|
|
399
|
+
<div style="margin:10px 0 0 24px;padding:12px 14px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px">
|
|
400
|
+
<label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
|
|
401
|
+
<input type="checkbox" name="umamiReplayEnabled" checked={settings.analytics.umami.replay.enabled} />
|
|
402
|
+
<span style="font-weight:600">Session replay</span>
|
|
403
|
+
</label>
|
|
404
|
+
<div class="field-row">
|
|
405
|
+
<label class="field">
|
|
406
|
+
<span>Sample rate (%)</span>
|
|
407
|
+
<input type="number" name="umamiReplaySampleRate" min="1" max="100" step="1" value={Math.round(settings.analytics.umami.replay.sampleRate * 100)} />
|
|
408
|
+
</label>
|
|
409
|
+
<label class="field">
|
|
410
|
+
<span>Mask level</span>
|
|
411
|
+
<select name="umamiReplayMaskLevel">
|
|
412
|
+
<option value="moderate" selected={settings.analytics.umami.replay.maskLevel === 'moderate'}>moderate</option>
|
|
413
|
+
<option value="strict" selected={settings.analytics.umami.replay.maskLevel === 'strict'}>strict</option>
|
|
414
|
+
</select>
|
|
415
|
+
</label>
|
|
416
|
+
<label class="field">
|
|
417
|
+
<span>Max duration</span>
|
|
418
|
+
<select name="umamiReplayMaxDuration">
|
|
419
|
+
<option value="60000" selected={settings.analytics.umami.replay.maxDuration === 60000}>1 minute</option>
|
|
420
|
+
<option value="300000" selected={settings.analytics.umami.replay.maxDuration === 300000}>5 minutes</option>
|
|
421
|
+
<option value="600000" selected={settings.analytics.umami.replay.maxDuration === 600000}>10 minutes</option>
|
|
422
|
+
<option value="1800000" selected={settings.analytics.umami.replay.maxDuration === 1800000}>30 minutes</option>
|
|
423
|
+
</select>
|
|
424
|
+
</label>
|
|
425
|
+
</div>
|
|
426
|
+
<p class="hint" style="margin:0">
|
|
427
|
+
Records a sample of visitor sessions for playback in Umami (v3.1+, Behavior → Replays).
|
|
428
|
+
Umami keeps replays for 30 days. Inputs are masked — mind visitor-consent rules (GDPR) before enabling.
|
|
429
|
+
</p>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
|
|
434
|
+
<label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
|
|
435
|
+
<input type="checkbox" name="metaEnabled" checked={settings.analytics.metaPixel.enabled} />
|
|
436
|
+
<span style="font-weight:600">Meta Pixel</span>
|
|
437
|
+
</label>
|
|
438
|
+
<label class="field">
|
|
439
|
+
<span>Pixel ID</span>
|
|
440
|
+
<input type="text" name="metaPixelId" value={settings.analytics.metaPixel.pixelId} placeholder="724159876543210" style="font-family:monospace;font-size:12.5px;max-width:320px" />
|
|
441
|
+
</label>
|
|
442
|
+
<p class="hint" style="margin:0">Tracks page views for Facebook/Instagram ad attribution and retargeting audiences.</p>
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
<div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
|
|
446
|
+
<label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
|
|
447
|
+
<input type="checkbox" name="tiktokEnabled" checked={settings.analytics.tiktokPixel.enabled} />
|
|
448
|
+
<span style="font-weight:600">TikTok Pixel <span style="display:inline-block;font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;padding:2px 8px;border-radius:999px;background:var(--amber-soft);color:var(--amber);vertical-align:1px">Experimental</span></span>
|
|
449
|
+
</label>
|
|
450
|
+
<label class="field">
|
|
451
|
+
<span>Pixel ID</span>
|
|
452
|
+
<input type="text" name="tiktokPixelId" value={settings.analytics.tiktokPixel.pixelId} placeholder="CABC123DEF456GHI789" style="font-family:monospace;font-size:12.5px;max-width:320px" />
|
|
453
|
+
</label>
|
|
454
|
+
<p class="hint" style="margin:0">
|
|
455
|
+
Untested integration — the snippet follows TikTok’s published base code, but verify events
|
|
456
|
+
in TikTok Events Manager before relying on it.
|
|
457
|
+
</p>
|
|
458
|
+
</div>
|
|
459
|
+
|
|
460
|
+
<div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
|
|
461
|
+
</form>
|
|
462
|
+
</div>
|
|
463
|
+
)}
|
|
464
|
+
|
|
317
465
|
<div class="card card-pad">
|
|
318
466
|
<p class="card-title">Announcement bar</p>
|
|
319
467
|
<form method="post">
|
|
@@ -7,6 +7,7 @@ import { highlightCodeHtml } from '../../lib/highlightCode';
|
|
|
7
7
|
import { verifySessionToken, SESSION_COOKIE } from '../../lib/auth';
|
|
8
8
|
import { slugify, authorSlug, publicTags } from '../../lib/schema';
|
|
9
9
|
import { resolveDesign } from '../../lib/theme';
|
|
10
|
+
import { effectiveSettings } from '../../lib/designPreview';
|
|
10
11
|
import { siteOrigin } from '../../lib/seo';
|
|
11
12
|
import { formatDate } from '../../lib/dates';
|
|
12
13
|
import { collectionFromPath, collectionUrl, tagUrl } from '../../lib/content';
|
|
@@ -24,13 +25,15 @@ if (!post || (post.status !== 'published' && !isAdmin)) {
|
|
|
24
25
|
return new Response(null, { status: 404, statusText: 'Not found' });
|
|
25
26
|
}
|
|
26
27
|
const authorIds = post.authorIds.length ? post.authorIds : post.authorId ? [post.authorId] : [];
|
|
27
|
-
const [tags, allUsers, allPosts,
|
|
28
|
+
const [tags, allUsers, allPosts, rawSettings, allMedia] = await Promise.all([
|
|
28
29
|
repo.listTags(),
|
|
29
30
|
authorIds.length ? repo.listUsers() : Promise.resolve([]),
|
|
30
31
|
repo.listPublishedPosts(col.key), // related reading stays within the collection
|
|
31
32
|
repo.getSettings(),
|
|
32
33
|
post.cover.startsWith('/media/') ? repo.listMedia() : Promise.resolve([]),
|
|
33
34
|
]);
|
|
35
|
+
// ?designPreview=1 (admin-only) renders the staged Design-screen changes
|
|
36
|
+
const settings = effectiveSettings(rawSettings, Astro.url, Astro.cookies);
|
|
34
37
|
// ordered author objects; the first is the primary (gets the author card)
|
|
35
38
|
const authors = authorIds
|
|
36
39
|
.map((id) => allUsers.find((u) => u.id === id))
|
package/src/pages/index.astro
CHANGED
|
@@ -3,6 +3,7 @@ import Site from '../layouts/Site.astro';
|
|
|
3
3
|
import WidgetRenderer from '../components/WidgetRenderer.astro';
|
|
4
4
|
import { getRepo } from '../lib/repo';
|
|
5
5
|
import { resolveDesign } from '../lib/theme';
|
|
6
|
+
import { effectiveSettings } from '../lib/designPreview';
|
|
6
7
|
import { expandDynamicBlocks } from '../lib/dynamicBlocks';
|
|
7
8
|
import { I18N, localeFromPath } from '../lib/i18n';
|
|
8
9
|
|
|
@@ -11,10 +12,12 @@ const repo = await getRepo(Astro.locals);
|
|
|
11
12
|
// resolved from the request path (injectRoute can't pass props)
|
|
12
13
|
const locale = I18N.enabled ? localeFromPath(Astro.url.pathname) : I18N.defaultLocale;
|
|
13
14
|
const wantsLocaleHome = I18N.enabled && locale !== I18N.defaultLocale;
|
|
14
|
-
let [home,
|
|
15
|
+
let [home, rawSettings] = await Promise.all([
|
|
15
16
|
repo.getHomePage(wantsLocaleHome ? locale : undefined),
|
|
16
17
|
repo.getSettings(),
|
|
17
18
|
]);
|
|
19
|
+
// ?designPreview=1 (admin-only) renders the staged Design-screen changes
|
|
20
|
+
const settings = effectiveSettings(rawSettings, Astro.url, Astro.cookies);
|
|
18
21
|
// a locale home that isn't authored yet falls back to the default home —
|
|
19
22
|
// /es must never 404 mid-setup
|
|
20
23
|
if (wantsLocaleHome && !home) home = await repo.getHomePage();
|
package/src/pages/links.astro
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Site from '../layouts/Site.astro';
|
|
3
3
|
import { getRepo } from '../lib/repo';
|
|
4
|
+
import { cleanEventName } from '../lib/analytics';
|
|
4
5
|
|
|
5
6
|
const repo = await getRepo(Astro.locals);
|
|
6
7
|
const [links, settings] = await Promise.all([repo.listLinks(), repo.getSettings()]);
|
|
@@ -11,6 +12,11 @@ const profileBio = settings.linksBio || settings.tagline;
|
|
|
11
12
|
|
|
12
13
|
const SPACER_PX: Record<string, number> = { s: 16, m: 36, l: 72 };
|
|
13
14
|
|
|
15
|
+
// every link button is auto-stamped with an analytics event so per-link click
|
|
16
|
+
// counts (sponsors!) show up in Umami alongside the first-party /go counter
|
|
17
|
+
const linkEvent = (label: string): string | undefined =>
|
|
18
|
+
cleanEventName(`Link: ${label}`) === 'Link:' ? undefined : cleanEventName(`Link: ${label}`);
|
|
19
|
+
|
|
14
20
|
// icon column convention: '<svg…' inline markup, http// image URL,
|
|
15
21
|
// 'favicon' derive from the link's host, anything else = emoji/text
|
|
16
22
|
function iconInfo(l: (typeof links)[number]): { type: 'svg' | 'img' | 'emoji' | 'none'; value: string } {
|
|
@@ -96,13 +102,13 @@ const visible = links.filter((l) => {
|
|
|
96
102
|
case 'html':
|
|
97
103
|
return <div class="link-html" set:html={l.html} />;
|
|
98
104
|
case 'email':
|
|
99
|
-
return <a class="link-btn" style={buttonStyle(l)} href={`mailto:${l.url}`}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
|
|
105
|
+
return <a class="link-btn" style={buttonStyle(l)} href={`mailto:${l.url}`} data-umami-event={linkEvent(l.label || l.url)}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
|
|
100
106
|
case 'phone':
|
|
101
|
-
return <a class="link-btn" style={buttonStyle(l)} href={`tel:${l.url.replace(/[^+\d]/g, '')}`}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
|
|
107
|
+
return <a class="link-btn" style={buttonStyle(l)} href={`tel:${l.url.replace(/[^+\d]/g, '')}`} data-umami-event={linkEvent(l.label || l.url)}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
|
|
102
108
|
case 'vcard':
|
|
103
|
-
return <a class="link-btn" style={buttonStyle(l)} href={vcardHref(l)} download={`${(l.meta.firstName as string) || 'contact'}.vcf`}>{icon}<span class="link-label">{l.label || 'Save contact'}</span></a>;
|
|
109
|
+
return <a class="link-btn" style={buttonStyle(l)} href={vcardHref(l)} download={`${(l.meta.firstName as string) || 'contact'}.vcf`} data-umami-event={linkEvent(l.label || 'Save contact')}>{icon}<span class="link-label">{l.label || 'Save contact'}</span></a>;
|
|
104
110
|
default:
|
|
105
|
-
return <a class="link-btn" style={buttonStyle(l)} href={`/go/${l.id}`} rel="noopener">{icon}<span class="link-label">{l.label}</span></a>;
|
|
111
|
+
return <a class="link-btn" style={buttonStyle(l)} href={`/go/${l.id}`} rel="noopener" data-umami-event={linkEvent(l.label)}>{icon}<span class="link-label">{l.label}</span></a>;
|
|
106
112
|
}
|
|
107
113
|
})}
|
|
108
114
|
{visible.length === 0 && <p>No links yet.</p>}
|
|
@@ -7,9 +7,12 @@ import { verifyPreviewToken } from '../../../lib/preview';
|
|
|
7
7
|
import { expandDynamicBlocks } from '../../../lib/dynamicBlocks';
|
|
8
8
|
import { highlightCodeHtml } from '../../../lib/highlightCode';
|
|
9
9
|
import { verifySessionToken, SESSION_COOKIE } from '../../../lib/auth';
|
|
10
|
+
import { mergePageDraft, mergePostDraft } from '../../../lib/drafts';
|
|
10
11
|
|
|
11
12
|
// Draft preview, any status. Reachable by a logged-in admin (the editor's
|
|
12
|
-
// preview iframe) or by anyone holding a signed share token.
|
|
13
|
+
// preview iframe) or by anyone holding a signed share token. Live documents
|
|
14
|
+
// with pending edits render the PENDING version — exactly what Update will
|
|
15
|
+
// publish — while the public URL keeps serving the live one.
|
|
13
16
|
const { type, id } = Astro.params;
|
|
14
17
|
if (type !== 'post' && type !== 'page') return new Response(null, { status: 404 });
|
|
15
18
|
|
|
@@ -20,9 +23,11 @@ if (!isAdmin && !verifyPreviewToken(type, id!, token)) {
|
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
const repo = await getRepo(Astro.locals);
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
if (!
|
|
26
|
+
const livePost = type === 'post' ? await repo.getPost(id!) : null;
|
|
27
|
+
const livePage = type === 'page' ? await repo.getPage(id!) : null;
|
|
28
|
+
if (!livePost && !livePage) return new Response(null, { status: 404 });
|
|
29
|
+
const post = livePost ? mergePostDraft(livePost) : null;
|
|
30
|
+
const page = livePage ? mergePageDraft(livePage) : null;
|
|
26
31
|
|
|
27
32
|
const html = post ? highlightCodeHtml(await expandDynamicBlocks(post.bodyHtml || renderMarkdown(post.body), repo)) : '';
|
|
28
33
|
const pageHtml = page?.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
|
package/src/styles/admin.css
CHANGED
|
@@ -140,6 +140,8 @@ table.list { width: 100%; border-collapse: collapse; }
|
|
|
140
140
|
.pill.scheduled { background: #fde5d8; color: var(--accent-deep); }
|
|
141
141
|
.pill.archived { background: rgb(102 109 124 / 12%); color: var(--muted); }
|
|
142
142
|
.pill.home { background: #fde5d8; color: var(--accent-deep); }
|
|
143
|
+
/* live doc with staged edits awaiting Update */
|
|
144
|
+
.pill.edited { background: var(--amber-soft); color: var(--amber); margin-left: 4px; }
|
|
143
145
|
.pill.lang { background: var(--panel-2); color: var(--muted); border: 1px solid var(--line); }
|
|
144
146
|
|
|
145
147
|
/* buttons & forms */
|
|
@@ -443,6 +445,8 @@ input.bare.mono { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12
|
|
|
443
445
|
.lm-savestate { font-size: 11.5px; color: var(--faint); margin-left: auto; }
|
|
444
446
|
.lm-savestate.saving { color: var(--muted); }
|
|
445
447
|
.lm-savestate.error { color: var(--red); }
|
|
448
|
+
/* local edits awaiting an explicit "Save changes" */
|
|
449
|
+
.lm-savestate.dirty { color: var(--amber); font-weight: 600; }
|
|
446
450
|
.lm-phone {
|
|
447
451
|
border: 1px solid var(--line); border-radius: var(--r-xl); overflow: hidden;
|
|
448
452
|
background: #fff; height: min(640px, calc(100vh - 140px)); box-shadow: var(--shadow-pop);
|
package/src/styles/cards.css
CHANGED
|
@@ -174,3 +174,26 @@ a.pc-info-card:hover { border-color: var(--info-badge); }
|
|
|
174
174
|
.pc-info-row[data-icon='tag']::before { display: inline-block; -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 4h7l9 9-7 7-9-9zM8.5 8.5h.01'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 4h7l9 9-7 7-9-9zM8.5 8.5h.01'/%3E%3C/svg%3E"); }
|
|
175
175
|
.pc-info-row[data-icon='check']::before { display: inline-block; -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12.5l5 5 11-11'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12.5l5 5 11-11'/%3E%3C/svg%3E"); }
|
|
176
176
|
}
|
|
177
|
+
|
|
178
|
+
@layer base {
|
|
179
|
+
/* toggle — collapsible section, native <details> so no JS needed */
|
|
180
|
+
.pc-toggle { border: 1px solid var(--line, #e3e3e0); border-radius: 10px; padding: 12px 18px; margin: 1.6em 0; }
|
|
181
|
+
.pc-toggle > summary { font-weight: 600; cursor: pointer; list-style: none; display: flex; align-items: center; gap: 10px; }
|
|
182
|
+
.pc-toggle > summary::-webkit-details-marker { display: none; }
|
|
183
|
+
.pc-toggle > summary::before { content: "▸"; font-size: 0.8em; color: var(--muted, #777); transition: transform 0.15s ease; }
|
|
184
|
+
.pc-toggle[open] > summary::before { transform: rotate(90deg); }
|
|
185
|
+
.pc-toggle-body { margin-top: 10px; }
|
|
186
|
+
.pc-toggle-body > :last-child { margin-bottom: 0; }
|
|
187
|
+
|
|
188
|
+
/* table of contents — expanded at request time from the body's headings */
|
|
189
|
+
.pc-toc {
|
|
190
|
+
display: flex; flex-direction: column; gap: 2px;
|
|
191
|
+
border-left: 3px solid var(--accent, #6e7bff); background: rgb(0 0 0 / 3%);
|
|
192
|
+
border-radius: 0 10px 10px 0; padding: 14px 20px; margin: 1.6em 0;
|
|
193
|
+
}
|
|
194
|
+
.pc-toc-title { text-transform: uppercase; letter-spacing: 0.08em; font-size: 11.5px; font-weight: 700; color: var(--muted, #777); margin-bottom: 6px; }
|
|
195
|
+
.pc-toc-link { color: inherit; text-decoration: none; padding: 2px 0; font-size: 0.95em; }
|
|
196
|
+
a.pc-toc-link:hover { color: var(--accent, #6e7bff); }
|
|
197
|
+
.pc-toc-link[data-depth='1'] { padding-left: 16px; }
|
|
198
|
+
.pc-toc-link[data-depth='2'] { padding-left: 32px; }
|
|
199
|
+
}
|
package/src/styles/editor.css
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
}
|
|
12
12
|
.pe-topbar-left, .pe-topbar-right { display: flex; align-items: center; gap: 10px; }
|
|
13
13
|
.pe-savestate { font-size: 12.5px; color: var(--faint); }
|
|
14
|
+
.pe-edited-dot { font-style: normal; font-weight: 600; color: var(--amber); }
|
|
14
15
|
.pe-savestate.dirty { color: var(--muted); }
|
|
15
16
|
.pe-savestate.saving { color: var(--muted); }
|
|
16
17
|
.pe-savestate.error { color: var(--red); }
|
|
@@ -98,6 +99,7 @@
|
|
|
98
99
|
/* prose */
|
|
99
100
|
.pe-prose { outline: none; color: var(--text); font-size: 16.5px; line-height: 1.72; min-height: 320px; }
|
|
100
101
|
.pe-prose p { margin: 0 0 0.9em; }
|
|
102
|
+
.pe-prose h1 { font-size: 31px; letter-spacing: -0.02em; margin: 1.3em 0 0.5em; }
|
|
101
103
|
.pe-prose h2 { font-size: 25px; letter-spacing: -0.015em; margin: 1.4em 0 0.5em; }
|
|
102
104
|
.pe-prose h3 { font-size: 19px; letter-spacing: -0.01em; margin: 1.3em 0 0.4em; }
|
|
103
105
|
.pe-prose a { color: var(--accent-deep); text-decoration: underline; text-decoration-color: rgb(238 93 32 / 35%); }
|
|
@@ -106,6 +108,8 @@
|
|
|
106
108
|
.pe-prose pre { background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 14px 16px; overflow-x: auto; margin: 1.2em 0; }
|
|
107
109
|
.pe-prose pre code { background: none; border: none; padding: 0; font-size: 13.5px; }
|
|
108
110
|
.pe-prose hr { border: none; border-top: 1px solid var(--line); margin: 2em auto; width: 120px; }
|
|
111
|
+
.pe-prose mark { background: #fde58a; border-radius: 3px; padding: 0 2px; }
|
|
112
|
+
.pe-bubble mark { background: #fde58a; border-radius: 3px; padding: 0 4px; color: #1a1d25; }
|
|
109
113
|
|
|
110
114
|
/* ---- code block (extensions/codeBlock.tsx) ---- */
|
|
111
115
|
.pe-codeblock { position: relative; margin: 1.2em 0; }
|
|
@@ -246,6 +250,12 @@
|
|
|
246
250
|
background: none; border: none; border-radius: var(--r-md); padding: 7px 10px; cursor: pointer; font-family: inherit;
|
|
247
251
|
}
|
|
248
252
|
.pe-slash-item.is-selected { background: var(--accent-soft); }
|
|
253
|
+
.pe-slash-del {
|
|
254
|
+
margin-left: auto; padding: 2px 7px; border-radius: 6px; color: var(--faint);
|
|
255
|
+
font-size: 11px; opacity: 0; transition: opacity 0.1s ease;
|
|
256
|
+
}
|
|
257
|
+
.pe-slash-item:hover .pe-slash-del, .pe-slash-item.is-selected .pe-slash-del { opacity: 1; }
|
|
258
|
+
.pe-slash-del:hover { color: var(--red); background: var(--red-soft); }
|
|
249
259
|
.pe-slash-icon { width: 30px; height: 30px; flex: 0 0 auto; display: grid; place-items: center; background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-sm); color: var(--muted); }
|
|
250
260
|
.pe-slash-icon svg { width: 16px; height: 16px; }
|
|
251
261
|
.pe-slash-item.is-selected .pe-slash-icon { color: var(--accent-deep); }
|
|
@@ -482,14 +492,27 @@
|
|
|
482
492
|
/* floating link editor (⌘K) */
|
|
483
493
|
.pe-linkedit {
|
|
484
494
|
position: sticky; top: 12px; z-index: 60;
|
|
485
|
-
display: flex; gap: 8px; align-items: center;
|
|
486
495
|
background: var(--panel, #fff); border: 1px solid var(--border, #d5d2ca);
|
|
487
496
|
border-radius: 10px; padding: 8px 10px; margin: 0 auto 12px;
|
|
488
497
|
max-width: 560px; box-shadow: 0 10px 30px rgb(0 0 0 / 14%);
|
|
489
498
|
}
|
|
499
|
+
.pe-linkedit-row { display: flex; gap: 8px; align-items: center; }
|
|
490
500
|
.pe-linkedit input {
|
|
491
501
|
flex: 1; border: 0; outline: none; font-size: 14px; background: none; color: inherit;
|
|
492
502
|
}
|
|
503
|
+
.pe-link-results {
|
|
504
|
+
border-top: 1px solid var(--line, #e3e5ec); margin-top: 8px; padding-top: 6px;
|
|
505
|
+
display: flex; flex-direction: column;
|
|
506
|
+
}
|
|
507
|
+
.pe-link-results button {
|
|
508
|
+
display: flex; align-items: baseline; gap: 8px; border: 0; background: none;
|
|
509
|
+
text-align: left; padding: 7px 8px; border-radius: 8px; cursor: pointer;
|
|
510
|
+
font: inherit; color: var(--text, #1a1d25);
|
|
511
|
+
}
|
|
512
|
+
.pe-link-results button.is-selected { background: var(--panel-2, #f4f5f8); }
|
|
513
|
+
.pe-link-title { font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
514
|
+
.pe-link-url { font-size: 12px; color: var(--faint, #8f95a3); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
515
|
+
.pe-link-kind { font-size: 10px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--faint, #8f95a3); }
|
|
493
516
|
|
|
494
517
|
/* shortcut cheat-sheet */
|
|
495
518
|
.pe-wordcount { display: flex; align-items: center; gap: 10px; }
|
|
@@ -594,3 +617,32 @@
|
|
|
594
617
|
.pe-infocard-icon svg { width: 14px; height: 14px; }
|
|
595
618
|
.pe-infocard-foot { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
|
596
619
|
.pe-infocard-href { flex: 1; }
|
|
620
|
+
|
|
621
|
+
/* toggle card (editor) — cards.css isn't loaded in the admin, so the box
|
|
622
|
+
look is restated here on the editor tokens */
|
|
623
|
+
.pe-toggle { border: 1px solid var(--line); border-radius: var(--r-md); padding: 12px 16px; margin: 1.2em 0; }
|
|
624
|
+
.pe-toggle.is-selected { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
|
|
625
|
+
.pe-toggle .pe-toggle-head { display: flex; align-items: center; gap: 8px; }
|
|
626
|
+
.pe-toggle-chevron {
|
|
627
|
+
border: 0; background: none; cursor: pointer; color: var(--muted, #666d7c);
|
|
628
|
+
font-size: 13px; padding: 2px 4px; transition: transform 0.15s ease;
|
|
629
|
+
}
|
|
630
|
+
.pe-toggle.is-open .pe-toggle-chevron { transform: rotate(90deg); }
|
|
631
|
+
.pe-toggle-summary { flex: 1; border: 0; outline: none; background: none; font: inherit; font-weight: 600; color: inherit; }
|
|
632
|
+
.pe-toggle .pc-toggle-body { margin-top: 10px; }
|
|
633
|
+
|
|
634
|
+
/* toc card (editor) */
|
|
635
|
+
.pe-toc .pc-toc {
|
|
636
|
+
display: flex; flex-direction: column; gap: 2px; margin: 0;
|
|
637
|
+
border-left: 3px solid var(--accent); background: var(--panel-2);
|
|
638
|
+
border-radius: 0 var(--r-md) var(--r-md) 0; padding: 14px 18px;
|
|
639
|
+
}
|
|
640
|
+
.pe-toc .pc-toc-link { padding: 2px 0; font-size: 14.5px; }
|
|
641
|
+
.pe-toc .pc-toc-link[data-depth='1'] { padding-left: 16px; }
|
|
642
|
+
.pe-toc .pc-toc-link[data-depth='2'] { padding-left: 32px; }
|
|
643
|
+
.pe-toc-title {
|
|
644
|
+
border: 0; outline: none; background: none; text-transform: uppercase;
|
|
645
|
+
letter-spacing: 0.08em; font-size: 11.5px; font-weight: 700;
|
|
646
|
+
color: var(--muted, #666d7c); margin-bottom: 6px; padding: 0;
|
|
647
|
+
}
|
|
648
|
+
.pe-toc-empty { color: var(--faint, #8f95a3); font-size: 13.5px; }
|
package/src/styles/site.css
CHANGED
|
@@ -77,6 +77,7 @@ pre .hljs-strong { font-weight: 700; }
|
|
|
77
77
|
code { font-family: ui-monospace, 'SF Mono', monospace; font-size: 0.9em; }
|
|
78
78
|
p code { background: var(--soft); padding: 2px 5px; border-radius: 4px; }
|
|
79
79
|
blockquote { margin: 16px 0; padding: 4px 18px; border-left: 3px solid var(--line); color: var(--muted); }
|
|
80
|
+
mark { background: #fde58a; border-radius: 3px; padding: 0 2px; }
|
|
80
81
|
table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 15px; line-height: 1.5; }
|
|
81
82
|
th, td { border: 1px solid var(--line); padding: 8px 12px; text-align: left; vertical-align: top; }
|
|
82
83
|
th { background: var(--soft); font-weight: 600; }
|
|
@@ -233,6 +234,8 @@ a.tag:hover { background: var(--accent); color: #fff; }
|
|
|
233
234
|
.post-kicker:empty { display: none; }
|
|
234
235
|
.post-layout { margin-top: 8px; }
|
|
235
236
|
.post-content > :first-child { margin-top: 0; }
|
|
237
|
+
/* in-body h1: below the post title's scale, above h2 */
|
|
238
|
+
.post-content h1 { font-size: 30px; margin: 1.2em 0 0.4em; }
|
|
236
239
|
|
|
237
240
|
.related-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; }
|
|
238
241
|
.related-card h3 { font-size: 18px; line-height: 1.3; margin: 4px 0 4px; }
|