@acms-subir/core 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -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/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +182 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +575 -47
- 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 +114 -57
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -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 +324 -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 +52 -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/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 +217 -0
- package/src/lib/repo/sql.ts +853 -60
- package/src/lib/repo/types.ts +122 -5
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +309 -0
- 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 +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +8 -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 +221 -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/links.astro +3 -3
- 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 +64 -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/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/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 +6 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +23 -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/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 +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -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 +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../layouts/Site.astro';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import { effectiveSettings } from '../../lib/designPreview';
|
|
5
|
+
import { siteOrigin } from '../../lib/seo';
|
|
6
|
+
import { siteLocale } from '../../lib/dates';
|
|
7
|
+
import { CALENDAR, calendarFeedUrl, calendarPageUrl } from '../../lib/calendar/config';
|
|
8
|
+
import { calendarWindow, renderCalendarView } from '../../lib/calendar/render';
|
|
9
|
+
import { collectViewEvents, type ViewEvent } from '../../lib/calendar/data';
|
|
10
|
+
import { eventCardHtml } from '../../lib/calendar/cards';
|
|
11
|
+
import { safeColor } from '../../lib/calendar/events';
|
|
12
|
+
import { tzParts } from '../../lib/calendar/ics';
|
|
13
|
+
|
|
14
|
+
// The main calendar page: filter chips per public calendar, Month / Week /
|
|
15
|
+
// List views (the same renderer as the calendar card), image cards for
|
|
16
|
+
// what's coming up, and subscribe links for every calendar app. On phones the
|
|
17
|
+
// month grid gives way to a two-week day strip over the agenda.
|
|
18
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
19
|
+
|
|
20
|
+
const repo = await getRepo(Astro.locals);
|
|
21
|
+
const settings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const tz = settings.timezone || 'UTC';
|
|
24
|
+
const locale = siteLocale(settings);
|
|
25
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
26
|
+
|
|
27
|
+
const all = await repo.listCalendars();
|
|
28
|
+
const listed = all.filter((c) => c.visibility === 'public');
|
|
29
|
+
const calSlug = Astro.url.searchParams.get('cal') ?? '';
|
|
30
|
+
// unlisted calendars work when linked directly, they just aren't offered
|
|
31
|
+
const active = calSlug ? all.find((c) => c.slug === calSlug && c.visibility !== 'private') ?? null : null;
|
|
32
|
+
const viewParam = Astro.url.searchParams.get('view');
|
|
33
|
+
const view = (['month', 'week', 'list'] as const).find((v) => v === viewParam) ?? CALENDAR.defaultView;
|
|
34
|
+
const explicitView = Boolean(viewParam);
|
|
35
|
+
const monthParam = /^(\d{4})-(\d{2})$/.exec(Astro.url.searchParams.get('m') ?? '');
|
|
36
|
+
|
|
37
|
+
const win = calendarWindow(settings, now);
|
|
38
|
+
const { events, calendars, errors } = await collectViewEvents(repo, settings, win, {
|
|
39
|
+
audience: 'public',
|
|
40
|
+
...(active ? { calendarIds: [active.id] } : {}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const calendarHtml = renderCalendarView(events, settings, now, {
|
|
44
|
+
view: view === 'list' ? 'upcoming' : view,
|
|
45
|
+
limit: 50,
|
|
46
|
+
multi: !active && calendars.length > 1,
|
|
47
|
+
emptyText: 'Nothing on the calendar yet.',
|
|
48
|
+
focus: monthParam ? { y: Number(monthParam[1]), m: Number(monthParam[2]) } : undefined,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// coming up: the next occurrence of each distinct native event
|
|
52
|
+
const seen = new Set<string>();
|
|
53
|
+
const comingUp: ViewEvent[] = [];
|
|
54
|
+
for (const v of events) {
|
|
55
|
+
if (!v.event || v.end <= now || seen.has(v.event.id)) continue;
|
|
56
|
+
seen.add(v.event.id);
|
|
57
|
+
comingUp.push(v);
|
|
58
|
+
if (comingUp.length >= 6) break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// agenda (list view + phones): next 60 days grouped by local day
|
|
62
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
63
|
+
const dayKey = (ms: number) => { const p = tzParts(ms, tz); return `${p.y}-${pad(p.m)}-${pad(p.d)}`; };
|
|
64
|
+
const horizon = now + 60 * 86_400_000;
|
|
65
|
+
const agenda = new Map<string, ViewEvent[]>();
|
|
66
|
+
for (const v of events) {
|
|
67
|
+
if (v.end <= now || v.start > horizon) continue;
|
|
68
|
+
// events already underway are listed under today
|
|
69
|
+
const k = dayKey(Math.max(v.start, now));
|
|
70
|
+
if (!agenda.has(k)) agenda.set(k, []);
|
|
71
|
+
agenda.get(k)!.push(v);
|
|
72
|
+
}
|
|
73
|
+
const fmt = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, { ...o, timeZone: tz });
|
|
74
|
+
const dayLabel = fmt({ weekday: 'long', month: 'long', day: 'numeric' });
|
|
75
|
+
const timeLabel = fmt({ hour: 'numeric', minute: '2-digit' });
|
|
76
|
+
const stripDays = Array.from({ length: 14 }, (_, i) => {
|
|
77
|
+
const ms = now + i * 86_400_000;
|
|
78
|
+
const k = dayKey(ms);
|
|
79
|
+
return { k, dow: fmt({ weekday: 'narrow' }).format(ms), d: tzParts(ms, tz).d, has: agenda.has(k), today: i === 0 };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const feed = origin + calendarFeedUrl(active?.slug);
|
|
83
|
+
const webcal = feed.replace(/^https?:\/\//, 'webcal://');
|
|
84
|
+
const googleAdd = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(webcal)}`;
|
|
85
|
+
const outlookAdd = `https://outlook.live.com/calendar/0/addfromweb?url=${encodeURIComponent(feed)}&name=${encodeURIComponent(active?.name ?? settings.siteTitle)}`;
|
|
86
|
+
const viewHref = (v: string) => {
|
|
87
|
+
const p = new URLSearchParams(Astro.url.search);
|
|
88
|
+
p.set('view', v);
|
|
89
|
+
p.delete('m');
|
|
90
|
+
return `${CALENDAR.base}?${p}`;
|
|
91
|
+
};
|
|
92
|
+
const title = active ? active.name : 'Calendar';
|
|
93
|
+
const lede = active?.description || `What’s happening at ${settings.siteTitle}. Subscribe to keep it in your own calendar.`;
|
|
94
|
+
const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.type === 'timeZoneName')?.value ?? tz;
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
<Site title={title} description={lede}>
|
|
98
|
+
<div class={`pc-calpage view-${view}${explicitView ? ' is-explicit' : ''}`}>
|
|
99
|
+
<header class="pc-calpage-head">
|
|
100
|
+
<h1>{title}</h1>
|
|
101
|
+
<p>{lede}</p>
|
|
102
|
+
</header>
|
|
103
|
+
|
|
104
|
+
<div class="pc-calpage-bar">
|
|
105
|
+
{(listed.length > 1 || active) && (
|
|
106
|
+
<nav class="pc-calpage-chips" aria-label="Calendars">
|
|
107
|
+
<a class={!active ? 'is-on' : ''} href={CALENDAR.base} aria-current={!active ? 'page' : undefined}>All</a>
|
|
108
|
+
{listed.map((c) => (
|
|
109
|
+
<a class={active?.id === c.id ? 'is-on' : ''} href={calendarPageUrl(c.slug)} aria-current={active?.id === c.id ? 'page' : undefined}>
|
|
110
|
+
<i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}
|
|
111
|
+
</a>
|
|
112
|
+
))}
|
|
113
|
+
{active && active.visibility !== 'public' && <a class="is-on" href={calendarPageUrl(active.slug)}><i style={`background:${safeColor(active.color)}`} aria-hidden="true" />{active.name}</a>}
|
|
114
|
+
</nav>
|
|
115
|
+
)}
|
|
116
|
+
<div class="pc-calpage-tools">
|
|
117
|
+
<nav class="pc-calpage-views" aria-label="View">
|
|
118
|
+
<a class={view === 'month' ? 'is-on' : ''} href={viewHref('month')}>Month</a>
|
|
119
|
+
<a class={view === 'week' ? 'is-on' : ''} href={viewHref('week')}>Week</a>
|
|
120
|
+
<a class={view === 'list' ? 'is-on' : ''} href={viewHref('list')}>List</a>
|
|
121
|
+
</nav>
|
|
122
|
+
<details class="pc-calpage-sub">
|
|
123
|
+
<summary>
|
|
124
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg>
|
|
125
|
+
Subscribe
|
|
126
|
+
</summary>
|
|
127
|
+
<div class="pc-calpage-sub-menu">
|
|
128
|
+
<p>Add {active ? `“${active.name}”` : 'this calendar'} to your calendar app. New events show up on their own.</p>
|
|
129
|
+
<a href={googleAdd} target="_blank" rel="noopener">Google Calendar</a>
|
|
130
|
+
<a href={webcal}>Apple Calendar</a>
|
|
131
|
+
<a href={outlookAdd} target="_blank" rel="noopener">Outlook</a>
|
|
132
|
+
<button type="button" data-copy={feed}>Copy link</button>
|
|
133
|
+
</div>
|
|
134
|
+
</details>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div class="pc-calpage-strip" aria-label="Next two weeks">
|
|
139
|
+
{stripDays.map((d) => (
|
|
140
|
+
<a class={`${d.has ? 'has' : ''}${d.today ? ' today' : ''}`} href={d.has ? `#d-${d.k}` : undefined} aria-label={d.k}>
|
|
141
|
+
<small>{d.dow}</small><b>{d.d}</b><i aria-hidden="true" />
|
|
142
|
+
</a>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div class="pc-calpage-view" set:html={calendarHtml} />
|
|
147
|
+
|
|
148
|
+
<section class="pc-calpage-agenda" aria-label="Agenda">
|
|
149
|
+
{agenda.size === 0 ? <p class="pc-cal-empty">Nothing in the next two months.</p> : [...agenda.entries()].map(([k, list]) => (
|
|
150
|
+
<div class="pc-agenda-day" id={`d-${k}`}>
|
|
151
|
+
<h3>{dayLabel.format(list[0].start < now ? now : list[0].start)}</h3>
|
|
152
|
+
{list.map((v) => (
|
|
153
|
+
<a class={`pc-agenda-row${v.cancelled ? ' is-cancelled' : ''}`} href={v.url ?? undefined}>
|
|
154
|
+
<span class="t">{v.allDay ? 'All day' : timeLabel.format(v.start)}</span>
|
|
155
|
+
<span class="ti">
|
|
156
|
+
{v.color && <i style={`background:${v.color}`} aria-hidden="true" />}
|
|
157
|
+
<b>{v.title}</b>
|
|
158
|
+
{v.location && <small>{v.location}</small>}
|
|
159
|
+
</span>
|
|
160
|
+
</a>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
))}
|
|
164
|
+
</section>
|
|
165
|
+
|
|
166
|
+
{view !== 'list' && comingUp.length > 0 && (
|
|
167
|
+
<section class="pc-calpage-up">
|
|
168
|
+
<h2>Coming up</h2>
|
|
169
|
+
<div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days }, settings, { showImage: true })).join('')} />
|
|
170
|
+
</section>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
<footer class="pc-calpage-foot">
|
|
174
|
+
<span>Times in {zoneName}</span>
|
|
175
|
+
{errors.length > 0 && <span>Some linked calendars couldn’t be loaded right now.</span>}
|
|
176
|
+
</footer>
|
|
177
|
+
</div>
|
|
178
|
+
<script>
|
|
179
|
+
document.addEventListener('click', async (e) => {
|
|
180
|
+
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
181
|
+
if (b) {
|
|
182
|
+
await navigator.clipboard.writeText(b.dataset.copy ?? '');
|
|
183
|
+
b.textContent = 'Link copied';
|
|
184
|
+
setTimeout(() => (b.textContent = 'Copy link'), 1400);
|
|
185
|
+
}
|
|
186
|
+
document.querySelectorAll<HTMLDetailsElement>('details.pc-calpage-sub[open]').forEach((d) => {
|
|
187
|
+
if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
</script>
|
|
191
|
+
</Site>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../../../layouts/Site.astro';
|
|
3
|
+
import BookingFlow from '../../../../components/booking/BookingFlow.astro';
|
|
4
|
+
import { getRepo } from '../../../../lib/repo';
|
|
5
|
+
import { bookingLinkUrl, bookingsEnabled } from '../../../../lib/calendar/config';
|
|
6
|
+
import { availabilityContext } from '../../../../lib/booking/service';
|
|
7
|
+
|
|
8
|
+
// Embeddable picker for other sites: day + time selection happens inside the
|
|
9
|
+
// iframe; choosing a time opens the full booking page (top window) to finish.
|
|
10
|
+
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
11
|
+
const repo = await getRepo(Astro.locals);
|
|
12
|
+
const resource = await repo.getResourceBySlug(Astro.params.resource ?? '');
|
|
13
|
+
const link = resource ? await repo.getBookingLinkBySlug(resource.id, Astro.params.link ?? '') : null;
|
|
14
|
+
if (!resource || !link || !link.active) return new Response(null, { status: 404 });
|
|
15
|
+
const settings = await repo.getSettings();
|
|
16
|
+
// a chosen time hands off to the real page, where the form lives
|
|
17
|
+
if (Astro.url.searchParams.get('start')) {
|
|
18
|
+
return Astro.redirect(`${bookingLinkUrl(resource.slug, link.slug)}${Astro.url.search}`);
|
|
19
|
+
}
|
|
20
|
+
const ctx = await availabilityContext(repo, resource, link, settings);
|
|
21
|
+
Astro.response.headers.set('Content-Security-Policy', 'frame-ancestors *');
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<Site title={link.name} bare noindex>
|
|
25
|
+
<BookingFlow resource={resource} link={link} ctx={ctx} settings={settings} url={Astro.url} embed />
|
|
26
|
+
<script is:inline>
|
|
27
|
+
(function () {
|
|
28
|
+
var send = function () { parent.postMessage({ acmsBookingEmbed: true, height: document.documentElement.scrollHeight }, '*'); };
|
|
29
|
+
window.addEventListener('load', send);
|
|
30
|
+
if (window.ResizeObserver) new ResizeObserver(send).observe(document.documentElement);
|
|
31
|
+
})();
|
|
32
|
+
</script>
|
|
33
|
+
</Site>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../../layouts/Site.astro';
|
|
3
|
+
import { getRepo } from '../../../lib/repo';
|
|
4
|
+
import { CALENDAR } from '../../../lib/calendar/config';
|
|
5
|
+
import { calendarWindow, renderCalendarView } from '../../../lib/calendar/render';
|
|
6
|
+
import { collectViewEvents } from '../../../lib/calendar/data';
|
|
7
|
+
import { upcomingEvents, eventCardHtml } from '../../../lib/calendar/cards';
|
|
8
|
+
|
|
9
|
+
// Embeddable calendar for other websites:
|
|
10
|
+
// <iframe src="https://site/embed/calendar/all?view=month" …>
|
|
11
|
+
// slug 'all' = every public calendar. view: month | week | list | cards.
|
|
12
|
+
// Links open in the parent window; height is posted to the parent so a
|
|
13
|
+
// small script can auto-size the iframe.
|
|
14
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
15
|
+
const repo = await getRepo(Astro.locals);
|
|
16
|
+
const settings = await repo.getSettings();
|
|
17
|
+
const slug = Astro.params.slug ?? 'all';
|
|
18
|
+
const cal = slug === 'all' ? null : await repo.getCalendarBySlug(slug);
|
|
19
|
+
if (slug !== 'all' && (!cal || cal.visibility === 'private')) return new Response(null, { status: 404 });
|
|
20
|
+
const view = (['month', 'week', 'list', 'cards'] as const).find((v) => v === Astro.url.searchParams.get('view')) ?? 'month';
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
let html: string;
|
|
23
|
+
if (view === 'cards') {
|
|
24
|
+
const items = await upcomingEvents(repo, settings, now, { calendarIds: cal ? [cal.id] : undefined, limit: Math.min(12, Number(Astro.url.searchParams.get('limit')) || 6) });
|
|
25
|
+
html = `<section class="pc-ev-cards" data-layout="grid">${items.map((o) => eventCardHtml(o, settings, { showImage: true })).join('') || '<p class="pc-ev-empty">No upcoming events.</p>'}</section>`;
|
|
26
|
+
} else {
|
|
27
|
+
const { events, calendars } = await collectViewEvents(repo, settings, calendarWindow(settings, now), { audience: 'public', ...(cal ? { calendarIds: [cal.id] } : {}) });
|
|
28
|
+
html = renderCalendarView(events, settings, now, { view: view === 'list' ? 'upcoming' : view, limit: 30, multi: !cal && calendars.length > 1 });
|
|
29
|
+
}
|
|
30
|
+
Astro.response.headers.set('Content-Security-Policy', "frame-ancestors *");
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<Site title={cal?.name ?? 'Calendar'} bare noindex>
|
|
34
|
+
<base target="_top" />
|
|
35
|
+
<div class="pc-embed" set:html={html} />
|
|
36
|
+
<script is:inline>
|
|
37
|
+
(function () {
|
|
38
|
+
var send = function () { parent.postMessage({ acmsCalendarEmbed: true, height: document.documentElement.scrollHeight }, '*'); };
|
|
39
|
+
window.addEventListener('load', send);
|
|
40
|
+
if (window.ResizeObserver) new ResizeObserver(send).observe(document.documentElement);
|
|
41
|
+
})();
|
|
42
|
+
</script>
|
|
43
|
+
</Site>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../layouts/Site.astro';
|
|
3
|
+
import EventPage from '../../components/EventPage.astro';
|
|
4
|
+
import { getRepo } from '../../lib/repo';
|
|
5
|
+
import { expandDynamicBlocks } from '../../lib/dynamicBlocks';
|
|
6
|
+
import { highlightCodeHtml } from '../../lib/highlightCode';
|
|
7
|
+
import { validateSession, SESSION_COOKIE } from '../../lib/auth';
|
|
8
|
+
import { effectiveSettings } from '../../lib/designPreview';
|
|
9
|
+
import { resolveDesign } from '../../lib/theme';
|
|
10
|
+
import { siteOrigin } from '../../lib/seo';
|
|
11
|
+
import { CALENDAR, eventUrl } from '../../lib/calendar/config';
|
|
12
|
+
import { eventIsPublic, featuredOccurrence } from '../../lib/calendar/events';
|
|
13
|
+
import { eventJsonLd } from '../../lib/calendar/seo';
|
|
14
|
+
import { upcomingEvents } from '../../lib/calendar/cards';
|
|
15
|
+
import { createRsvp, seatsTaken, BookingError } from '../../lib/booking/service';
|
|
16
|
+
import { manageUrl } from '../../lib/calendar/config';
|
|
17
|
+
import { waitUntilFrom } from '../../lib/webhooks';
|
|
18
|
+
|
|
19
|
+
// Public event page. Drafts and private-calendar events 404 for visitors
|
|
20
|
+
// (admins see them with a banner). ?on=YYYY-MM-DD shows one occurrence of a
|
|
21
|
+
// repeating event — the calendar grid links carry it.
|
|
22
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
23
|
+
const repo = await getRepo(Astro.locals);
|
|
24
|
+
const event = await repo.getEventBySlug(Astro.params.slug ?? '');
|
|
25
|
+
const [rawSettings, allCalendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
|
|
26
|
+
const calMap = new Map(allCalendars.map((c) => [c.id, c]));
|
|
27
|
+
const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
|
|
28
|
+
if (!event || (!eventIsPublic(event, calMap) && !isAdmin)) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
29
|
+
|
|
30
|
+
const settings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const on = Astro.url.searchParams.get('on') ?? undefined;
|
|
33
|
+
const occurrence = featuredOccurrence(event, settings, now, on && /^\d{4}-\d{2}-\d{2}$/.test(on) ? on : undefined);
|
|
34
|
+
if (!occurrence) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
35
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
36
|
+
// RSVP: "Save my seat" posts back here and lands on the manage page
|
|
37
|
+
let rsvpError = '';
|
|
38
|
+
if (Astro.request.method === 'POST' && event.rsvp.enabled && event.status === 'published') {
|
|
39
|
+
const form = await Astro.request.formData();
|
|
40
|
+
try {
|
|
41
|
+
const b = await createRsvp(repo, {
|
|
42
|
+
event, start: occurrence.start, end: occurrence.end, seats: Number(form.get('seats')) || 1,
|
|
43
|
+
booker: { name: String(form.get('name') ?? ''), email: String(form.get('email') ?? '') },
|
|
44
|
+
}, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
|
|
45
|
+
return Astro.redirect(`${manageUrl(b.manageToken)}?new=1`);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
rsvpError = err instanceof BookingError ? err.message : 'Couldn’t save your RSVP. Please try again.';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
|
|
51
|
+
const rsvp = event.rsvp.enabled
|
|
52
|
+
? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, open: occurrence.end > now && !event.cancelled }
|
|
53
|
+
: null;
|
|
54
|
+
const calendars = event.calendarIds.map((id) => calMap.get(id)).filter((c): c is NonNullable<typeof c> => Boolean(c) && c!.visibility !== 'private');
|
|
55
|
+
const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo));
|
|
56
|
+
const upcoming = (await upcomingEvents(repo, settings, now, { limit: 4 })).filter((u) => u.event.id !== event.id).slice(0, 3);
|
|
57
|
+
const pageUrl = origin + eventUrl(event.slug);
|
|
58
|
+
const jsonLd = eventJsonLd(event, occurrence, settings, origin, pageUrl);
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
|
+
const Template = resolveDesign(settings).theme.EventTemplate as any;
|
|
61
|
+
const props = { event, occurrence, settings, calendars, html, upcoming, origin, preview: event.status !== 'published', rsvp };
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
<Site
|
|
65
|
+
title={event.seoTitle || event.title}
|
|
66
|
+
exactTitle={Boolean(event.seoTitle)}
|
|
67
|
+
description={event.seoDesc || event.excerpt || undefined}
|
|
68
|
+
image={event.cover || undefined}
|
|
69
|
+
imageAlt={event.coverAlt || undefined}
|
|
70
|
+
jsonLd={jsonLd}
|
|
71
|
+
noindex={event.status !== 'published'}
|
|
72
|
+
>
|
|
73
|
+
{Template ? <Template {...props} /> : <EventPage {...props} />}
|
|
74
|
+
</Site>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../lib/repo';
|
|
3
|
+
import { siteOrigin } from '../../lib/seo';
|
|
4
|
+
import { CALENDAR, eventUrl } from '../../lib/calendar/config';
|
|
5
|
+
import { eventIsPublic } from '../../lib/calendar/events';
|
|
6
|
+
import { buildFeed } from '../../lib/calendar/ics-write';
|
|
7
|
+
import { icsResponse } from '../../lib/calendar/feeds';
|
|
8
|
+
|
|
9
|
+
// Single-event .ics behind "Add to calendar → Apple / Download". Served
|
|
10
|
+
// inline so iPhones open it straight in Calendar.
|
|
11
|
+
export const GET: APIRoute = async ({ locals, url, params }) => {
|
|
12
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
13
|
+
const repo = await getRepo(locals);
|
|
14
|
+
const event = await repo.getEventBySlug(params.slug ?? '');
|
|
15
|
+
const calendars = await repo.listCalendars();
|
|
16
|
+
const map = new Map(calendars.map((c) => [c.id, c]));
|
|
17
|
+
if (!event || !eventIsPublic(event, map)) return new Response(null, { status: 404 });
|
|
18
|
+
const settings = await repo.getSettings();
|
|
19
|
+
const origin = siteOrigin(settings, url);
|
|
20
|
+
const body = buildFeed([event], settings.timezone, {
|
|
21
|
+
name: event.title, origin, host: new URL(origin).host, tz: settings.timezone || 'UTC',
|
|
22
|
+
eventUrl: (e) => eventUrl(e.slug), calendars: map, refreshMinutes: 1440,
|
|
23
|
+
});
|
|
24
|
+
return icsResponse(body, `${event.slug}.ics`);
|
|
25
|
+
};
|
package/src/pages/index.astro
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import ThemeSections from '../components/ThemeSections.astro';
|
|
2
3
|
import Site from '../layouts/Site.astro';
|
|
3
4
|
import WidgetRenderer from '../components/WidgetRenderer.astro';
|
|
4
5
|
import { getRepo } from '../lib/repo';
|
|
@@ -17,7 +18,7 @@ let [home, rawSettings] = await Promise.all([
|
|
|
17
18
|
repo.getSettings(),
|
|
18
19
|
]);
|
|
19
20
|
// ?designPreview=1 (admin-only) renders the staged Design-screen changes
|
|
20
|
-
const settings = effectiveSettings(rawSettings, Astro.url, Astro.cookies);
|
|
21
|
+
const settings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
|
|
21
22
|
// a locale home that isn't authored yet falls back to the default home —
|
|
22
23
|
// /es must never 404 mid-setup
|
|
23
24
|
if (wantsLocaleHome && !home) home = await repo.getHomePage();
|
|
@@ -26,7 +27,7 @@ if (wantsLocaleHome && !home) home = await repo.getHomePage();
|
|
|
26
27
|
// isHome content page unless the Design screen forces 'content'. The template
|
|
27
28
|
// receives the page (its body renders as the hero) plus published posts/tags.
|
|
28
29
|
const design = resolveDesign(settings);
|
|
29
|
-
const HomeTemplate = design.homeMode !== 'content' ? (design.HomeTemplate as any) : null;
|
|
30
|
+
const HomeTemplate = design.homeMode !== 'content' && (!home || settings.sectionPages?.[home.id]?.mode !== 'content') ? (design.HomeTemplate as any) : null;
|
|
30
31
|
// theme homepages are blog-shaped — they receive the built-in collection only
|
|
31
32
|
const [posts, tags] = HomeTemplate
|
|
32
33
|
? await Promise.all([repo.listPublishedPosts('posts'), repo.listTags()])
|
|
@@ -38,7 +39,9 @@ const homeForTemplate = home ? { ...home, bodyHtml: homeHtml } : null;
|
|
|
38
39
|
---
|
|
39
40
|
|
|
40
41
|
<Site title={home?.seoTitle || undefined} description={home?.seoDesc || undefined}>
|
|
41
|
-
{
|
|
42
|
+
{home && settings.sectionPages?.[home.id]?.mode === 'sections' ? (
|
|
43
|
+
<ThemeSections page={home} settings={settings} />
|
|
44
|
+
) : HomeTemplate ? (
|
|
42
45
|
<HomeTemplate settings={settings} page={homeForTemplate} posts={posts} tags={tags} />
|
|
43
46
|
) : home ? (
|
|
44
47
|
home.bodyHtml ? (
|
package/src/pages/links.astro
CHANGED
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
import Site from '../layouts/Site.astro';
|
|
3
3
|
import { getRepo } from '../lib/repo';
|
|
4
4
|
import { cleanEventName } from '../lib/analytics';
|
|
5
|
+
import { socialIconPath, socialIconSvg } from '../lib/socialIcons';
|
|
6
|
+
import { effectiveNav } from '../lib/designPreview';
|
|
5
7
|
|
|
6
8
|
const repo = await getRepo(Astro.locals);
|
|
7
|
-
const [links, settings] = await Promise.all([repo.listLinks(), repo.getSettings()]);
|
|
9
|
+
const [links, settings, liveNav] = await Promise.all([repo.listLinks(), repo.getSettings(), repo.listNavItems()]);
|
|
10
|
+
// staged social links show up in design preview mode
|
|
11
|
+
const navItems = await effectiveNav(liveNav, Astro.url, Astro.cookies, Astro.locals);
|
|
12
|
+
// the site's social links (Navigation → Social) as an icon row under the bio
|
|
13
|
+
const socials = navItems
|
|
14
|
+
.filter((n) => n.area === 'social')
|
|
15
|
+
.map((n) => ({ ...n, path: socialIconPath(n.url) }));
|
|
8
16
|
|
|
9
17
|
// links page has its own identity; falls back to the site's until it's set
|
|
10
18
|
const profileName = settings.linksName || settings.siteTitle;
|
|
@@ -71,8 +79,10 @@ function vcardHref(l: (typeof links)[number]): string {
|
|
|
71
79
|
return `data:text/vcard;charset=utf-8,${encodeURIComponent(lines.join('\r\n'))}`;
|
|
72
80
|
}
|
|
73
81
|
|
|
74
|
-
// incomplete buttons (no destination yet)
|
|
82
|
+
// incomplete buttons (no destination yet) and blocks switched off in the
|
|
83
|
+
// admin (meta.hidden) stay hidden on the public page
|
|
75
84
|
const visible = links.filter((l) => {
|
|
85
|
+
if (l.meta?.hidden) return false;
|
|
76
86
|
if (l.kind === 'link' || l.kind === 'email' || l.kind === 'phone') return Boolean(l.url.trim());
|
|
77
87
|
if (l.kind === 'heading' || l.kind === 'text') return Boolean((l.kind === 'heading' ? l.label : l.html).trim());
|
|
78
88
|
if (l.kind === 'html') return Boolean(l.html.trim());
|
|
@@ -85,6 +95,15 @@ const visible = links.filter((l) => {
|
|
|
85
95
|
{settings.linksAvatar && <img class="links-avatar" src={settings.linksAvatar} alt={profileName} />}
|
|
86
96
|
<h1>{profileName}</h1>
|
|
87
97
|
{profileBio && <p class="tagline">{profileBio}</p>}
|
|
98
|
+
{socials.length > 0 && (
|
|
99
|
+
<nav class="social-nav" aria-label="Social">
|
|
100
|
+
{socials.map((n) => (
|
|
101
|
+
<a href={n.url} class:list={[{ 'has-icon': n.path }]} aria-label={n.path ? n.label : undefined} title={n.path ? n.label : undefined} target="_blank" rel="noopener me">
|
|
102
|
+
{n.path ? <Fragment set:html={socialIconSvg(n.path)} /> : n.label}
|
|
103
|
+
</a>
|
|
104
|
+
))}
|
|
105
|
+
</nav>
|
|
106
|
+
)}
|
|
88
107
|
{visible.map((l) => {
|
|
89
108
|
const ico = iconInfo(l);
|
|
90
109
|
const icon =
|
|
@@ -111,6 +130,7 @@ const visible = links.filter((l) => {
|
|
|
111
130
|
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>;
|
|
112
131
|
}
|
|
113
132
|
})}
|
|
114
|
-
{visible.length === 0 && <p>No links yet.</p>}
|
|
133
|
+
{visible.length === 0 && <p class="muted-note">No links yet.</p>}
|
|
134
|
+
<p class="links-foot"><a href="/">{settings.siteTitle}</a></p>
|
|
115
135
|
</div>
|
|
116
136
|
</Site>
|
package/src/pages/llms.txt.ts
CHANGED
|
@@ -35,6 +35,10 @@ export const GET: APIRoute = async ({ locals, url }) => {
|
|
|
35
35
|
.map((p) => `- [${p.title}](${origin}${collectionUrl(key, p.slug)})${p.excerpt ? `: ${p.excerpt}` : ''}`),
|
|
36
36
|
]),
|
|
37
37
|
'',
|
|
38
|
+
'## Agent editing',
|
|
39
|
+
'',
|
|
40
|
+
`- [Agent API documentation](${origin}/api/agent/docs) — authenticated HTTP and MCP access; generate a key in admin settings.`,
|
|
41
|
+
'',
|
|
38
42
|
'## Feeds',
|
|
39
43
|
'',
|
|
40
44
|
`- [RSS](${origin}/rss.xml)`,
|
|
@@ -6,17 +6,20 @@ import { renderMarkdown } from '../../../lib/markdown';
|
|
|
6
6
|
import { verifyPreviewToken } from '../../../lib/preview';
|
|
7
7
|
import { expandDynamicBlocks } from '../../../lib/dynamicBlocks';
|
|
8
8
|
import { highlightCodeHtml } from '../../../lib/highlightCode';
|
|
9
|
-
import {
|
|
10
|
-
import { mergePageDraft, mergePostDraft } from '../../../lib/drafts';
|
|
9
|
+
import { validateSession, SESSION_COOKIE } from '../../../lib/auth';
|
|
10
|
+
import { mergeEventDraft, mergePageDraft, mergePostDraft } from '../../../lib/drafts';
|
|
11
|
+
import EventPage from '../../../components/EventPage.astro';
|
|
12
|
+
import { featuredOccurrence } from '../../../lib/calendar/events';
|
|
13
|
+
import { siteOrigin } from '../../../lib/seo';
|
|
11
14
|
|
|
12
15
|
// Draft preview, any status. Reachable by a logged-in admin (the editor's
|
|
13
16
|
// preview iframe) or by anyone holding a signed share token. Live documents
|
|
14
17
|
// with pending edits render the PENDING version — exactly what Update will
|
|
15
18
|
// publish — while the public URL keeps serving the live one.
|
|
16
19
|
const { type, id } = Astro.params;
|
|
17
|
-
if (type !== 'post' && type !== 'page') return new Response(null, { status: 404 });
|
|
20
|
+
if (type !== 'post' && type !== 'page' && type !== 'event') return new Response(null, { status: 404 });
|
|
18
21
|
|
|
19
|
-
const isAdmin =
|
|
22
|
+
const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
|
|
20
23
|
const token = Astro.url.searchParams.get('token') ?? undefined;
|
|
21
24
|
if (!isAdmin && !verifyPreviewToken(type, id!, token)) {
|
|
22
25
|
return new Response(null, { status: 404 });
|
|
@@ -25,7 +28,22 @@ if (!isAdmin && !verifyPreviewToken(type, id!, token)) {
|
|
|
25
28
|
const repo = await getRepo(Astro.locals);
|
|
26
29
|
const livePost = type === 'post' ? await repo.getPost(id!) : null;
|
|
27
30
|
const livePage = type === 'page' ? await repo.getPage(id!) : null;
|
|
28
|
-
|
|
31
|
+
const liveEvent = type === 'event' ? await repo.getEvent(id!) : null;
|
|
32
|
+
if (!livePost && !livePage && !liveEvent) return new Response(null, { status: 404 });
|
|
33
|
+
// events render through the real event page component, draft merged in
|
|
34
|
+
const event = liveEvent ? mergeEventDraft(liveEvent) : null;
|
|
35
|
+
const eventView = event
|
|
36
|
+
? await (async () => {
|
|
37
|
+
const [settings, calendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
|
|
38
|
+
const occurrence = featuredOccurrence(event, settings, Date.now());
|
|
39
|
+
return {
|
|
40
|
+
settings, occurrence,
|
|
41
|
+
calendars: event.calendarIds.map((cid) => calendars.find((c) => c.id === cid)).filter((c): c is NonNullable<typeof c> => Boolean(c)),
|
|
42
|
+
html: highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo)),
|
|
43
|
+
origin: siteOrigin(settings, Astro.url),
|
|
44
|
+
};
|
|
45
|
+
})()
|
|
46
|
+
: null;
|
|
29
47
|
const post = livePost ? mergePostDraft(livePost) : null;
|
|
30
48
|
const page = livePage ? mergePageDraft(livePage) : null;
|
|
31
49
|
|
|
@@ -35,7 +53,10 @@ const fmt = (ts: number | null) =>
|
|
|
35
53
|
ts ? new Date(ts).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '';
|
|
36
54
|
---
|
|
37
55
|
|
|
38
|
-
<Site title={post?.title ?? page?.seoTitle ?? page?.title}>
|
|
56
|
+
<Site title={post?.title ?? event?.title ?? page?.seoTitle ?? page?.title}>
|
|
57
|
+
{event && eventView?.occurrence && (
|
|
58
|
+
<EventPage event={event} occurrence={eventView.occurrence} settings={eventView.settings} calendars={eventView.calendars} html={eventView.html} upcoming={[]} origin={eventView.origin} preview />
|
|
59
|
+
)}
|
|
39
60
|
{post && (
|
|
40
61
|
<article>
|
|
41
62
|
<h1>{post.title}</h1>
|
package/src/pages/search.astro
CHANGED
|
@@ -6,6 +6,9 @@ import { collectionUrl, tagUrl } from '../lib/content';
|
|
|
6
6
|
import { authorSlug } from '../lib/schema';
|
|
7
7
|
import { I18N, localeFromPath, localeHomeUrl, normalizePageLocale } from '../lib/i18n';
|
|
8
8
|
import { searchStrings } from '../lib/search-i18n';
|
|
9
|
+
import { CALENDAR, eventUrl } from '../lib/calendar/config';
|
|
10
|
+
import { eventIsPublic, featuredOccurrence, formatOccurrence } from '../lib/calendar/events';
|
|
11
|
+
import { siteLocale } from '../lib/dates';
|
|
9
12
|
|
|
10
13
|
// The no-JS search surface (the modal's form falls back here). Serves both
|
|
11
14
|
// /search and the locale-prefixed /<locale>/search routes — the locale comes
|
|
@@ -20,8 +23,10 @@ const locale = normalizePageLocale(reqLocale);
|
|
|
20
23
|
const t = searchStrings((reqLocale || siteLang(settings)).slice(0, 2));
|
|
21
24
|
const results = q
|
|
22
25
|
? await repo.searchPublished(q, { locale })
|
|
23
|
-
: { posts: [], pages: [], tags: [], authors: [] };
|
|
24
|
-
const
|
|
26
|
+
: { posts: [], pages: [], events: [], tags: [], authors: [] };
|
|
27
|
+
const calMap = new Map((await repo.listCalendars()).map((c) => [c.id, c]));
|
|
28
|
+
const events = CALENDAR.enabled ? results.events.filter(({ doc }) => eventIsPublic(doc, calMap)).slice(0, 10) : [];
|
|
29
|
+
const total = results.posts.length + results.pages.length + events.length + results.tags.length + results.authors.length;
|
|
25
30
|
---
|
|
26
31
|
|
|
27
32
|
<Site title={q ? `${t.label}: ${q}` : t.label} description={`${t.label} · ${settings.siteTitle}`} noindex={Boolean(q)}>
|
|
@@ -88,6 +93,26 @@ const total = results.posts.length + results.pages.length + results.tags.length
|
|
|
88
93
|
</section>
|
|
89
94
|
)}
|
|
90
95
|
|
|
96
|
+
{events.length > 0 && (
|
|
97
|
+
<section class="search-section">
|
|
98
|
+
<h2>{t.events}</h2>
|
|
99
|
+
<ul class="post-list">
|
|
100
|
+
{events.map(({ doc: e, snippet }) => {
|
|
101
|
+
const o = featuredOccurrence(e, settings, Date.now());
|
|
102
|
+
return (
|
|
103
|
+
<li class="post-item">
|
|
104
|
+
<div>
|
|
105
|
+
<a href={eventUrl(e.slug)}>{e.title}</a>
|
|
106
|
+
{o && <span class="search-loc">{formatOccurrence(o, settings.timezone || 'UTC', siteLocale(settings)).range}</span>}
|
|
107
|
+
{snippet && <p class="post-excerpt" set:html={snippet} />}
|
|
108
|
+
</div>
|
|
109
|
+
</li>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
</ul>
|
|
113
|
+
</section>
|
|
114
|
+
)}
|
|
115
|
+
|
|
91
116
|
{results.tags.length > 0 && (
|
|
92
117
|
<section class="search-section">
|
|
93
118
|
<h2>{t.tags}</h2>
|
|
@@ -118,31 +143,26 @@ const total = results.posts.length + results.pages.length + results.tags.length
|
|
|
118
143
|
</Site>
|
|
119
144
|
|
|
120
145
|
<style>
|
|
121
|
-
.search-hero {
|
|
122
|
-
.search-
|
|
123
|
-
.search-form
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
.search-form input:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; }
|
|
146
|
+
.search-hero { padding: calc(20px * var(--space)) 0 0; }
|
|
147
|
+
.search-hero h1 { font-size: clamp(34px, 5vw, 46px); margin: 0 0 18px; }
|
|
148
|
+
.search-form { display: flex; gap: 6px; padding: 5px; margin: 0 0 18px; background: var(--panel); border: 1px solid var(--line-2); border-radius: 14px; box-shadow: var(--sh-1); }
|
|
149
|
+
.search-form:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
150
|
+
.search-form input { flex: 1; min-width: 0; font: inherit; padding: 0 12px; border: 0; background: none; color: var(--ink); outline: none; }
|
|
128
151
|
.search-form button {
|
|
129
|
-
font:
|
|
130
|
-
background: var(--
|
|
152
|
+
font: 550 15px/1 var(--font-body); height: 42px; padding: 0 18px; border: 0; border-radius: 10px;
|
|
153
|
+
background: var(--btn); color: var(--btn-ink); cursor: pointer;
|
|
131
154
|
}
|
|
132
|
-
.search-count { color: var(--muted); }
|
|
133
|
-
.search-section h2 { font-size:
|
|
155
|
+
.search-count { color: var(--muted); font-size: 14.5px; margin: 0 0 8px; }
|
|
156
|
+
.search-section h2 { font-size: 13px; font-weight: 550; letter-spacing: 0; color: var(--muted); margin: 36px 0 4px; font-family: var(--font-body); }
|
|
157
|
+
.post-list :global(.post-excerpt) { color: var(--muted); font-size: 15px; margin: 4px 0 0; }
|
|
134
158
|
/* snippets arrive pre-escaped with <mark> around matches (set:html), so
|
|
135
159
|
the highlight style must reach into the injected fragment */
|
|
136
160
|
.post-excerpt :global(mark) {
|
|
137
161
|
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
|
138
162
|
color: inherit; border-radius: 3px; padding: 0 1px;
|
|
139
163
|
}
|
|
140
|
-
.search-loc {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
vertical-align: 2px;
|
|
144
|
-
}
|
|
145
|
-
.search-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
|
146
|
-
.search-tag-count { color: var(--muted); }
|
|
164
|
+
.search-loc { font-size: 12.5px; font-weight: 550; color: var(--muted); margin-left: 10px; }
|
|
165
|
+
.search-tags { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 12px; }
|
|
166
|
+
.search-tag-count { color: var(--muted); font-weight: 400; }
|
|
147
167
|
.search-avatar { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; flex: none; }
|
|
148
168
|
</style>
|