@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,408 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../../layouts/Admin.astro';
|
|
3
|
+
import Modal from '../../../../components/Modal.astro';
|
|
4
|
+
import ContentList, { type ContentItem } from '../../../../components/ContentList.astro';
|
|
5
|
+
import { getRepo } from '../../../../lib/repo';
|
|
6
|
+
import { slugify, type Status } from '../../../../lib/schema';
|
|
7
|
+
import { CALENDAR, eventUrl } from '../../../../lib/calendar/config';
|
|
8
|
+
import { featuredOccurrence, formatOccurrence, locationLine, safeColor } from '../../../../lib/calendar/events';
|
|
9
|
+
import { describeRepeat } from '../../../../lib/calendar/rrule';
|
|
10
|
+
import { expandVEvents, loadFeed, tzParts } from '../../../../lib/calendar/ics';
|
|
11
|
+
import { normalizeCalendarSrc } from '../../../../lib/calendar/src';
|
|
12
|
+
import { eventsFromCsv, eventsFromIcs, eventsFromVEvents } from '../../../../lib/calendar/import';
|
|
13
|
+
import { eventChanged, eventDeleted } from '../../../../lib/calendar/hooks';
|
|
14
|
+
import { siteOrigin } from '../../../../lib/seo';
|
|
15
|
+
import { siteLocale } from '../../../../lib/dates';
|
|
16
|
+
import { waitUntilFrom } from '../../../../lib/webhooks';
|
|
17
|
+
import { invalidateRedirectCache } from '../../../../middleware';
|
|
18
|
+
|
|
19
|
+
// Events list: upcoming first, grouped by the month each event next happens.
|
|
20
|
+
// Feed calendars (live ICS mirrors) appear as read-only rows with a "Copy"
|
|
21
|
+
// action that turns one into an editable draft.
|
|
22
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
23
|
+
|
|
24
|
+
const repo = await getRepo(Astro.locals);
|
|
25
|
+
const settings = await repo.getSettings();
|
|
26
|
+
const tz = settings.timezone || 'UTC';
|
|
27
|
+
const locale = siteLocale(settings);
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const back = (notice?: string) => {
|
|
30
|
+
const p = new URLSearchParams(Astro.url.search);
|
|
31
|
+
p.delete('notice');
|
|
32
|
+
if (notice) p.set('notice', notice);
|
|
33
|
+
const q = p.toString();
|
|
34
|
+
return Astro.redirect(`/admin/calendar/events${q ? `?${q}` : ''}`);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
async function uniqueSlug(title: string) {
|
|
38
|
+
const base = slugify(title);
|
|
39
|
+
let slug = base;
|
|
40
|
+
for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
|
|
41
|
+
return slug;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function saveImported(list: ReturnType<typeof eventsFromIcs>['events'], calendarId: string) {
|
|
45
|
+
const known = new Set((await repo.listEvents()).map((e) => e.sourceUid).filter(Boolean));
|
|
46
|
+
let created = 0;
|
|
47
|
+
let dupes = 0;
|
|
48
|
+
for (const e of list) {
|
|
49
|
+
if (e.sourceUid && known.has(e.sourceUid)) { dupes++; continue; }
|
|
50
|
+
await repo.createEvent({ ...e, slug: await uniqueSlug(e.title), status: 'draft', calendarIds: calendarId ? [calendarId] : [] });
|
|
51
|
+
if (e.sourceUid) known.add(e.sourceUid);
|
|
52
|
+
created++;
|
|
53
|
+
}
|
|
54
|
+
return { created, dupes };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (Astro.request.method === 'POST') {
|
|
58
|
+
const form = await Astro.request.formData();
|
|
59
|
+
const op = String(form.get('op'));
|
|
60
|
+
const hookOpts = { origin: siteOrigin(settings, Astro.url), waitUntil: waitUntilFrom(Astro.locals), onRedirect: invalidateRedirectCache };
|
|
61
|
+
|
|
62
|
+
if (op === 'create') {
|
|
63
|
+
const title = String(form.get('title') || 'Untitled event').slice(0, 300);
|
|
64
|
+
const date = String(form.get('date') || '');
|
|
65
|
+
const time = String(form.get('time') || '');
|
|
66
|
+
const calendarId = String(form.get('calendarId') || '');
|
|
67
|
+
const today = tzParts(now + 86_400_000, tz);
|
|
68
|
+
const day = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : `${today.y}-${String(today.m).padStart(2, '0')}-${String(today.d).padStart(2, '0')}`;
|
|
69
|
+
const start = /^\d{2}:\d{2}$/.test(time) ? `${day}T${time}` : day;
|
|
70
|
+
const event = await repo.createEvent({
|
|
71
|
+
title, slug: await uniqueSlug(title), start, allDay: !/^\d{2}:\d{2}$/.test(time),
|
|
72
|
+
calendarIds: calendarId && (await repo.getCalendar(calendarId))?.kind === 'native' ? [calendarId] : [],
|
|
73
|
+
});
|
|
74
|
+
return Astro.redirect(`/admin/calendar/events/${event.id}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (op === 'bulk') {
|
|
78
|
+
const to = String(form.get('to'));
|
|
79
|
+
for (const id of form.getAll('ids').map(String).filter(Boolean)) {
|
|
80
|
+
const before = await repo.getEvent(id);
|
|
81
|
+
if (!before) continue;
|
|
82
|
+
if (to === 'delete') {
|
|
83
|
+
await repo.deleteEvent(id);
|
|
84
|
+
await eventDeleted(repo, before, hookOpts);
|
|
85
|
+
} else if (to === 'published' || to === 'draft' || to === 'archived') {
|
|
86
|
+
await repo.updateEvent(id, { status: to as Status, ...(to === 'published' ? { scheduledAt: null } : {}) });
|
|
87
|
+
await eventChanged(repo, before, (await repo.getEvent(id))!, hookOpts);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return back();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// a read-only feed occurrence → an editable draft
|
|
94
|
+
if (op === 'copy') {
|
|
95
|
+
const cal = await repo.getCalendar(String(form.get('calendarId')));
|
|
96
|
+
const uid = String(form.get('uid'));
|
|
97
|
+
if (!cal || cal.kind !== 'feed') return back('That feed calendar is gone.');
|
|
98
|
+
try {
|
|
99
|
+
const feed = await loadFeed(cal.feedUrl, now);
|
|
100
|
+
const v = feed.vevents.find((x) => x.uid === uid && !x.recurrenceId);
|
|
101
|
+
const [e] = v ? eventsFromVEvents([v], feed.calTz, settings.timezone).events : [];
|
|
102
|
+
if (!e) return back('That event is no longer in the feed.');
|
|
103
|
+
const event = await repo.createEvent({ ...e, slug: await uniqueSlug(e.title), status: 'draft' });
|
|
104
|
+
return Astro.redirect(`/admin/calendar/events/${event.id}`);
|
|
105
|
+
} catch {
|
|
106
|
+
return back('Couldn’t reach that feed right now.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// live mirror of an external calendar
|
|
111
|
+
if (op === 'subscribe') {
|
|
112
|
+
const url = normalizeCalendarSrc(String(form.get('url') || ''));
|
|
113
|
+
const name = String(form.get('name') || '').trim() || 'Imported calendar';
|
|
114
|
+
if (!url) return back('Paste a Google Calendar link, a calendar ID, or an .ics feed URL.');
|
|
115
|
+
try {
|
|
116
|
+
await loadFeed(url, now);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return back(err instanceof Error ? err.message : 'Couldn’t load that feed.');
|
|
119
|
+
}
|
|
120
|
+
const base = slugify(name);
|
|
121
|
+
let slug = base;
|
|
122
|
+
for (let n = 2; await repo.getCalendarBySlug(slug); n++) slug = `${base}-${n}`;
|
|
123
|
+
await repo.createCalendar({ name, slug, kind: 'feed', feedUrl: url, color: safeColor(String(form.get('color') || '#2f9e6e')) });
|
|
124
|
+
return back(`Subscribed to “${name}” — its events now show on your calendar.`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// one-time import: .ics / .csv file, or an ICS URL, into drafts
|
|
128
|
+
if (op === 'import') {
|
|
129
|
+
const calendarId = String(form.get('calendarId') || '');
|
|
130
|
+
const file = form.get('file');
|
|
131
|
+
const url = String(form.get('url') || '').trim();
|
|
132
|
+
let parsed: { events: ReturnType<typeof eventsFromIcs>['events']; skipped: number; error?: string };
|
|
133
|
+
try {
|
|
134
|
+
if (file instanceof File && file.size > 0) {
|
|
135
|
+
if (file.size > 3_000_000) return back('That file is too large (3 MB max).');
|
|
136
|
+
const text = await file.text();
|
|
137
|
+
parsed = /\.csv$/i.test(file.name) || !text.includes('BEGIN:VCALENDAR') ? eventsFromCsv(text) : eventsFromIcs(text, settings.timezone);
|
|
138
|
+
} else if (url) {
|
|
139
|
+
const feedUrl = normalizeCalendarSrc(url);
|
|
140
|
+
if (!feedUrl) return back('Paste a Google Calendar link, a calendar ID, or an .ics feed URL.');
|
|
141
|
+
const feed = await loadFeed(feedUrl, now);
|
|
142
|
+
parsed = eventsFromVEvents(feed.vevents, feed.calTz, settings.timezone);
|
|
143
|
+
} else {
|
|
144
|
+
return back('Choose a file or paste a calendar URL.');
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return back(err instanceof Error ? err.message : 'Import failed.');
|
|
148
|
+
}
|
|
149
|
+
if (parsed.error) return back(parsed.error);
|
|
150
|
+
const { created, dupes } = await saveImported(parsed.events, calendarId);
|
|
151
|
+
const bits = [`Imported ${created} event${created === 1 ? '' : 's'} as drafts`];
|
|
152
|
+
if (dupes) bits.push(`${dupes} already imported`);
|
|
153
|
+
if (parsed.skipped) bits.push(`${parsed.skipped} skipped`);
|
|
154
|
+
return back(`${bits.join(' · ')}.`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const [events, calendars] = await Promise.all([repo.listEvents(), repo.listCalendars()]);
|
|
159
|
+
const calById = new Map(calendars.map((c) => [c.id, c]));
|
|
160
|
+
const nativeCals = calendars.filter((c) => c.kind === 'native');
|
|
161
|
+
const feedCals = calendars.filter((c) => c.kind === 'feed');
|
|
162
|
+
|
|
163
|
+
const scope = (['upcoming', 'past', 'drafts', 'all'] as const).find((s) => s === Astro.url.searchParams.get('scope')) ?? 'upcoming';
|
|
164
|
+
const calFilter = Astro.url.searchParams.get('cal') ?? '';
|
|
165
|
+
const q = (Astro.url.searchParams.get('q') ?? '').toLowerCase();
|
|
166
|
+
const notice = Astro.url.searchParams.get('notice') ?? '';
|
|
167
|
+
|
|
168
|
+
const rows = events.map((e) => ({ e, o: featuredOccurrence(e, settings, now) }));
|
|
169
|
+
const isUpcoming = (r: (typeof rows)[number]) => r.e.status !== 'archived' && (r.o ? r.o.end > now : true);
|
|
170
|
+
const counts = {
|
|
171
|
+
upcoming: rows.filter(isUpcoming).length,
|
|
172
|
+
past: rows.filter((r) => !isUpcoming(r)).length,
|
|
173
|
+
drafts: rows.filter((r) => r.e.status === 'draft').length,
|
|
174
|
+
all: rows.length,
|
|
175
|
+
};
|
|
176
|
+
const selected = rows
|
|
177
|
+
.filter((r) => {
|
|
178
|
+
if (scope === 'upcoming' && !isUpcoming(r)) return false;
|
|
179
|
+
if (scope === 'past' && isUpcoming(r)) return false;
|
|
180
|
+
if (scope === 'drafts' && r.e.status !== 'draft') return false;
|
|
181
|
+
if (calFilter && !r.e.calendarIds.includes(calFilter)) return false;
|
|
182
|
+
if (q && !`${r.e.title} ${r.e.slug} ${r.e.location.name}`.toLowerCase().includes(q)) return false;
|
|
183
|
+
return true;
|
|
184
|
+
})
|
|
185
|
+
.sort((a, b) => ((a.o?.start ?? 0) - (b.o?.start ?? 0)) * (scope === 'past' ? -1 : 1));
|
|
186
|
+
|
|
187
|
+
const items: ContentItem[] = selected.map(({ e, o }) => {
|
|
188
|
+
const f = o ? formatOccurrence(o, tz, locale) : null;
|
|
189
|
+
const cals = e.calendarIds.map((id) => calById.get(id)?.name).filter(Boolean);
|
|
190
|
+
const repeats = describeRepeat(e.rrule, e.start);
|
|
191
|
+
return {
|
|
192
|
+
id: e.id,
|
|
193
|
+
href: `/admin/calendar/events/${e.id}`,
|
|
194
|
+
title: e.title,
|
|
195
|
+
sub: '',
|
|
196
|
+
meta: [locationLine(e.location) ? e.location.name || locationLine(e.location) : '', cals.join(', '), repeats].filter(Boolean).join(' · '),
|
|
197
|
+
when: f ? (o!.allDay ? f.range : `${f.range} – ${new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz }).format(o!.end)}`) : '',
|
|
198
|
+
at: o?.start ?? e.startAt,
|
|
199
|
+
status: e.status,
|
|
200
|
+
edited: Boolean(e.draft),
|
|
201
|
+
scheduledAt: e.scheduledAt,
|
|
202
|
+
image: e.cover,
|
|
203
|
+
viewHref: e.status === 'published' ? eventUrl(e.slug) : undefined,
|
|
204
|
+
featured: e.featured,
|
|
205
|
+
badges: e.cancelled ? ['Cancelled'] : [],
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// feed calendars: the next 60 days as read-only rows (upcoming/all scopes)
|
|
210
|
+
const feedErrors: string[] = [];
|
|
211
|
+
if ((scope === 'upcoming' || scope === 'all') && feedCals.length) {
|
|
212
|
+
const inScope = calFilter ? feedCals.filter((c) => c.id === calFilter) : feedCals;
|
|
213
|
+
const win = { displayTz: tz, now, windowStartMs: now, windowEndMs: now + 60 * 86_400_000 };
|
|
214
|
+
await Promise.all(inScope.map(async (c) => {
|
|
215
|
+
try {
|
|
216
|
+
const feed = await loadFeed(c.feedUrl, now);
|
|
217
|
+
const uids = new Map(feed.vevents.map((v) => [`${v.title}|${v.location}`, v.uid]));
|
|
218
|
+
for (const occ of expandVEvents(feed.vevents, feed.calTz, win, 0).slice(0, 40)) {
|
|
219
|
+
if (q && !occ.title.toLowerCase().includes(q)) continue;
|
|
220
|
+
const f = formatOccurrence(occ, tz, locale);
|
|
221
|
+
const uid = uids.get(`${occ.title}|${occ.location}`) ?? '';
|
|
222
|
+
items.push({
|
|
223
|
+
id: `feed-${c.id}-${occ.start}`,
|
|
224
|
+
href: `/admin/calendar/calendars#cal-${c.id}`,
|
|
225
|
+
title: occ.title,
|
|
226
|
+
sub: '',
|
|
227
|
+
meta: [occ.location, `from ${c.name}`].filter(Boolean).join(' · '),
|
|
228
|
+
when: occ.allDay ? f.range : f.range,
|
|
229
|
+
at: occ.start,
|
|
230
|
+
status: 'feed',
|
|
231
|
+
statusLabel: 'Read-only · feed',
|
|
232
|
+
readonly: true,
|
|
233
|
+
action: uid ? { label: 'Copy', fields: { op: 'copy', calendarId: c.id, uid } } : undefined,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
} catch (err) {
|
|
237
|
+
feedErrors.push(`${c.name}: ${err instanceof Error ? err.message : 'unreachable'}`);
|
|
238
|
+
}
|
|
239
|
+
}));
|
|
240
|
+
items.sort((a, b) => a.at - b.at);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const scopeHref = (s: string) => {
|
|
244
|
+
const p = new URLSearchParams(Astro.url.search);
|
|
245
|
+
p.delete('notice');
|
|
246
|
+
s === 'upcoming' ? p.delete('scope') : p.set('scope', s);
|
|
247
|
+
const str = p.toString();
|
|
248
|
+
return `/admin/calendar/events${str ? `?${str}` : ''}`;
|
|
249
|
+
};
|
|
250
|
+
const calHref = (id: string) => {
|
|
251
|
+
const p = new URLSearchParams(Astro.url.search);
|
|
252
|
+
p.delete('notice');
|
|
253
|
+
id ? p.set('cal', id) : p.delete('cal');
|
|
254
|
+
const str = p.toString();
|
|
255
|
+
return `/admin/calendar/events${str ? `?${str}` : ''}`;
|
|
256
|
+
};
|
|
257
|
+
const SCOPES = [['upcoming', 'Upcoming'], ['past', 'Past'], ['drafts', 'Drafts'], ['all', 'All']] as const;
|
|
258
|
+
const chev = 'M6 9l6 6 6-6';
|
|
259
|
+
const tomorrow = tzParts(now + 86_400_000, tz);
|
|
260
|
+
const tomorrowIso = `${tomorrow.y}-${String(tomorrow.m).padStart(2, '0')}-${String(tomorrow.d).padStart(2, '0')}`;
|
|
261
|
+
const subBits = [
|
|
262
|
+
`${counts.upcoming} upcoming`,
|
|
263
|
+
counts.drafts ? `${counts.drafts} draft${counts.drafts === 1 ? '' : 's'}` : '',
|
|
264
|
+
feedCals.length ? `${feedCals.length} feed calendar${feedCals.length === 1 ? '' : 's'}` : '',
|
|
265
|
+
].filter(Boolean);
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
<Admin title="Events" section="calendar:events">
|
|
269
|
+
<div class="page-head">
|
|
270
|
+
<div>
|
|
271
|
+
<h1>Events</h1>
|
|
272
|
+
<p class="sub">{subBits.join(' · ')} · public at <a href={CALENDAR.base} target="_blank" rel="noopener">{CALENDAR.base}</a></p>
|
|
273
|
+
</div>
|
|
274
|
+
<div class="page-actions">
|
|
275
|
+
<button class="btn" type="button" data-modal="import-events">Import</button>
|
|
276
|
+
<button class="btn primary" type="button" data-modal="new-event">New event</button>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
{notice && <p class="notice" role="status">{notice}</p>}
|
|
281
|
+
{feedErrors.map((e) => <p class="notice warn">Couldn’t refresh {e}</p>)}
|
|
282
|
+
|
|
283
|
+
<Modal id="new-event" title="New event" open={Astro.url.searchParams.has('new')}>
|
|
284
|
+
<form method="post">
|
|
285
|
+
<input type="hidden" name="op" value="create" />
|
|
286
|
+
<label class="field">
|
|
287
|
+
<span>Title</span>
|
|
288
|
+
<input type="text" name="title" placeholder="Fall Festival" required autofocus />
|
|
289
|
+
</label>
|
|
290
|
+
<div class="field-row">
|
|
291
|
+
<label class="field"><span>Date</span><input type="date" name="date" value={tomorrowIso} required /></label>
|
|
292
|
+
<label class="field"><span>Time <em class="hint">blank = all day</em></span><input type="time" name="time" value="10:00" step="300" /></label>
|
|
293
|
+
</div>
|
|
294
|
+
{nativeCals.length > 0 && (
|
|
295
|
+
<label class="field">
|
|
296
|
+
<span>Calendar</span>
|
|
297
|
+
<select name="calendarId">
|
|
298
|
+
<option value="">None — main calendar only</option>
|
|
299
|
+
{nativeCals.map((c) => <option value={c.id} selected={c.id === calFilter}>{c.name}</option>)}
|
|
300
|
+
</select>
|
|
301
|
+
</label>
|
|
302
|
+
)}
|
|
303
|
+
<div class="modal-foot">
|
|
304
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
305
|
+
<button class="btn primary" type="submit">Create event</button>
|
|
306
|
+
</div>
|
|
307
|
+
<p class="hint modal-note">Creates a draft and opens the editor, where you add the image, location, button and repeat rule.</p>
|
|
308
|
+
</form>
|
|
309
|
+
</Modal>
|
|
310
|
+
|
|
311
|
+
<Modal id="import-events" title="Bring in events">
|
|
312
|
+
<div class="seg block ev-import-tabs" role="tablist">
|
|
313
|
+
<button type="button" role="tab" aria-selected="true" data-tab="subscribe">Subscribe to a calendar</button>
|
|
314
|
+
<button type="button" role="tab" aria-selected="false" data-tab="once">Import once</button>
|
|
315
|
+
</div>
|
|
316
|
+
<form method="post" data-panel="subscribe">
|
|
317
|
+
<input type="hidden" name="op" value="subscribe" />
|
|
318
|
+
<p class="hint">Mirror a Google, Apple or Outlook calendar live. Its events show on your calendar and stay in sync, read-only.</p>
|
|
319
|
+
<label class="field"><span>Name</span><input type="text" name="name" placeholder="Staff calendar" required /></label>
|
|
320
|
+
<label class="field"><span>Calendar link</span><input class="mono" type="text" name="url" placeholder="Google Calendar link, calendar ID, or an .ics URL" required /></label>
|
|
321
|
+
<label class="field"><span>Color</span><input type="color" name="color" value="#2f9e6e" /></label>
|
|
322
|
+
<div class="modal-foot">
|
|
323
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
324
|
+
<button class="btn primary" type="submit">Subscribe</button>
|
|
325
|
+
</div>
|
|
326
|
+
</form>
|
|
327
|
+
<form method="post" enctype="multipart/form-data" data-panel="once" hidden>
|
|
328
|
+
<input type="hidden" name="op" value="import" />
|
|
329
|
+
<p class="hint">Copy events in as editable drafts. Works with .ics exports from Google, Apple and Outlook, or a spreadsheet saved as .csv with Title and Start date columns.</p>
|
|
330
|
+
<label class="field"><span>File</span><input type="file" name="file" accept=".ics,.csv,text/calendar,text/csv" /></label>
|
|
331
|
+
<label class="field"><span>Or a calendar link</span><input class="mono" type="text" name="url" placeholder="https://calendar.google.com/…" /></label>
|
|
332
|
+
{nativeCals.length > 0 && (
|
|
333
|
+
<label class="field">
|
|
334
|
+
<span>Add them to</span>
|
|
335
|
+
<select name="calendarId">
|
|
336
|
+
<option value="">No calendar</option>
|
|
337
|
+
{nativeCals.map((c) => <option value={c.id}>{c.name}</option>)}
|
|
338
|
+
</select>
|
|
339
|
+
</label>
|
|
340
|
+
)}
|
|
341
|
+
<div class="modal-foot">
|
|
342
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
343
|
+
<button class="btn primary" type="submit">Import as drafts</button>
|
|
344
|
+
</div>
|
|
345
|
+
</form>
|
|
346
|
+
</Modal>
|
|
347
|
+
|
|
348
|
+
{events.length === 0 && feedCals.length === 0 ? (
|
|
349
|
+
<div class="empty-state">
|
|
350
|
+
<b>No events yet</b>
|
|
351
|
+
<p>Add your first event, or bring in an existing Google, Apple or Outlook calendar.</p>
|
|
352
|
+
<div class="empty-actions">
|
|
353
|
+
<button class="btn" type="button" data-modal="import-events">Import</button>
|
|
354
|
+
<button class="btn primary" type="button" data-modal="new-event">New event</button>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
) : (
|
|
358
|
+
<>
|
|
359
|
+
<nav class="chips" aria-label="Show">
|
|
360
|
+
{SCOPES.map(([k, l]) => <a class={scope === k ? 'on' : ''} href={scopeHref(k)}>{l}<small>{counts[k]}</small></a>)}
|
|
361
|
+
</nav>
|
|
362
|
+
<div class="list-tools">
|
|
363
|
+
<div class="list-lead">
|
|
364
|
+
<nav class="seg ev-scope" aria-label="Show">
|
|
365
|
+
{SCOPES.map(([k, l]) => <a href={scopeHref(k)} aria-current={scope === k ? 'page' : undefined} class={scope === k ? 'on' : ''}>{l}</a>)}
|
|
366
|
+
</nav>
|
|
367
|
+
<form method="get" class="list-search" role="search">
|
|
368
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg>
|
|
369
|
+
{scope !== 'upcoming' && <input type="hidden" name="scope" value={scope} />}
|
|
370
|
+
{calFilter && <input type="hidden" name="cal" value={calFilter} />}
|
|
371
|
+
<input type="search" name="q" value={q} placeholder="Filter events…" data-live-filter aria-label="Filter events" />
|
|
372
|
+
</form>
|
|
373
|
+
</div>
|
|
374
|
+
{calendars.length > 0 && (
|
|
375
|
+
<div class="fmenus">
|
|
376
|
+
<details class={`fmenu ${calFilter ? 'is-set' : ''}`}>
|
|
377
|
+
<summary>{calFilter ? calById.get(calFilter)?.name ?? 'Calendar' : 'All calendars'} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d={chev} /></svg></summary>
|
|
378
|
+
<div class="fmenu-pop">
|
|
379
|
+
<a class={!calFilter ? 'on' : ''} href={calHref('')}>All calendars</a>
|
|
380
|
+
{calendars.map((c) => <a class={calFilter === c.id ? 'on' : ''} href={calHref(c.id)}><span><i class="cal-dot" style={`background:${safeColor(c.color)}`} />{c.name}</span>{c.kind === 'feed' && <small>feed</small>}</a>)}
|
|
381
|
+
</div>
|
|
382
|
+
</details>
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
385
|
+
</div>
|
|
386
|
+
<ContentList items={items} noun="event" timeZone={settings.timezone || undefined} groupByMonth={true} empty="No events match the current filters." />
|
|
387
|
+
{items.length === 0 && (
|
|
388
|
+
<p class="hint" style="padding:24px 8px">
|
|
389
|
+
{scope === 'upcoming' ? 'Nothing coming up.' : 'No events match the current filters.'} <a href="/admin/calendar/events?new=1">Add an event</a>
|
|
390
|
+
</p>
|
|
391
|
+
)}
|
|
392
|
+
</>
|
|
393
|
+
)}
|
|
394
|
+
</Admin>
|
|
395
|
+
|
|
396
|
+
<script>
|
|
397
|
+
document.addEventListener('click', (e) => {
|
|
398
|
+
document.querySelectorAll<HTMLDetailsElement>('details.fmenu[open]').forEach((d) => {
|
|
399
|
+
if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
// import modal tabs
|
|
403
|
+
const tabs = document.querySelectorAll<HTMLButtonElement>('.ev-import-tabs [data-tab]');
|
|
404
|
+
tabs.forEach((t) => t.addEventListener('click', () => {
|
|
405
|
+
tabs.forEach((x) => x.setAttribute('aria-selected', String(x === t)));
|
|
406
|
+
document.querySelectorAll<HTMLElement>('#import-events [data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== t.dataset.tab; });
|
|
407
|
+
}));
|
|
408
|
+
</script>
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../layouts/Admin.astro';
|
|
3
|
+
import Modal from '../../../components/Modal.astro';
|
|
4
|
+
import { getRepo } from '../../../lib/repo';
|
|
5
|
+
import { API_SCOPES, WEBHOOK_EVENTS, type ApiScope } from '../../../lib/schema';
|
|
6
|
+
import { validateSession, SESSION_COOKIE, OWNER_ENV } from '../../../lib/auth';
|
|
7
|
+
import { CALENDAR, bookingsEnabled, calendarFeedUrl } from '../../../lib/calendar/config';
|
|
8
|
+
import { issueApiKey } from '../../../lib/api-keys';
|
|
9
|
+
import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../../lib/webhooks';
|
|
10
|
+
import { resolveConfig } from '../../../lib/email/config';
|
|
11
|
+
import { env } from '../../../lib/env';
|
|
12
|
+
import { siteOrigin } from '../../../lib/seo';
|
|
13
|
+
|
|
14
|
+
// Settings → Calendar & integrations (mockup J): subscribe feeds, scoped API
|
|
15
|
+
// keys (shown once), signed webhooks with a delivery log and a test button,
|
|
16
|
+
// and the email setup status. Owner-only: keys and webhooks reach private data.
|
|
17
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
18
|
+
const repo = await getRepo(Astro.locals);
|
|
19
|
+
const sessionId = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
|
|
20
|
+
const me = sessionId && sessionId !== OWNER_ENV ? await repo.getUser(sessionId) : null;
|
|
21
|
+
const isOwner = sessionId === OWNER_ENV || me?.role === 'owner';
|
|
22
|
+
const settings = await repo.getSettings();
|
|
23
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
24
|
+
let notice = '';
|
|
25
|
+
let error = '';
|
|
26
|
+
let newKey = '';
|
|
27
|
+
let newSecret = '';
|
|
28
|
+
const dev = import.meta.env.DEV;
|
|
29
|
+
|
|
30
|
+
if (Astro.request.method === 'POST') {
|
|
31
|
+
if (!isOwner) return new Response('Owners only', { status: 403 });
|
|
32
|
+
const form = await Astro.request.formData();
|
|
33
|
+
const op = String(form.get('op'));
|
|
34
|
+
try {
|
|
35
|
+
if (op === 'key') {
|
|
36
|
+
const scopes = form.getAll('scopes').map(String).filter((s): s is ApiScope => (API_SCOPES as readonly string[]).includes(s));
|
|
37
|
+
if (!scopes.length) throw new Error('Pick at least one permission.');
|
|
38
|
+
const { key } = await issueApiKey(repo, String(form.get('name') || 'API key').slice(0, 80), scopes);
|
|
39
|
+
newKey = key;
|
|
40
|
+
} else if (op === 'revoke') {
|
|
41
|
+
await repo.deleteApiKey(String(form.get('id')));
|
|
42
|
+
notice = 'Key revoked — anything using it stops working now.';
|
|
43
|
+
} else if (op === 'hook') {
|
|
44
|
+
const url = validateWebhookUrl(String(form.get('url') || ''), dev);
|
|
45
|
+
const events = form.getAll('events').map(String).filter((e) => (WEBHOOK_EVENTS as readonly string[]).includes(e));
|
|
46
|
+
if (!events.length) throw new Error('Pick at least one event.');
|
|
47
|
+
const secret = generateWebhookSecret();
|
|
48
|
+
await repo.createWebhook({ url, secret, events });
|
|
49
|
+
newSecret = secret;
|
|
50
|
+
} else if (op === 'toggle') {
|
|
51
|
+
const h = (await repo.listWebhooks()).find((w) => w.id === form.get('id'));
|
|
52
|
+
if (h) await repo.updateWebhook(h.id, { active: !h.active });
|
|
53
|
+
} else if (op === 'delhook') {
|
|
54
|
+
await repo.deleteWebhook(String(form.get('id')));
|
|
55
|
+
notice = 'Webhook removed.';
|
|
56
|
+
} else if (op === 'test') {
|
|
57
|
+
const h = (await repo.listWebhooks()).find((w) => w.id === form.get('id'));
|
|
58
|
+
if (h) {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const d = { id: crypto.randomUUID(), webhookId: h.id, event: 'ping', payload: JSON.stringify({ type: 'ping', createdAt: new Date(now).toISOString(), data: { message: 'Test delivery from acms' } }), status: 'pending' as const, attempts: 0, nextAt: now, responseCode: null, createdAt: now, deliveredAt: null };
|
|
61
|
+
await repo.createDeliveries([d]);
|
|
62
|
+
await attemptDelivery(repo, { ...d, attempts: 3 }, h);
|
|
63
|
+
const last = (await repo.listDeliveries(h.id, 1))[0];
|
|
64
|
+
notice = last?.responseCode ? `Test sent — the endpoint answered ${last.responseCode}.` : 'Test sent — the endpoint didn’t answer.';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
error = err instanceof Error ? err.message : 'Something went wrong.';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const [keys, hooks, calendars] = await Promise.all([repo.listApiKeys(), repo.listWebhooks(), repo.listCalendars()]);
|
|
73
|
+
const logs = new Map(await Promise.all(hooks.map(async (h) => [h.id, await repo.listDeliveries(h.id, 5)] as const)));
|
|
74
|
+
const emailConfig = resolveConfig(await repo.email.config(), Astro.locals);
|
|
75
|
+
const provider = emailConfig.error ? 'none' : emailConfig.provider;
|
|
76
|
+
const ago = (ts: number | null) => {
|
|
77
|
+
if (!ts) return 'never';
|
|
78
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
79
|
+
return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`;
|
|
80
|
+
};
|
|
81
|
+
const SCOPE_LABEL: Record<string, string> = {
|
|
82
|
+
'events:read': 'Read events (incl. private calendars)', 'events:write': 'Create and edit events',
|
|
83
|
+
'bookings:read': 'Read bookings and guest details', 'bookings:write': 'Create, confirm and cancel bookings',
|
|
84
|
+
'resources:read': 'Read resources and links', 'resources:write': 'Edit resources and links',
|
|
85
|
+
};
|
|
86
|
+
const feeds = [
|
|
87
|
+
{ label: 'Everything public', url: origin + calendarFeedUrl() },
|
|
88
|
+
...calendars.filter((c) => c.kind === 'native' && c.visibility !== 'private').map((c) => ({ label: c.name, url: origin + calendarFeedUrl(c.slug) })),
|
|
89
|
+
];
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
<Admin title="API & webhooks" section="calendar:integrations">
|
|
93
|
+
<div class="page-head">
|
|
94
|
+
<div>
|
|
95
|
+
<h1>API & webhooks</h1>
|
|
96
|
+
<p class="sub">Connect the calendar{bookingsEnabled() ? ' and bookings' : ''} to Zapier, Make, Slack, a lobby screen or your own code. <a href="/api/calendar/docs" target="_blank" rel="noopener">API reference ↗</a></p>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
{notice && <p class="notice" role="status">{notice}</p>}
|
|
100
|
+
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
101
|
+
{!isOwner && <p class="notice">Only owners can create keys or webhooks.</p>}
|
|
102
|
+
{newKey && (
|
|
103
|
+
<div class="notice reveal">
|
|
104
|
+
<b>Copy this key now — it won’t be shown again.</b>
|
|
105
|
+
<div class="cal-feed"><input type="text" class="mono" readonly value={newKey} /><button class="btn sm" type="button" data-copy={newKey}>Copy</button></div>
|
|
106
|
+
<span class="hint">Send it as <code>Authorization: Bearer {newKey.slice(0, 14)}…</code></span>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
{newSecret && (
|
|
110
|
+
<div class="notice reveal">
|
|
111
|
+
<b>Signing secret — copy it into the receiver now.</b>
|
|
112
|
+
<div class="cal-feed"><input type="text" class="mono" readonly value={newSecret} /><button class="btn sm" type="button" data-copy={newSecret}>Copy</button></div>
|
|
113
|
+
<span class="hint">Verify <code>X-Acms-Signature</code> = <code>sha256=</code>HMAC-SHA256(secret, <code>{'{X-Acms-Timestamp}.{body}'}</code>).</span>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
<div class="res-grid">
|
|
118
|
+
<div>
|
|
119
|
+
<section class="card card-pad">
|
|
120
|
+
<div class="card-head"><b>API keys</b>{isOwner && <button class="btn sm" type="button" data-modal="new-key">New key</button>}</div>
|
|
121
|
+
{keys.length === 0 && <p class="hint">No keys yet. Public calendars and bookings don’t need one — keys unlock private data and changes.</p>}
|
|
122
|
+
{keys.map((k) => (
|
|
123
|
+
<div class="int-row">
|
|
124
|
+
<div><b>{k.name}</b> <code>{k.prefix}…</code><div class="int-scopes">{k.scopes.map((s) => <span class="pill lang">{s}</span>)}</div></div>
|
|
125
|
+
<span class="hint">used {ago(k.lastUsedAt)}</span>
|
|
126
|
+
{isOwner && <form method="post"><input type="hidden" name="op" value="revoke" /><input type="hidden" name="id" value={k.id} /><button class="btn sm ghost danger" data-confirm={`Revoke “${k.name}”?`}>Revoke</button></form>}
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
<p class="hint">The agent key from Settings → AI & agents also works here, with every permission.</p>
|
|
130
|
+
</section>
|
|
131
|
+
|
|
132
|
+
<section class="card card-pad">
|
|
133
|
+
<div class="card-head"><b>Webhooks</b>{isOwner && <button class="btn sm" type="button" data-modal="new-hook">Add endpoint</button>}</div>
|
|
134
|
+
{hooks.length === 0 && <p class="hint">Get a signed POST the moment something happens — a booking comes in, an event goes live.</p>}
|
|
135
|
+
{hooks.map((h) => {
|
|
136
|
+
const log = logs.get(h.id) ?? [];
|
|
137
|
+
const ok = h.lastStatus !== null && h.lastStatus >= 200 && h.lastStatus < 300;
|
|
138
|
+
return (
|
|
139
|
+
<div class="int-hook">
|
|
140
|
+
<div class="int-row">
|
|
141
|
+
<div><b class="mono">{h.url.replace(/^https?:\/\//, '').slice(0, 60)}</b><div class="int-scopes">{h.events.map((e) => <span class="pill lang">{e}</span>)}</div></div>
|
|
142
|
+
<span class={`int-status ${h.lastStatus === null ? '' : ok ? 'ok' : 'bad'}`}>{h.lastStatus === null ? 'no deliveries yet' : `${h.lastStatus || 'no answer'} · ${ago(h.lastAt)}`}</span>
|
|
143
|
+
{isOwner && (
|
|
144
|
+
<form method="post" class="int-acts">
|
|
145
|
+
<input type="hidden" name="id" value={h.id} />
|
|
146
|
+
<button class="btn sm" name="op" value="test">Send test</button>
|
|
147
|
+
<button class="btn sm ghost" name="op" value="toggle">{h.active ? 'Pause' : 'Resume'}</button>
|
|
148
|
+
<button class="btn sm ghost danger" name="op" value="delhook" data-confirm="Remove this webhook?">Remove</button>
|
|
149
|
+
</form>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
{log.length > 0 && (
|
|
153
|
+
<details class="int-log"><summary>Last {log.length} deliveries</summary>
|
|
154
|
+
{log.map((d) => <div><span class={`status ${d.status === 'delivered' ? 'published' : d.status === 'failed' ? 'archived' : 'scheduled'}`}>{d.status}</span> <code>{d.event}</code> <span class="hint">{d.responseCode ?? '—'} · {d.attempts} attempt{d.attempts === 1 ? '' : 's'} · {ago(d.createdAt)}</span></div>)}
|
|
155
|
+
</details>
|
|
156
|
+
)}
|
|
157
|
+
{!h.active && <p class="hint">Paused — nothing is sent.</p>}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</section>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div class="res-side">
|
|
165
|
+
<section class="card card-pad">
|
|
166
|
+
<div class="card-head"><b>Email</b><span class={`int-status ${provider === 'none' ? 'bad' : 'ok'}`}>{provider === 'none' ? 'Not set up' : ({cloudflare:'Cloudflare Email',resend:'Resend',mailgun:'Mailgun',ses:'Amazon SES'})[provider]}</span></div>
|
|
167
|
+
{provider === 'none' ? (
|
|
168
|
+
<p class="hint">Choose Resend, Mailgun, Amazon SES or Cloudflare in <a href="/admin/email">Email settings</a>. Configure a sender and review delivery activity there.</p>
|
|
169
|
+
) : <p class="hint">Sending as <code>{emailConfig.config.from}</code>. Guests get confirmations, updates and a reminder the day before.</p>}
|
|
170
|
+
</section>
|
|
171
|
+
<section class="card card-pad">
|
|
172
|
+
<div class="card-head"><b>Subscribe feeds</b></div>
|
|
173
|
+
{feeds.map((f) => <div class="int-feed"><span>{f.label}</span><div class="cal-feed"><input type="text" class="mono" readonly value={f.url} /><button class="btn sm" type="button" data-copy={f.url}>Copy</button></div></div>)}
|
|
174
|
+
<p class="hint">Private calendars and resource staff feeds have secret links on their own screens.</p>
|
|
175
|
+
</section>
|
|
176
|
+
<section class="card card-pad">
|
|
177
|
+
<div class="card-head"><b>Quick start</b></div>
|
|
178
|
+
<pre class="int-code">{`curl ${origin}/api/calendar/events?from=2026-10-01
|
|
179
|
+
|
|
180
|
+
curl -H "Authorization: Bearer <key>" \\
|
|
181
|
+
${origin}/api/calendar/bookings?status=pending`}</pre>
|
|
182
|
+
</section>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<Modal id="new-key" title="New API key">
|
|
187
|
+
<form method="post">
|
|
188
|
+
<input type="hidden" name="op" value="key" />
|
|
189
|
+
<label class="field"><span>Name</span><input type="text" name="name" placeholder="Zapier, lobby screen…" required /></label>
|
|
190
|
+
<div class="field"><span>Permissions</span>
|
|
191
|
+
{API_SCOPES.filter((s) => bookingsEnabled() || !s.startsWith('bookings') && !s.startsWith('resources')).map((s) => (
|
|
192
|
+
<label class="check-row"><input type="checkbox" name="scopes" value={s} checked={s.endsWith(':read')} /> {SCOPE_LABEL[s]} <code>{s}</code></label>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Create key</button></div>
|
|
196
|
+
</form>
|
|
197
|
+
</Modal>
|
|
198
|
+
<Modal id="new-hook" title="Add webhook endpoint">
|
|
199
|
+
<form method="post">
|
|
200
|
+
<input type="hidden" name="op" value="hook" />
|
|
201
|
+
<label class="field"><span>Endpoint URL</span><input type="url" class="mono" name="url" placeholder="https://hooks.zapier.com/…" required /></label>
|
|
202
|
+
<div class="field"><span>Send these events</span>
|
|
203
|
+
{WEBHOOK_EVENTS.filter((e) => bookingsEnabled() || !e.startsWith('booking')).map((e) => <label class="check-row"><input type="checkbox" name="events" value={e} checked={e === 'booking.created' || e === 'event.published'} /> <code>{e}</code></label>)}
|
|
204
|
+
</div>
|
|
205
|
+
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Add endpoint</button></div>
|
|
206
|
+
<p class="hint modal-note">Deliveries are signed and retried 3 times over about an hour.</p>
|
|
207
|
+
</form>
|
|
208
|
+
</Modal>
|
|
209
|
+
</Admin>
|
|
210
|
+
|
|
211
|
+
<script>
|
|
212
|
+
document.addEventListener('click', async (e) => {
|
|
213
|
+
const c = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
|
|
214
|
+
if (c && !confirm(c.dataset.confirm)) { e.preventDefault(); return; }
|
|
215
|
+
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
216
|
+
if (!b) return;
|
|
217
|
+
await navigator.clipboard.writeText(b.dataset.copy ?? '');
|
|
218
|
+
b.textContent = 'Copied';
|
|
219
|
+
setTimeout(() => (b.textContent = 'Copy'), 1200);
|
|
220
|
+
});
|
|
221
|
+
</script>
|