@acms-subir/core 0.2.0 → 0.3.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/CHANGELOG.md +87 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -369
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/live-video.md +91 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +189 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +578 -49
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +418 -46
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +119 -58
- package/src/components/LiveCard.astro +174 -0
- package/src/components/LiveManager.tsx +910 -0
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +58 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/WidgetRenderer.astro +3 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +352 -59
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +56 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +110 -12
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/live/client.ts +88 -0
- package/src/lib/live/crypto.ts +28 -0
- package/src/lib/live/migration.ts +15 -0
- package/src/lib/live/providers.ts +152 -0
- package/src/lib/live/service.ts +170 -0
- package/src/lib/live/signals.ts +89 -0
- package/src/lib/live/store.ts +49 -0
- package/src/lib/live/types.ts +52 -0
- package/src/lib/live/view.ts +78 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +221 -0
- package/src/lib/repo/sql.ts +907 -68
- package/src/lib/repo/types.ts +125 -5
- package/src/lib/scheduler.ts +27 -0
- package/src/lib/schema.ts +316 -1
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +183 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhook-sql.ts +68 -0
- package/src/lib/webhooks.ts +147 -0
- package/src/lib/widgets.ts +1 -0
- package/src/middleware.ts +45 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +14 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +4 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- package/src/pages/admin/integrations.astro +237 -0
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/live.astro +12 -0
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +66 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
- package/src/pages/api/live/settings.ts +45 -0
- package/src/pages/api/live/status.ts +7 -0
- package/src/pages/api/live/thumb.ts +48 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/api/webhooks/docs.ts +61 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +137 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +12 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +29 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/live-admin.css +126 -0
- package/src/styles/live.css +199 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +81 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +55 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +27 -37
- package/src/themes/editorial/theme.json +25 -3
- package/src/themes/field/theme.json +27 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +28 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
import Site from '../../layouts/Site.astro';
|
|
3
3
|
import { getRepo } from '../../lib/repo';
|
|
4
4
|
import { renderMarkdown } from '../../lib/markdown';
|
|
5
|
-
import { expandDynamicBlocks } from '../../lib/dynamicBlocks';
|
|
5
|
+
import { expandDynamicBlocks, extractToc } from '../../lib/dynamicBlocks';
|
|
6
|
+
import TagLabel from '../../components/site/TagLabel.astro';
|
|
7
|
+
import PostCard from '../../components/site/PostCard.astro';
|
|
6
8
|
import { highlightCodeHtml } from '../../lib/highlightCode';
|
|
7
|
-
import {
|
|
9
|
+
import { validateSession, SESSION_COOKIE } from '../../lib/auth';
|
|
8
10
|
import { slugify, authorSlug, publicTags } from '../../lib/schema';
|
|
9
11
|
import { resolveDesign } from '../../lib/theme';
|
|
10
12
|
import { effectiveSettings } from '../../lib/designPreview';
|
|
@@ -20,7 +22,7 @@ const { slug } = Astro.params;
|
|
|
20
22
|
const repo = await getRepo(Astro.locals);
|
|
21
23
|
const post = slug ? await repo.getPostBySlug(slug, col.key) : null;
|
|
22
24
|
|
|
23
|
-
const isAdmin =
|
|
25
|
+
const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
|
|
24
26
|
if (!post || (post.status !== 'published' && !isAdmin)) {
|
|
25
27
|
return new Response(null, { status: 404, statusText: 'Not found' });
|
|
26
28
|
}
|
|
@@ -33,7 +35,7 @@ const [tags, allUsers, allPosts, rawSettings, allMedia] = await Promise.all([
|
|
|
33
35
|
post.cover.startsWith('/media/') ? repo.listMedia() : Promise.resolve([]),
|
|
34
36
|
]);
|
|
35
37
|
// ?designPreview=1 (admin-only) renders the staged Design-screen changes
|
|
36
|
-
const settings = effectiveSettings(rawSettings, Astro.url, Astro.cookies);
|
|
38
|
+
const settings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
|
|
37
39
|
// ordered author objects; the first is the primary (gets the author card)
|
|
38
40
|
const authors = authorIds
|
|
39
41
|
.map((id) => allUsers.find((u) => u.id === id))
|
|
@@ -72,6 +74,13 @@ const byOverlap = allPosts
|
|
|
72
74
|
.sort((a, b) => b.shared - a.shared || (b.p.publishedAt ?? 0) - (a.p.publishedAt ?? 0))
|
|
73
75
|
.map((r) => r.p);
|
|
74
76
|
const related = byOverlap.slice(0, 3);
|
|
77
|
+
const relatedReadMins = (p: (typeof allPosts)[number]) =>
|
|
78
|
+
Math.max(1, Math.round((p.bodyHtml || p.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length / 220));
|
|
79
|
+
const tagLinks = (names: string[]) => publicTags(names).map((n) => ({ name: n, href: tagUrl(tagSlug(n)) }));
|
|
80
|
+
// "On this page" rail: only worth it with a few sections to jump between
|
|
81
|
+
const toc = extractToc(html).filter((i) => i.level > 1);
|
|
82
|
+
const showToc = po.toc && toc.length >= 3;
|
|
83
|
+
const tocMin = toc.length ? Math.min(...toc.map((i) => i.level)) : 2;
|
|
75
84
|
|
|
76
85
|
const heroOverlay = po.cover === 'overlay' && Boolean(post.cover);
|
|
77
86
|
const iso = (ts: number | null) => (ts ? new Date(ts).toISOString() : undefined);
|
|
@@ -111,11 +120,10 @@ const shareLi = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURI
|
|
|
111
120
|
{PostTemplate ? (
|
|
112
121
|
<PostTemplate post={post} html={html} authors={authors} settings={settings} related={related} />
|
|
113
122
|
) : (
|
|
114
|
-
|
|
123
|
+
<>
|
|
124
|
+
<article class="post">
|
|
115
125
|
{post.status !== 'published' && (
|
|
116
|
-
<p
|
|
117
|
-
Draft preview — only you can see this post.
|
|
118
|
-
</p>
|
|
126
|
+
<p class="notice is-warning">Draft preview. Only you can see this post.</p>
|
|
119
127
|
)}
|
|
120
128
|
{heroOverlay ? (
|
|
121
129
|
<header class="post-hero">
|
|
@@ -131,7 +139,7 @@ const shareLi = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURI
|
|
|
131
139
|
<div class="post-hero-scrim" aria-hidden="true" />
|
|
132
140
|
<div class="post-hero-content">
|
|
133
141
|
<div class="post-kicker">
|
|
134
|
-
{visibleTags.map((t) => <
|
|
142
|
+
{visibleTags.map((t) => <TagLabel name={t} href={tagUrl(tagSlug(t))} />)}
|
|
135
143
|
</div>
|
|
136
144
|
<h1>{post.title}</h1>
|
|
137
145
|
{(metaBits || (po.byline && author)) && (
|
|
@@ -150,52 +158,80 @@ const shareLi = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURI
|
|
|
150
158
|
</header>
|
|
151
159
|
) : (
|
|
152
160
|
<header class="post-header">
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
161
|
+
{visibleTags.length > 0 && (
|
|
162
|
+
<div class="post-kicker">
|
|
163
|
+
{visibleTags.map((t) => <TagLabel name={t} href={tagUrl(tagSlug(t))} />)}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
156
166
|
<h1>{post.title}</h1>
|
|
157
|
-
{
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
167
|
+
{post.excerpt && <p class="post-dek">{post.excerpt}</p>}
|
|
168
|
+
{(metaBits || (po.byline && author) || po.share) && (
|
|
169
|
+
<div class="post-byline-row">
|
|
170
|
+
<div class="post-byline">
|
|
171
|
+
{po.byline && authors.length > 0 && (
|
|
172
|
+
<span class="avatar-stack">
|
|
173
|
+
{authors.slice(0, 3).map((a) => a.avatar
|
|
174
|
+
? <img class="avatar" src={a.avatar} alt="" />
|
|
175
|
+
: <span class="avatar avatar-empty" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="8.5" r="3.5" /><path d="M5 20a7 7 0 0 1 14 0" /></svg></span>
|
|
176
|
+
)}
|
|
177
|
+
</span>
|
|
178
|
+
)}
|
|
179
|
+
<div>
|
|
180
|
+
{po.byline && authors.length > 0 && (
|
|
181
|
+
<span class="byline">{authors.map((a, i) => (
|
|
182
|
+
<>{i > 0 && (i === authors.length - 1 ? ' & ' : ', ')}<a href={`/author/${authorSlug(a)}`}>{a.name}</a></>
|
|
183
|
+
))}</span>
|
|
184
|
+
)}
|
|
185
|
+
{metaBits && (
|
|
186
|
+
<span class="post-meta">
|
|
187
|
+
{po.showDate && post.publishedAt && <time datetime={iso(post.publishedAt)}>{fmt(post.publishedAt)}</time>}
|
|
188
|
+
{po.showDate && post.publishedAt && po.showReadingTime && ' \u00b7 '}
|
|
189
|
+
{po.showReadingTime && `${readMins} min read`}
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
{po.share && (
|
|
195
|
+
<div class="post-share" aria-label="Share this post">
|
|
196
|
+
<a href={shareX} target="_blank" rel="noopener" aria-label="Share on X">
|
|
197
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.9 2H22l-6.8 7.8L23.2 22h-6.3l-4.9-6.4L6.4 22H3.3l7.3-8.3L1.6 2H8l4.4 5.9L18.9 2zm-1.1 18h1.7L7.1 3.7H5.3L17.8 20z"/></svg>
|
|
198
|
+
</a>
|
|
199
|
+
<a href={shareLi} target="_blank" rel="noopener" aria-label="Share on LinkedIn">
|
|
200
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.4 20.4h-3.5v-5.6c0-1.3 0-3-1.9-3s-2.1 1.4-2.1 2.9v5.7H9.4V9h3.4v1.6a3.7 3.7 0 0 1 3.3-1.8c3.6 0 4.2 2.3 4.2 5.4v6.2zM5.3 7.4a2 2 0 1 1 0-4.1 2 2 0 0 1 0 4.1zM7.1 20.4H3.6V9h3.5v11.4z"/></svg>
|
|
201
|
+
</a>
|
|
202
|
+
<button type="button" class="post-share-copy" data-url={shareUrl} aria-label="Copy link">
|
|
203
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" aria-hidden="true"><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>
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
166
206
|
)}
|
|
167
207
|
</div>
|
|
168
208
|
)}
|
|
169
209
|
</header>
|
|
170
210
|
)}
|
|
171
211
|
{post.cover && !heroOverlay && po.cover !== 'hidden' && (
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
212
|
+
<figure class:list={['post-cover-wrap', { 'is-full': po.cover === 'full' }]}>
|
|
213
|
+
<img
|
|
214
|
+
class:list={['post-cover', { 'is-full': po.cover === 'full' }]}
|
|
215
|
+
src={post.cover}
|
|
216
|
+
alt={post.coverAlt}
|
|
217
|
+
width={coverMedia?.width || undefined}
|
|
218
|
+
height={coverMedia?.height || undefined}
|
|
219
|
+
srcset={coverSrcset}
|
|
220
|
+
sizes={coverSrcset ? (po.cover === 'full' ? '100vw' : '(max-width: 1000px) 100vw, 1000px') : undefined}
|
|
221
|
+
/>
|
|
222
|
+
</figure>
|
|
181
223
|
)}
|
|
182
224
|
|
|
183
|
-
<div class=
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
</
|
|
191
|
-
<a href={shareLi} target="_blank" rel="noopener" aria-label="Share on LinkedIn">
|
|
192
|
-
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M20.4 20.4h-3.5v-5.6c0-1.3 0-3-1.9-3s-2.1 1.4-2.1 2.9v5.7H9.4V9h3.4v1.6a3.7 3.7 0 0 1 3.3-1.8c3.6 0 4.2 2.3 4.2 5.4v6.2zM5.3 7.4a2 2 0 1 1 0-4.1 2 2 0 0 1 0 4.1zM7.1 20.4H3.6V9h3.5v11.4z"/></svg>
|
|
193
|
-
</a>
|
|
194
|
-
<button type="button" class="post-share-copy" data-url={shareUrl} aria-label="Copy link">
|
|
195
|
-
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><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>
|
|
196
|
-
</button>
|
|
197
|
-
</aside>
|
|
225
|
+
<div class:list={['post-layout', { 'has-toc': showToc }]}>
|
|
226
|
+
{showToc && (
|
|
227
|
+
<aside class="post-toc">
|
|
228
|
+
<nav aria-label="On this page">
|
|
229
|
+
<span class="post-toc-title">On this page</span>
|
|
230
|
+
{toc.map((i) => <a href={`#${i.id}`} data-depth={i.level - tocMin}>{i.text}</a>)}
|
|
231
|
+
</nav>
|
|
232
|
+
</aside>
|
|
198
233
|
)}
|
|
234
|
+
<div class="post-content" set:html={html} />
|
|
199
235
|
</div>
|
|
200
236
|
|
|
201
237
|
{po.authorCard && author && (
|
|
@@ -203,34 +239,38 @@ const shareLi = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURI
|
|
|
203
239
|
{author.avatar ? (
|
|
204
240
|
<img class="author-avatar" src={author.avatar} alt={author.name} />
|
|
205
241
|
) : (
|
|
206
|
-
<span class="author-avatar
|
|
242
|
+
<span class="author-avatar avatar-empty" aria-hidden="true"><svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="8.5" r="3.5" /><path d="M5 20a7 7 0 0 1 14 0" /></svg></span>
|
|
207
243
|
)}
|
|
208
|
-
<div>
|
|
244
|
+
<div class="author-card-text">
|
|
209
245
|
<h2 class="author-card-name"><a href={`/author/${authorSlug(author)}`}>{author.name}</a></h2>
|
|
210
246
|
{author.bio && <p class="author-card-bio">{author.bio}</p>}
|
|
211
|
-
<a class="author-card-more" href={`/author/${authorSlug(author)}`}>More posts</a>
|
|
212
247
|
</div>
|
|
248
|
+
<a class="btn secondary sm author-card-more" href={`/author/${authorSlug(author)}`}>More posts</a>
|
|
213
249
|
</aside>
|
|
214
250
|
)}
|
|
215
|
-
|
|
216
|
-
{po.related && related.length > 0 && (
|
|
217
|
-
<section class="related">
|
|
218
|
-
<h2 class="related-title">Related reading</h2>
|
|
219
|
-
<div class="related-grid">
|
|
220
|
-
{related.map((p) => (
|
|
221
|
-
<article class="related-card">
|
|
222
|
-
{p.cover && (
|
|
223
|
-
<a class="related-thumb" href={collectionUrl(col.key, p.slug)}><img src={p.cover} alt={p.coverAlt} loading="lazy" /></a>
|
|
224
|
-
)}
|
|
225
|
-
<div class="post-meta">{p.publishedAt && <time datetime={iso(p.publishedAt)}>{fmt(p.publishedAt)}</time>}</div>
|
|
226
|
-
<h3><a href={collectionUrl(col.key, p.slug)}>{p.title}</a></h3>
|
|
227
|
-
{p.excerpt && <p class="related-excerpt">{p.excerpt}</p>}
|
|
228
|
-
</article>
|
|
229
|
-
))}
|
|
230
|
-
</div>
|
|
231
|
-
</section>
|
|
232
|
-
)}
|
|
233
251
|
</article>
|
|
252
|
+
|
|
253
|
+
{po.related && related.length > 0 && (
|
|
254
|
+
<section class="related wide">
|
|
255
|
+
<h2 class="related-title">Keep reading</h2>
|
|
256
|
+
<div class="post-grid is-cards">
|
|
257
|
+
{related.map((p) => (
|
|
258
|
+
<PostCard
|
|
259
|
+
href={collectionUrl(col.key, p.slug)}
|
|
260
|
+
title={p.title}
|
|
261
|
+
cover={p.cover}
|
|
262
|
+
coverAlt={p.coverAlt}
|
|
263
|
+
date={formatDate(p.publishedAt, settings, 'short')}
|
|
264
|
+
datetime={iso(p.publishedAt)}
|
|
265
|
+
readMins={relatedReadMins(p)}
|
|
266
|
+
tags={tagLinks(p.tags)}
|
|
267
|
+
/>
|
|
268
|
+
))}
|
|
269
|
+
</div>
|
|
270
|
+
</section>
|
|
271
|
+
)}
|
|
272
|
+
{po.progress && <div class="read-progress" aria-hidden="true"><i></i></div>}
|
|
273
|
+
</>
|
|
234
274
|
)}
|
|
235
275
|
<script>
|
|
236
276
|
document.querySelector<HTMLButtonElement>('.post-share-copy')?.addEventListener('click', async (e) => {
|
|
@@ -239,5 +279,36 @@ const shareLi = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURI
|
|
|
239
279
|
btn.classList.add('is-copied');
|
|
240
280
|
setTimeout(() => btn.classList.remove('is-copied'), 1200);
|
|
241
281
|
});
|
|
282
|
+
|
|
283
|
+
// reading progress: how far the article body has scrolled past the top
|
|
284
|
+
const bar = document.querySelector<HTMLElement>('.read-progress i');
|
|
285
|
+
const body = document.querySelector<HTMLElement>('.post-content');
|
|
286
|
+
if (bar && body) {
|
|
287
|
+
const update = () => {
|
|
288
|
+
const r = body.getBoundingClientRect();
|
|
289
|
+
const total = r.height - window.innerHeight * 0.6;
|
|
290
|
+
const done = Math.min(1, Math.max(0, -r.top / Math.max(1, total)));
|
|
291
|
+
bar.style.transform = `scaleX(${done})`;
|
|
292
|
+
};
|
|
293
|
+
addEventListener('scroll', update, { passive: true });
|
|
294
|
+
addEventListener('resize', update);
|
|
295
|
+
update();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// "On this page": mark the section currently in view
|
|
299
|
+
const links = [...document.querySelectorAll<HTMLAnchorElement>('.post-toc a')];
|
|
300
|
+
if (links.length && 'IntersectionObserver' in window) {
|
|
301
|
+
const byId = new Map(links.map((a) => [decodeURIComponent(a.hash.slice(1)), a]));
|
|
302
|
+
const visible = new Set<string>();
|
|
303
|
+
const mark = () => {
|
|
304
|
+
const first = [...byId.keys()].find((id) => visible.has(id));
|
|
305
|
+
if (first) links.forEach((a) => a.toggleAttribute('aria-current', a === byId.get(first)));
|
|
306
|
+
};
|
|
307
|
+
const io = new IntersectionObserver((entries) => {
|
|
308
|
+
entries.forEach((e) => (e.isIntersecting ? visible.add(e.target.id) : visible.delete(e.target.id)));
|
|
309
|
+
mark();
|
|
310
|
+
}, { rootMargin: '-80px 0px -55% 0px' });
|
|
311
|
+
byId.forEach((_, id) => { const el = document.getElementById(id); if (el) io.observe(el); });
|
|
312
|
+
}
|
|
242
313
|
</script>
|
|
243
314
|
</Site>
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Site from '../../layouts/Site.astro';
|
|
3
|
+
import PostCard from '../../components/site/PostCard.astro';
|
|
4
|
+
import Pager from '../../components/site/Pager.astro';
|
|
3
5
|
import { getRepo } from '../../lib/repo';
|
|
4
|
-
import { publicTags, slugify } from '../../lib/schema';
|
|
6
|
+
import { publicTags, slugify, authorSlug } from '../../lib/schema';
|
|
5
7
|
import { formatDate } from '../../lib/dates';
|
|
8
|
+
import { resolveDesign } from '../../lib/theme';
|
|
9
|
+
import { effectiveSettings } from '../../lib/designPreview';
|
|
6
10
|
import { collectionFromPath, collectionUrl, tagUrl } from '../../lib/content';
|
|
7
11
|
|
|
8
12
|
// One file serves every collection's index — injectRoute can't pass props,
|
|
@@ -10,42 +14,103 @@ import { collectionFromPath, collectionUrl, tagUrl } from '../../lib/content';
|
|
|
10
14
|
const col = collectionFromPath(Astro.url.pathname);
|
|
11
15
|
if (!col) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
12
16
|
|
|
13
|
-
const PAGE_SIZE =
|
|
17
|
+
const PAGE_SIZE = 12;
|
|
14
18
|
const repo = await getRepo(Astro.locals);
|
|
15
|
-
const [allPosts, tags, users,
|
|
19
|
+
const [allPosts, tags, users, rawSettings] = await Promise.all([repo.listPublishedPosts(col.key), repo.listTags(), repo.listUsers(), repo.getSettings()]);
|
|
20
|
+
const settings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
|
|
21
|
+
const design = resolveDesign(settings);
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
// page 1 leads with the featured post (if any) as a big split card; it's
|
|
24
|
+
// pulled out of the grid so it never shows twice
|
|
25
|
+
const featured = allPosts.find((p) => p.featured) ?? null;
|
|
26
|
+
const rest = featured ? allPosts.filter((p) => p.id !== featured.id) : allPosts;
|
|
27
|
+
const pageCount = Math.max(1, Math.ceil(rest.length / PAGE_SIZE));
|
|
18
28
|
const pageNum = Math.min(pageCount, Math.max(1, Number(Astro.url.searchParams.get('page')) || 1));
|
|
19
|
-
const posts =
|
|
29
|
+
const posts = rest.slice((pageNum - 1) * PAGE_SIZE, pageNum * PAGE_SIZE);
|
|
30
|
+
const lead = pageNum === 1 ? featured : null;
|
|
31
|
+
|
|
32
|
+
// 'auto' picks cards when at least half the posts have covers — a text-first
|
|
33
|
+
// blog gets honest list rows instead of a grid of generated tiles
|
|
34
|
+
const withCover = allPosts.filter((p) => p.cover).length;
|
|
35
|
+
const layout = design.tokens.blogLayout === 'auto'
|
|
36
|
+
? (allPosts.length && withCover / allPosts.length >= 0.5 ? 'cards' : 'list')
|
|
37
|
+
: design.tokens.blogLayout;
|
|
20
38
|
|
|
21
39
|
const tagSlug = (name: string) =>
|
|
22
40
|
tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.slug ?? slugify(name);
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
41
|
+
const tagLinks = (names: string[]) => publicTags(names).map((n) => ({ name: n, href: tagUrl(tagSlug(n)) }));
|
|
42
|
+
const readMins = (p: (typeof allPosts)[number]) =>
|
|
43
|
+
Math.max(1, Math.round((p.bodyHtml || p.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length / 220));
|
|
44
|
+
const authorOf = (p: (typeof allPosts)[number]) => {
|
|
45
|
+
const id = p.authorIds[0] ?? p.authorId;
|
|
46
|
+
const u = id ? users.find((x) => x.id === id) : undefined;
|
|
47
|
+
return u ? { name: u.name, avatar: u.avatar || undefined, href: `/author/${authorSlug(u)}` } : null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// filter tabs: the collection's most-used public tags, with counts
|
|
51
|
+
// (case-insensitive: "Product" and "product" are one tag, first spelling wins)
|
|
52
|
+
const counts = new Map<string, [string, number]>();
|
|
53
|
+
for (const p of allPosts) for (const t of publicTags(p.tags)) {
|
|
54
|
+
const k = t.toLowerCase();
|
|
55
|
+
counts.set(k, [counts.get(k)?.[0] ?? t, (counts.get(k)?.[1] ?? 0) + 1]);
|
|
56
|
+
}
|
|
57
|
+
const topTags = [...counts.values()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 6);
|
|
58
|
+
const iso = (ts: number | null) => (ts ? new Date(ts).toISOString() : undefined);
|
|
26
59
|
---
|
|
27
60
|
|
|
28
61
|
<Site title={col.plural}>
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
<div class="wide blog-index">
|
|
63
|
+
<header class="page-head">
|
|
64
|
+
<h1>{col.plural}</h1>
|
|
65
|
+
{settings.tagline && <p class="page-lede">{settings.tagline}</p>}
|
|
66
|
+
</header>
|
|
67
|
+
{topTags.length > 1 && (
|
|
68
|
+
<nav class="filter-tabs" aria-label="Filter by tag">
|
|
69
|
+
<a href={col.base} aria-current="page">All <small>{allPosts.length}</small></a>
|
|
70
|
+
{topTags.map(([name, n]) => <a href={tagUrl(tagSlug(name))}>{name} <small>{n}</small></a>)}
|
|
71
|
+
</nav>
|
|
72
|
+
)}
|
|
73
|
+
{allPosts.length === 0 && (
|
|
74
|
+
<div class="empty-state">
|
|
75
|
+
<span class="empty-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 13 6.5 5h11L20 13v5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18v-5Z" /><path d="M4 13h4.5l1 2h5l1-2H20" /></svg></span>
|
|
76
|
+
<b>No {col.plural.toLowerCase()} yet</b>
|
|
77
|
+
<span>New {col.plural.toLowerCase()} will show up here.</span>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
{lead && (
|
|
81
|
+
<PostCard
|
|
82
|
+
variant="feature"
|
|
83
|
+
headingLevel={2}
|
|
84
|
+
href={collectionUrl(col.key, lead.slug)}
|
|
85
|
+
title={lead.title}
|
|
86
|
+
excerpt={lead.excerpt}
|
|
87
|
+
cover={lead.cover}
|
|
88
|
+
coverAlt={lead.coverAlt}
|
|
89
|
+
date={formatDate(lead.publishedAt, settings, 'short')}
|
|
90
|
+
datetime={iso(lead.publishedAt)}
|
|
91
|
+
readMins={readMins(lead)}
|
|
92
|
+
tags={tagLinks(lead.tags)}
|
|
93
|
+
featured
|
|
94
|
+
author={authorOf(lead)}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
<div class:list={['post-grid', `is-${layout}`]}>
|
|
98
|
+
{posts.map((p) => (
|
|
99
|
+
<PostCard
|
|
100
|
+
variant={layout === 'cards' ? 'card' : 'row'}
|
|
101
|
+
headingLevel={2}
|
|
102
|
+
href={collectionUrl(col.key, p.slug)}
|
|
103
|
+
title={p.title}
|
|
104
|
+
excerpt={p.excerpt}
|
|
105
|
+
cover={p.cover}
|
|
106
|
+
coverAlt={p.coverAlt}
|
|
107
|
+
date={formatDate(p.publishedAt, settings, 'short')}
|
|
108
|
+
datetime={iso(p.publishedAt)}
|
|
109
|
+
readMins={readMins(p)}
|
|
110
|
+
tags={tagLinks(p.tags)}
|
|
111
|
+
/>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
<Pager base={col.base} page={pageNum} count={pageCount} />
|
|
115
|
+
</div>
|
|
51
116
|
</Site>
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Site from '../../../layouts/Site.astro';
|
|
3
|
+
import PostCard from '../../../components/site/PostCard.astro';
|
|
4
|
+
import Pager from '../../../components/site/Pager.astro';
|
|
3
5
|
import { getRepo } from '../../../lib/repo';
|
|
4
|
-
import {
|
|
6
|
+
import { tagColor } from '../../../lib/tagColor';
|
|
7
|
+
import { isInternalTag, publicTags, slugify } from '../../../lib/schema';
|
|
5
8
|
import { formatDate } from '../../../lib/dates';
|
|
6
9
|
import { CONTENT, collectionUrl, postsIndexUrl, tagUrl } from '../../../lib/content';
|
|
7
10
|
|
|
8
|
-
const PAGE_SIZE =
|
|
11
|
+
const PAGE_SIZE = 12;
|
|
9
12
|
const { slug } = Astro.params;
|
|
10
13
|
const repo = await getRepo(Astro.locals);
|
|
11
|
-
const [tag, settings] = await Promise.all([slug ? repo.getTagBySlug(slug) : Promise.resolve(null), repo.getSettings()]);
|
|
14
|
+
const [tag, settings, tags] = await Promise.all([slug ? repo.getTagBySlug(slug) : Promise.resolve(null), repo.getSettings(), repo.listTags()]);
|
|
12
15
|
// internal (#-prefixed) tags never get a public archive
|
|
13
16
|
if (!tag || isInternalTag(tag.name)) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
14
17
|
|
|
@@ -40,44 +43,46 @@ const fmt = (ts: number | null) =>
|
|
|
40
43
|
formatDate(ts, settings, 'short');
|
|
41
44
|
const readMins = (p: (typeof posts)[number]) =>
|
|
42
45
|
Math.max(1, Math.round((p.bodyHtml || p.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length / 220));
|
|
46
|
+
const tagSlug = (name: string) =>
|
|
47
|
+
tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.slug ?? slugify(name);
|
|
48
|
+
// the archive's own tag is implied, so cards show the post's other tags
|
|
49
|
+
const tagLinks = (names: string[]) =>
|
|
50
|
+
publicTags(names).filter((n) => n.toLowerCase() !== tag.name.toLowerCase()).map((n) => ({ name: n, href: tagUrl(tagSlug(n)) }));
|
|
51
|
+
const layout = allPosts.length && allPosts.filter((p) => p.cover).length / allPosts.length >= 0.5 ? 'cards' : 'list';
|
|
52
|
+
const iso = (ts: number | null) => (ts ? new Date(ts).toISOString() : undefined);
|
|
43
53
|
---
|
|
44
54
|
|
|
45
55
|
<Site title={tag.name} description={tag.description || `Tagged ${tag.name}`}>
|
|
46
|
-
<
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
{
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
</
|
|
77
|
-
|
|
78
|
-
<p style="margin-top:28px"><a href={postsIndexUrl()}>← All {CONTENT.posts.plural.toLowerCase()}</a></p>
|
|
56
|
+
<div class="wide archive">
|
|
57
|
+
<header class="page-head">
|
|
58
|
+
<p class="page-kicker"><span class="tag" style={`--tc:${tagColor(tag.name)}`}>Tag</span> <span>{allPosts.length} {countNoun}</span></p>
|
|
59
|
+
<h1>{tag.name}</h1>
|
|
60
|
+
{tag.description && <p class="page-lede">{tag.description}</p>}
|
|
61
|
+
</header>
|
|
62
|
+
{posts.length === 0 && <p class="muted-note">Nothing with this tag yet.</p>}
|
|
63
|
+
{groups.map((g) => (
|
|
64
|
+
<>
|
|
65
|
+
{multi && <h2 class="archive-group">{g.plural}</h2>}
|
|
66
|
+
<div class:list={['post-grid', `is-${layout}`]}>
|
|
67
|
+
{g.posts.map((p) => (
|
|
68
|
+
<PostCard
|
|
69
|
+
variant={layout === 'cards' ? 'card' : 'row'}
|
|
70
|
+
headingLevel={multi ? 3 : 2}
|
|
71
|
+
href={collectionUrl(p.collection, p.slug)}
|
|
72
|
+
title={p.title}
|
|
73
|
+
excerpt={p.excerpt}
|
|
74
|
+
cover={p.cover}
|
|
75
|
+
coverAlt={p.coverAlt}
|
|
76
|
+
date={fmt(p.publishedAt)}
|
|
77
|
+
datetime={iso(p.publishedAt)}
|
|
78
|
+
readMins={readMins(p)}
|
|
79
|
+
tags={tagLinks(p.tags)}
|
|
80
|
+
/>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
</>
|
|
84
|
+
))}
|
|
85
|
+
<Pager base={base} page={pageNum} count={pageCount} />
|
|
86
|
+
<p class="archive-back"><a href={postsIndexUrl()}>← All {CONTENT.posts.plural.toLowerCase()}</a></p>
|
|
87
|
+
</div>
|
|
79
88
|
</Site>
|
|
80
|
-
|
|
81
|
-
<style>
|
|
82
|
-
.tag-group { font-size: 1.05rem; margin: 1.6rem 0 0.2rem; color: var(--muted, inherit); }
|
|
83
|
-
</style>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../../layouts/Site.astro';
|
|
3
|
+
import BookingFlow from '../../../components/booking/BookingFlow.astro';
|
|
4
|
+
import { getRepo } from '../../../lib/repo';
|
|
5
|
+
import { bookingsEnabled, manageUrl, resourceUrl } from '../../../lib/calendar/config';
|
|
6
|
+
import { availabilityContext, createResourceBooking, rescheduleBooking, BookingError } from '../../../lib/booking/service';
|
|
7
|
+
import { siteOrigin } from '../../../lib/seo';
|
|
8
|
+
import { waitUntilFrom } from '../../../lib/webhooks';
|
|
9
|
+
|
|
10
|
+
// One booking link: pick → answer → done. POSTs create the booking (or move
|
|
11
|
+
// one when ?reschedule=<token>) and land on the manage page.
|
|
12
|
+
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
13
|
+
const repo = await getRepo(Astro.locals);
|
|
14
|
+
const resource = await repo.getResourceBySlug(Astro.params.resource ?? '');
|
|
15
|
+
const link = resource ? await repo.getBookingLinkBySlug(resource.id, Astro.params.link ?? '') : null;
|
|
16
|
+
if (!resource || !link || !link.active) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
17
|
+
const settings = await repo.getSettings();
|
|
18
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
19
|
+
const flow = { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) };
|
|
20
|
+
const rescheduleToken = Astro.url.searchParams.get('reschedule') ?? '';
|
|
21
|
+
const reschedule = rescheduleToken ? await repo.getBookingByToken(rescheduleToken) : null;
|
|
22
|
+
if (reschedule && reschedule.linkId !== link.id) return Astro.redirect(manageUrl(reschedule.manageToken));
|
|
23
|
+
|
|
24
|
+
let error = '';
|
|
25
|
+
let values: Record<string, string> = {};
|
|
26
|
+
// after a failed submit, re-open the form on the time that was chosen
|
|
27
|
+
const flowUrl = new URL(Astro.url);
|
|
28
|
+
if (Astro.request.method === 'POST') {
|
|
29
|
+
const form = await Astro.request.formData();
|
|
30
|
+
values = Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)]));
|
|
31
|
+
const start = Number(form.get('start'));
|
|
32
|
+
const end = Number(form.get('end'));
|
|
33
|
+
try {
|
|
34
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) throw new BookingError(400, 'Pick a time first.');
|
|
35
|
+
const token = String(form.get('reschedule') || '');
|
|
36
|
+
if (token) {
|
|
37
|
+
const b = await repo.getBookingByToken(token);
|
|
38
|
+
if (!b || b.linkId !== link.id) throw new BookingError(404, 'That booking can’t be found.');
|
|
39
|
+
await rescheduleBooking(repo, b, start, end, flow);
|
|
40
|
+
return Astro.redirect(`${manageUrl(b.manageToken)}?moved=1`);
|
|
41
|
+
}
|
|
42
|
+
const answers: Record<string, string> = {};
|
|
43
|
+
for (const qn of link.questions) answers[qn.id] = String(form.get(`q_${qn.id}`) ?? '');
|
|
44
|
+
const b = await createResourceBooking(repo, {
|
|
45
|
+
resource, link, start, end,
|
|
46
|
+
booker: { name: String(form.get('name') ?? ''), email: String(form.get('email') ?? ''), phone: String(form.get('phone') ?? ''), answers },
|
|
47
|
+
}, flow);
|
|
48
|
+
if (link.redirectUrl && /^https?:\/\//.test(link.redirectUrl)) return Astro.redirect(link.redirectUrl);
|
|
49
|
+
return Astro.redirect(`${manageUrl(b.manageToken)}?new=1`);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
error = err instanceof BookingError ? err.message : 'Something went wrong. Please try again.';
|
|
52
|
+
if (Number.isFinite(start)) {
|
|
53
|
+
const tz = resource.timezone || settings.timezone || 'UTC';
|
|
54
|
+
flowUrl.searchParams.set('start', String(start));
|
|
55
|
+
flowUrl.searchParams.set('date', new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(start));
|
|
56
|
+
if (link.mode !== 'slots') flowUrl.searchParams.set('len', String(Math.round((end - start) / 60_000)));
|
|
57
|
+
}
|
|
58
|
+
if (!(err instanceof BookingError)) console.error(err);
|
|
59
|
+
Astro.response.status = err instanceof BookingError ? err.status : 500;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const ctx = await availabilityContext(repo, resource, link, settings, Date.now(), reschedule?.id);
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
<Site title={`${link.name} · ${resource.name}`} description={link.description || `Book ${resource.name}.`} noindex={link.visibility === 'unlisted' || resource.visibility === 'unlisted'}>
|
|
66
|
+
<div class="pc-bkpage">
|
|
67
|
+
<BookingFlow resource={resource} link={link} ctx={ctx} settings={settings} url={flowUrl} error={error} values={values} reschedule={reschedule} />
|
|
68
|
+
<p class="pc-bk-foot"><a href={resourceUrl(resource.slug)}>Other ways to book {resource.name}</a></p>
|
|
69
|
+
</div>
|
|
70
|
+
</Site>
|