@acms-subir/core 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -369
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/live-video.md +91 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +189 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +578 -49
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +418 -46
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +119 -58
- package/src/components/LiveCard.astro +174 -0
- package/src/components/LiveManager.tsx +910 -0
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +58 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/WidgetRenderer.astro +3 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +352 -59
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +56 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +110 -12
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/live/client.ts +88 -0
- package/src/lib/live/crypto.ts +28 -0
- package/src/lib/live/migration.ts +15 -0
- package/src/lib/live/providers.ts +152 -0
- package/src/lib/live/service.ts +170 -0
- package/src/lib/live/signals.ts +89 -0
- package/src/lib/live/store.ts +49 -0
- package/src/lib/live/types.ts +52 -0
- package/src/lib/live/view.ts +78 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +221 -0
- package/src/lib/repo/sql.ts +907 -68
- package/src/lib/repo/types.ts +125 -5
- package/src/lib/scheduler.ts +27 -0
- package/src/lib/schema.ts +316 -1
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +183 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhook-sql.ts +68 -0
- package/src/lib/webhooks.ts +147 -0
- package/src/lib/widgets.ts +1 -0
- package/src/middleware.ts +45 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +14 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +4 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- package/src/pages/admin/integrations.astro +237 -0
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/live.astro +12 -0
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +66 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
- package/src/pages/api/live/settings.ts +45 -0
- package/src/pages/api/live/status.ts +7 -0
- package/src/pages/api/live/thumb.ts +48 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/api/webhooks/docs.ts +61 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +137 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +12 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +29 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/live-admin.css +126 -0
- package/src/styles/live.css +199 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +81 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +55 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +27 -37
- package/src/themes/editorial/theme.json +25 -3
- package/src/themes/field/theme.json +27 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +28 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
|
@@ -3,65 +3,262 @@ import Admin from '../../layouts/Admin.astro';
|
|
|
3
3
|
import { getRepo } from '../../lib/repo';
|
|
4
4
|
import { userConfig } from 'virtual:acms-config';
|
|
5
5
|
import { CONTENT } from '../../lib/content';
|
|
6
|
+
import { validateSession, SESSION_COOKIE, OWNER_ENV } from '../../lib/auth';
|
|
7
|
+
import { getTraffic, umamiReadEnabled } from '../../lib/umami';
|
|
8
|
+
import { collectionUrl } from '../../lib/content';
|
|
6
9
|
|
|
7
10
|
const coreVersion = userConfig.coreVersion;
|
|
8
11
|
const repo = await getRepo(Astro.locals);
|
|
9
|
-
const
|
|
12
|
+
const sessionUserId = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
|
|
13
|
+
const [stats, pages, posts, links, settings, nav, users] = await Promise.all([
|
|
10
14
|
repo.getStats(),
|
|
11
15
|
repo.listPages(),
|
|
12
16
|
repo.listPosts(),
|
|
13
17
|
repo.listLinks(),
|
|
18
|
+
repo.getSettings(),
|
|
19
|
+
repo.listNavItems(),
|
|
20
|
+
repo.listUsers(),
|
|
14
21
|
]);
|
|
22
|
+
const traffic = await getTraffic(settings, 30);
|
|
23
|
+
const me = sessionUserId && sessionUserId !== OWNER_ENV ? users.find((u) => u.id === sessionUserId) : null;
|
|
24
|
+
const firstName = me?.name.split(/\s+/)[0] ?? '';
|
|
15
25
|
|
|
26
|
+
const tz = settings.timezone ? { timeZone: settings.timezone } : {};
|
|
27
|
+
const safe = <T,>(f: () => T, fb: T) => { try { return f(); } catch { return fb; } };
|
|
28
|
+
const hour = safe(() => Number(new Intl.DateTimeFormat('en-US', { hour: 'numeric', hourCycle: 'h23', ...tz }).format(new Date())), new Date().getHours());
|
|
29
|
+
const greeting = hour < 5 ? 'Working late' : hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
|
30
|
+
const when = (ts: number) => safe(() => new Date(ts).toLocaleString('en-US', { weekday: 'short', hour: 'numeric', minute: '2-digit', ...tz }), '');
|
|
31
|
+
function ago(ts: number) {
|
|
32
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
33
|
+
if (s < 60) return 'just now';
|
|
34
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
35
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
36
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const noun = (key: string) => (CONTENT.collections[key] ?? CONTENT.posts).singular;
|
|
40
|
+
const firstKey = Object.keys(CONTENT.collections)[0] ?? 'posts';
|
|
41
|
+
const drafts = posts.filter((p) => p.status === 'draft');
|
|
42
|
+
const scheduled = posts.filter((p) => p.status === 'scheduled' && p.scheduledAt).sort((a, b) => a.scheduledAt! - b.scheduledAt!);
|
|
43
|
+
const published = posts.filter((p) => p.status === 'published');
|
|
44
|
+
const summary = [
|
|
45
|
+
drafts.length ? `${drafts.length} ${drafts.length === 1 ? 'draft' : 'drafts'} waiting` : '',
|
|
46
|
+
scheduled.length ? `${scheduled.length === 1 ? `1 ${noun(scheduled[0].collection).toLowerCase()}` : `${scheduled.length} items`} going live ${when(scheduled[0].scheduledAt!)}` : '',
|
|
47
|
+
].filter(Boolean).join(' · ') || 'Everything is published. Nice.';
|
|
48
|
+
|
|
49
|
+
// continue writing: drafts + scheduled, most recently touched first
|
|
50
|
+
const continueRows = [...drafts, ...scheduled].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 4);
|
|
16
51
|
const recent = [
|
|
17
52
|
...pages.map((p) => ({ kind: 'Page', title: p.title, href: `/admin/pages/${p.id}`, at: p.updatedAt, status: p.status })),
|
|
18
|
-
...posts.map((p) => ({
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
status: p.status,
|
|
24
|
-
})),
|
|
25
|
-
]
|
|
26
|
-
.sort((a, b) => b.at - a.at)
|
|
27
|
-
.slice(0, 8);
|
|
28
|
-
|
|
29
|
-
// per-collection counts for the stat row; content whose collection key was
|
|
30
|
-
// removed from config would otherwise be invisible, so it gets a notice
|
|
53
|
+
...posts.map((p) => ({ kind: noun(p.collection), title: p.title, href: `/admin/c/${p.collection}/${p.id}`, at: p.updatedAt, status: p.status })),
|
|
54
|
+
].sort((a, b) => b.at - a.at).slice(0, 5);
|
|
55
|
+
const topLinks = [...links].filter((l) => l.clicks > 0).sort((a, b) => b.clicks - a.clicks).slice(0, 5);
|
|
56
|
+
|
|
57
|
+
// content whose collection key was removed from config would otherwise be invisible
|
|
31
58
|
const countByCollection = new Map<string, number>();
|
|
32
59
|
for (const p of posts) countByCollection.set(p.collection, (countByCollection.get(p.collection) ?? 0) + 1);
|
|
33
60
|
const orphaned = [...countByCollection.entries()].filter(([key]) => !CONTENT.collections[key]);
|
|
34
61
|
|
|
35
|
-
|
|
62
|
+
// setup checklist — disappears once everything is done
|
|
63
|
+
const steps = [
|
|
64
|
+
{ label: 'Add your site URL', done: Boolean(settings.siteUrl), href: '/admin/settings#site' },
|
|
65
|
+
{ label: 'Connect analytics', done: Boolean(settings.analytics?.umami?.enabled), href: '/admin/settings#analytics' },
|
|
66
|
+
{ label: 'Set up navigation', done: nav.length > 0, href: '/admin/navigation' },
|
|
67
|
+
{ label: `Publish your first ${noun(firstKey).toLowerCase()}`, done: published.length > 0, href: `/admin/c/${firstKey}?new=1` },
|
|
68
|
+
{ label: 'Invite a teammate', done: users.length > 0, href: '/admin/users?new=1' },
|
|
69
|
+
];
|
|
70
|
+
const doneCount = steps.filter((s) => s.done).length;
|
|
36
71
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return
|
|
72
|
+
// traffic → KPI tiles + chart
|
|
73
|
+
const fmtN = (n: number) => (n >= 10000 ? `${(n / 1000).toFixed(1)}k` : n.toLocaleString('en-US'));
|
|
74
|
+
const delta = (v: number, prev: number) => (prev > 0 ? Math.round(((v - prev) / prev) * 100) : null);
|
|
75
|
+
const dur = (s: number) => (s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`);
|
|
76
|
+
function areaPath(vals: number[], w: number, h: number, pad = 3) {
|
|
77
|
+
if (vals.length < 2) return { line: '', fill: '' };
|
|
78
|
+
const max = Math.max(...vals, 1), step = (w - pad * 2) / (vals.length - 1);
|
|
79
|
+
const pts = vals.map((v, i) => [pad + i * step, h - pad - (v / max) * (h - pad * 2)]);
|
|
80
|
+
const line = pts.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
|
|
81
|
+
return { line, fill: `${line} L${pts[pts.length - 1][0].toFixed(1)} ${h} L${pad} ${h}Z` };
|
|
43
82
|
}
|
|
83
|
+
const views = traffic?.series.map((s) => s.views) ?? [];
|
|
84
|
+
const chart = areaPath(views, 700, 150);
|
|
85
|
+
const spark = (vals: number[]) => areaPath(vals.slice(-14), 120, 26).line;
|
|
86
|
+
const titleByPath = new Map<string, string>([
|
|
87
|
+
...posts.map((p) => [collectionUrl(p.collection, p.slug), p.title] as [string, string]),
|
|
88
|
+
...pages.map((p) => [`/${p.slug}`, p.title] as [string, string]),
|
|
89
|
+
]);
|
|
90
|
+
const topContent = (traffic?.topPaths ?? []).filter((p) => titleByPath.has(p.path)).slice(0, 5);
|
|
91
|
+
const kpis = traffic
|
|
92
|
+
? [
|
|
93
|
+
{ label: 'Visitors · 30d', value: fmtN(traffic.visitors), d: delta(traffic.visitors, traffic.visitorsPrev), spark: spark(traffic.series.map((s) => s.visitors)) },
|
|
94
|
+
{ label: 'Pageviews', value: fmtN(traffic.pageviews), d: delta(traffic.pageviews, traffic.pageviewsPrev), spark: spark(views) },
|
|
95
|
+
{ label: 'Avg. visit', value: dur(traffic.avgSeconds), d: null, spark: '' },
|
|
96
|
+
{ label: 'Link clicks', value: fmtN(stats.linkClicks), d: null, spark: '' },
|
|
97
|
+
]
|
|
98
|
+
: [
|
|
99
|
+
{ label: 'Published', value: fmtN(published.length + stats.publishedPages), d: null, spark: '' },
|
|
100
|
+
{ label: 'Drafts', value: fmtN(stats.drafts), d: null, spark: '' },
|
|
101
|
+
{ label: 'Scheduled', value: fmtN(scheduled.length), d: null, spark: '' },
|
|
102
|
+
{ label: 'Link clicks', value: fmtN(stats.linkClicks), d: null, spark: '' },
|
|
103
|
+
];
|
|
104
|
+
const umamiOnNoKey = Boolean(settings.analytics?.umami?.enabled) && !umamiReadEnabled(settings);
|
|
105
|
+
const axis = traffic?.series.length
|
|
106
|
+
? [0, Math.floor(traffic.series.length / 3), Math.floor((traffic.series.length * 2) / 3)].map((i) =>
|
|
107
|
+
safe(() => new Date(traffic.series[i].t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...tz }), ''))
|
|
108
|
+
: [];
|
|
109
|
+
const I = {
|
|
110
|
+
write: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4',
|
|
111
|
+
page: 'M7 3h8l4 4v14H7zM15 3v4h4M10 12h6M10 16h6',
|
|
112
|
+
upload: 'M12 16V4M7 9l5-5 5 5M4 17v3h16v-3',
|
|
113
|
+
links: 'M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1.5 1.5M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1.5-1.5',
|
|
114
|
+
check: 'M5 12l5 5L20 7',
|
|
115
|
+
};
|
|
116
|
+
const tiles = [
|
|
117
|
+
{ icon: I.write, title: `Write a ${noun(firstKey).toLowerCase()}`, sub: 'Start a draft in the editor', href: `/admin/c/${firstKey}?new=1` },
|
|
118
|
+
{ icon: I.page, title: 'Add a page', sub: 'Same editor, lives at /your-slug', href: '/admin/pages?new=1' },
|
|
119
|
+
{ icon: I.upload, title: 'Upload media', sub: 'Drop images, get WebP variants', href: '/admin/media' },
|
|
120
|
+
{ icon: I.links, title: 'Edit links page', sub: `${links.length} blocks · ${fmtN(stats.linkClicks)} clicks`, href: '/admin/links' },
|
|
121
|
+
];
|
|
44
122
|
---
|
|
45
123
|
|
|
46
|
-
<Admin title="
|
|
47
|
-
<div class="
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
<
|
|
124
|
+
<Admin title="Overview" section="dashboard">
|
|
125
|
+
<div class="dash">
|
|
126
|
+
<div class="page-head">
|
|
127
|
+
<div>
|
|
128
|
+
<h1>{greeting}{firstName ? `, ${firstName}` : ''}</h1>
|
|
129
|
+
<p class="sub">{summary}</p>
|
|
130
|
+
</div>
|
|
131
|
+
<div class="page-actions">
|
|
132
|
+
<a class="btn primary" href={`/admin/c/${firstKey}?new=1`}>New {noun(firstKey).toLowerCase()}</a>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div id="acms-update" hidden data-current={coreVersion} class="notice">
|
|
137
|
+
<div class="grow-text">
|
|
138
|
+
<b>ACMS <span id="acms-update-latest"></span> is available</b>
|
|
139
|
+
<span class="hint">you're on {coreVersion} — run <code>npx acms update</code>; it prints the deploy steps for your setup.</span>
|
|
140
|
+
</div>
|
|
141
|
+
<a id="acms-update-notes" class="btn sm ghost" href="https://www.npmjs.com/package/@acms-subir/core" target="_blank" rel="noopener">What's new</a>
|
|
142
|
+
<button id="acms-update-dismiss" class="icon-btn" type="button" aria-label="Dismiss until the next release" title="Dismiss until the next release">×</button>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{orphaned.length > 0 && (
|
|
146
|
+
<div class="notice warn">
|
|
147
|
+
<div>
|
|
148
|
+
<b>Content in removed collections</b>
|
|
149
|
+
<p class="hint tight">
|
|
150
|
+
{orphaned.map(([key, n]) => `${n} ${n === 1 ? 'entry' : 'entries'} belong${n === 1 ? 's' : ''} to "${key}"`).join(' · ')}
|
|
151
|
+
— nothing was deleted; re-add the key under content.collections in astro.config to restore them.
|
|
152
|
+
</p>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
<div class="kpis">
|
|
158
|
+
{kpis.map((k) => (
|
|
159
|
+
<div class="kpi">
|
|
160
|
+
<small>{k.label}</small>
|
|
161
|
+
<b>{k.value}{k.d !== null && <span class={`delta ${k.d < 0 ? 'down' : ''}`}>{k.d > 0 ? '+' : ''}{k.d}%</span>}</b>
|
|
162
|
+
{k.spark && <svg class="spark" viewBox="0 0 120 26" preserveAspectRatio="none" aria-hidden="true"><path d={k.spark} /></svg>}
|
|
163
|
+
</div>
|
|
52
164
|
))}
|
|
53
|
-
<a class="btn primary" href="/admin/pages?new=1">New page</a>
|
|
54
165
|
</div>
|
|
55
|
-
</div>
|
|
56
166
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
167
|
+
{traffic && views.length > 1 && (
|
|
168
|
+
<div class="card card-pad chart-card">
|
|
169
|
+
<div class="card-head"><b>Pageviews</b><span class="hint">Last 30 days · Umami</span></div>
|
|
170
|
+
<svg class="chart" viewBox="0 0 700 150" preserveAspectRatio="none" role="img" aria-label="Pageviews over the last 30 days">
|
|
171
|
+
<path class="fill" d={chart.fill} /><path class="line" d={chart.line} />
|
|
172
|
+
</svg>
|
|
173
|
+
<div class="chart-axis">{axis.map((a) => <span>{a}</span>)}<span>Today</span></div>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
{umamiOnNoKey && (
|
|
177
|
+
<p class="hint dash-hint">Umami is tracking visitors. Add a <code>UMAMI_API_TOKEN</code> (self-hosted) or <code>UMAMI_API_KEY</code> (Cloud) environment variable to see traffic here.</p>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
<div class="dash-two">
|
|
181
|
+
<div>
|
|
182
|
+
<div class="section-title">Start something</div>
|
|
183
|
+
<div class="tiles">
|
|
184
|
+
{tiles.map((t) => (
|
|
185
|
+
<a class="tile" href={t.href}>
|
|
186
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={t.icon} /></svg>
|
|
187
|
+
<b>{t.title}</b><small>{t.sub}</small>
|
|
188
|
+
</a>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
{doneCount < steps.length ? (
|
|
193
|
+
<div class="card card-pad">
|
|
194
|
+
<div class="card-head"><b>Finish setting up</b><span class="hint">{doneCount} of {steps.length}</span></div>
|
|
195
|
+
<div class="progress"><i style={`width:${(doneCount / steps.length) * 100}%`} /></div>
|
|
196
|
+
{steps.map((s) => (
|
|
197
|
+
<a class={`step ${s.done ? 'done' : ''}`} href={s.href}>
|
|
198
|
+
<span class="step-dot">{s.done && <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d={I.check} /></svg>}</span>
|
|
199
|
+
{s.label}
|
|
200
|
+
{!s.done && <em>Set up →</em>}
|
|
201
|
+
</a>
|
|
202
|
+
))}
|
|
203
|
+
</div>
|
|
204
|
+
) : (
|
|
205
|
+
<div class="card card-pad">
|
|
206
|
+
<div class="card-head"><b>Top links</b><a class="hint" href="/admin/links">Links page →</a></div>
|
|
207
|
+
{topLinks.length ? topLinks.map((l) => (
|
|
208
|
+
<div class="kv-row"><span>{l.label}</span><b>{l.clicks.toLocaleString('en-US')}</b></div>
|
|
209
|
+
)) : <p class="hint tight">No clicks yet.</p>}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{continueRows.length > 0 && (
|
|
215
|
+
<>
|
|
216
|
+
<div class="section-title">Continue writing</div>
|
|
217
|
+
<div class="mini-rows">
|
|
218
|
+
{continueRows.map((p) => (
|
|
219
|
+
<a class="mini-row" href={`/admin/c/${p.collection}/${p.id}`}>
|
|
220
|
+
<span class="crow-thumb">{p.cover ? <img src={p.cover} alt="" /> : null}</span>
|
|
221
|
+
<span class="grow"><b>{p.title || 'Untitled'}</b><small>{noun(p.collection)} · edited {ago(p.updatedAt)}</small></span>
|
|
222
|
+
<span class={`status ${p.status}`}>{p.status === 'scheduled' ? `Scheduled · ${when(p.scheduledAt!)}` : 'Draft'}</span>
|
|
223
|
+
</a>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
</>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
<div class="dash-two">
|
|
230
|
+
<div>
|
|
231
|
+
<div class="section-title">{topContent.length ? 'Top content · 30d' : 'Recently edited'}</div>
|
|
232
|
+
<div class="mini-rows">
|
|
233
|
+
{topContent.length
|
|
234
|
+
? topContent.map((t) => (
|
|
235
|
+
<a class="mini-row bar-row" href={t.path} target="_blank" rel="noopener">
|
|
236
|
+
<i style={`width:${(t.views / topContent[0].views) * 100}%`} />
|
|
237
|
+
<span class="grow">{titleByPath.get(t.path)}</span><b>{t.views.toLocaleString('en-US')}</b>
|
|
238
|
+
</a>
|
|
239
|
+
))
|
|
240
|
+
: recent.map((r) => (
|
|
241
|
+
<a class="mini-row" href={r.href}>
|
|
242
|
+
<span class="grow"><b>{r.title || 'Untitled'}</b><small>{r.kind} · {ago(r.at)}</small></span>
|
|
243
|
+
<span class={`status ${r.status}`}>{r.status[0].toUpperCase() + r.status.slice(1)}</span>
|
|
244
|
+
</a>
|
|
245
|
+
))}
|
|
246
|
+
{!topContent.length && recent.length === 0 && <p class="hint">Nothing yet — create a page or {CONTENT.posts.singular.toLowerCase()}.</p>}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
{doneCount < steps.length && (
|
|
250
|
+
<div>
|
|
251
|
+
<div class="section-title">Top links</div>
|
|
252
|
+
<div class="mini-rows">
|
|
253
|
+
{topLinks.length ? topLinks.map((l) => (
|
|
254
|
+
<div class="mini-row"><span class="grow">{l.label}</span><b>{l.clicks.toLocaleString('en-US')}</b></div>
|
|
255
|
+
)) : <p class="hint">No link clicks yet.</p>}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
61
259
|
</div>
|
|
62
|
-
<a id="acms-update-notes" class="btn sm ghost" style="margin-left:auto" href="https://www.npmjs.com/package/@acms-subir/core" target="_blank" rel="noopener">What's new</a>
|
|
63
|
-
<button id="acms-update-dismiss" class="btn sm ghost" type="button" aria-label="Dismiss until the next release" title="Dismiss until the next release">×</button>
|
|
64
260
|
</div>
|
|
261
|
+
|
|
65
262
|
<script>
|
|
66
263
|
// Ghost-style update nudge: npm registry check, cached a day, silent on
|
|
67
264
|
// any failure (offline, unpublished package, CORS). Dismissal is stored
|
|
@@ -105,58 +302,8 @@ function ago(ts: number) {
|
|
|
105
302
|
document.getElementById('acms-update-dismiss')?.addEventListener('click', () => {
|
|
106
303
|
try { localStorage.setItem(DISMISSED, latest); } catch {}
|
|
107
304
|
el.hidden = true;
|
|
108
|
-
el.style.display = 'none';
|
|
109
305
|
});
|
|
110
306
|
el.hidden = false;
|
|
111
|
-
el.style.display = 'flex';
|
|
112
307
|
})();
|
|
113
308
|
</script>
|
|
114
|
-
|
|
115
|
-
{orphaned.length > 0 && (
|
|
116
|
-
<div class="card card-pad" style="margin-bottom:14px;border-left:3px solid var(--warn, #c77700)">
|
|
117
|
-
<b>Content in removed collections</b>
|
|
118
|
-
<p class="hint" style="margin:4px 0 0">
|
|
119
|
-
{orphaned.map(([key, n]) => `${n} ${n === 1 ? 'entry' : 'entries'} belong${n === 1 ? 's' : ''} to "${key}"`).join(' · ')}
|
|
120
|
-
— nothing was deleted; re-add the key under content.collections in astro.config to restore them.
|
|
121
|
-
</p>
|
|
122
|
-
</div>
|
|
123
|
-
)}
|
|
124
|
-
|
|
125
|
-
<div class="stats">
|
|
126
|
-
<div class="stat"><div class="label">Published pages</div><div class="value">{stats.publishedPages}</div></div>
|
|
127
|
-
{Object.entries(CONTENT.collections).map(([key, c]) => (
|
|
128
|
-
<div class="stat"><div class="label">{c.plural}</div><div class="value">{countByCollection.get(key) ?? 0}</div></div>
|
|
129
|
-
))}
|
|
130
|
-
<div class="stat"><div class="label">Drafts</div><div class="value">{stats.drafts}</div></div>
|
|
131
|
-
<div class="stat"><div class="label">Link clicks</div><div class="value">{stats.linkClicks}</div></div>
|
|
132
|
-
</div>
|
|
133
|
-
|
|
134
|
-
<div class="dash-grid">
|
|
135
|
-
<div class="card card-pad">
|
|
136
|
-
<p class="card-title">Recently edited</p>
|
|
137
|
-
<ul class="recent">
|
|
138
|
-
{recent.map((r) => (
|
|
139
|
-
<li>
|
|
140
|
-
<span class={`pill ${r.status}`}>{r.status}</span>
|
|
141
|
-
<a href={r.href}>{r.title}</a>
|
|
142
|
-
<span class="t-sub">{r.kind}</span>
|
|
143
|
-
<span class="when">{ago(r.at)}</span>
|
|
144
|
-
</li>
|
|
145
|
-
))}
|
|
146
|
-
{recent.length === 0 && <li class="hint">Nothing yet — create a page or {CONTENT.posts.singular.toLowerCase()}.</li>}
|
|
147
|
-
</ul>
|
|
148
|
-
</div>
|
|
149
|
-
<div class="card card-pad">
|
|
150
|
-
<p class="card-title">Top links</p>
|
|
151
|
-
<ul class="recent">
|
|
152
|
-
{topLinks.map((l) => (
|
|
153
|
-
<li>
|
|
154
|
-
<span>{l.label}</span>
|
|
155
|
-
<span class="when">{l.clicks} clicks</span>
|
|
156
|
-
</li>
|
|
157
|
-
))}
|
|
158
|
-
{topLinks.length === 0 && <li class="hint">No links yet.</li>}
|
|
159
|
-
</ul>
|
|
160
|
-
</div>
|
|
161
|
-
</div>
|
|
162
309
|
</Admin>
|
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
// Site-wide integrations. Owners manage keys and webhook subscriptions;
|
|
15
|
+
// calendar-specific feeds and API help appear only when enabled.
|
|
16
|
+
const repo = await getRepo(Astro.locals);
|
|
17
|
+
const sessionId = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
|
|
18
|
+
const me = sessionId && sessionId !== OWNER_ENV ? await repo.getUser(sessionId) : null;
|
|
19
|
+
const isOwner = sessionId === OWNER_ENV || me?.role === 'owner';
|
|
20
|
+
const settings = await repo.getSettings();
|
|
21
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
22
|
+
let notice = '';
|
|
23
|
+
let error = '';
|
|
24
|
+
let newKey = '';
|
|
25
|
+
let newSecret = '';
|
|
26
|
+
const dev = import.meta.env.DEV;
|
|
27
|
+
|
|
28
|
+
if (Astro.request.method === 'POST') {
|
|
29
|
+
if (!isOwner) return new Response('Owners only', { status: 403 });
|
|
30
|
+
const form = await Astro.request.formData();
|
|
31
|
+
const op = String(form.get('op'));
|
|
32
|
+
try {
|
|
33
|
+
if (op === 'key') {
|
|
34
|
+
const scopes = form.getAll('scopes').map(String).filter((s): s is ApiScope => (API_SCOPES as readonly string[]).includes(s));
|
|
35
|
+
if (!scopes.length) throw new Error('Pick at least one permission.');
|
|
36
|
+
const { key } = await issueApiKey(repo, String(form.get('name') || 'API key').slice(0, 80), scopes);
|
|
37
|
+
newKey = key;
|
|
38
|
+
} else if (op === 'revoke') {
|
|
39
|
+
await repo.deleteApiKey(String(form.get('id')));
|
|
40
|
+
notice = 'Key revoked — anything using it stops working now.';
|
|
41
|
+
} else if (op === 'hook') {
|
|
42
|
+
const url = validateWebhookUrl(String(form.get('url') || ''), dev);
|
|
43
|
+
const events = form.getAll('events').map(String).filter((e) => (WEBHOOK_EVENTS as readonly string[]).includes(e));
|
|
44
|
+
if (!events.length) throw new Error('Pick at least one event.');
|
|
45
|
+
const secret = generateWebhookSecret();
|
|
46
|
+
await repo.createWebhook({ url, secret, events });
|
|
47
|
+
newSecret = secret;
|
|
48
|
+
} else if (op === 'subscriptions') {
|
|
49
|
+
const events = form.getAll('events').map(String).filter(e => (WEBHOOK_EVENTS as readonly string[]).includes(e));
|
|
50
|
+
if (!events.length) throw new Error('Pick at least one event.');
|
|
51
|
+
await repo.updateWebhook(String(form.get('id')), { events });
|
|
52
|
+
notice = 'Event subscriptions saved.';
|
|
53
|
+
} else if (op === 'toggle') {
|
|
54
|
+
const h = (await repo.listWebhooks()).find((w) => w.id === form.get('id'));
|
|
55
|
+
if (h) await repo.updateWebhook(h.id, { active: !h.active });
|
|
56
|
+
} else if (op === 'delhook') {
|
|
57
|
+
await repo.deleteWebhook(String(form.get('id')));
|
|
58
|
+
notice = 'Webhook removed.';
|
|
59
|
+
} else if (op === 'test') {
|
|
60
|
+
const h = (await repo.listWebhooks()).find((w) => w.id === form.get('id'));
|
|
61
|
+
if (h) {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
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 };
|
|
64
|
+
await repo.createDeliveries([d]);
|
|
65
|
+
await attemptDelivery(repo, d, h);
|
|
66
|
+
const last = (await repo.listDeliveries(h.id, 1))[0];
|
|
67
|
+
notice = last?.responseCode ? `Test sent — the endpoint answered ${last.responseCode}.` : 'Test sent — the endpoint didn’t answer.';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
error = err instanceof Error ? err.message : 'Something went wrong.';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const [keys, hooks, calendars] = await Promise.all([repo.listApiKeys(), repo.listWebhooks(), repo.listCalendars()]);
|
|
76
|
+
const logs = new Map(await Promise.all(hooks.map(async (h) => [h.id, await repo.listDeliveries(h.id, 5)] as const)));
|
|
77
|
+
const emailConfig = resolveConfig(await repo.email.config(), Astro.locals);
|
|
78
|
+
const provider = emailConfig.error ? 'none' : emailConfig.provider;
|
|
79
|
+
const ago = (ts: number | null) => {
|
|
80
|
+
if (!ts) return 'never';
|
|
81
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
82
|
+
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`;
|
|
83
|
+
};
|
|
84
|
+
const SCOPE_LABEL: Record<string, string> = {
|
|
85
|
+
'events:read': 'Read events (incl. private calendars)', 'events:write': 'Create and edit events',
|
|
86
|
+
'bookings:read': 'Read bookings and guest details', 'bookings:write': 'Create, confirm and cancel bookings',
|
|
87
|
+
'resources:read': 'Read resources and links', 'resources:write': 'Edit resources and links',
|
|
88
|
+
};
|
|
89
|
+
const feeds = [
|
|
90
|
+
{ label: 'Everything public', url: origin + calendarFeedUrl() },
|
|
91
|
+
...calendars.filter((c) => c.kind === 'native' && c.visibility !== 'private').map((c) => ({ label: c.name, url: origin + calendarFeedUrl(c.slug) })),
|
|
92
|
+
];
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
<Admin title="API & webhooks" section="integrations">
|
|
96
|
+
<div class="card" style="padding:20px;margin-bottom:20px">
|
|
97
|
+
<h2>Live video</h2><p>Connect your own Twitch or YouTube app to discover broadcasts automatically on your homepage and links page.</p>
|
|
98
|
+
<a class="btn" href="/admin/live">Set up live video →</a>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div class="page-head">
|
|
102
|
+
<div>
|
|
103
|
+
<h1>API & webhooks</h1>
|
|
104
|
+
<p class="sub">Connect content, design, media{CALENDAR.enabled ? ', calendars' : ''}{bookingsEnabled() ? ' and bookings' : ''} to your automations. <a href="/api/webhooks/docs" target="_blank" rel="noopener">API reference ↗</a></p>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
{notice && <p class="notice" role="status">{notice}</p>}
|
|
108
|
+
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
109
|
+
{!isOwner && <p class="notice">Only owners can create keys or webhooks.</p>}
|
|
110
|
+
{newKey && (
|
|
111
|
+
<div class="notice reveal">
|
|
112
|
+
<b>Copy this key now — it won’t be shown again.</b>
|
|
113
|
+
<div class="cal-feed"><input type="text" class="mono" readonly value={newKey} /><button class="btn sm" type="button" data-copy={newKey}>Copy</button></div>
|
|
114
|
+
<span class="hint">Send it as <code>Authorization: Bearer {newKey.slice(0, 14)}…</code></span>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
{newSecret && (
|
|
118
|
+
<div class="notice reveal">
|
|
119
|
+
<b>Signing secret — copy it into the receiver now.</b>
|
|
120
|
+
<div class="cal-feed"><input type="text" class="mono" readonly value={newSecret} /><button class="btn sm" type="button" data-copy={newSecret}>Copy</button></div>
|
|
121
|
+
<span class="hint">Verify <code>X-Acms-Signature</code> = <code>sha256=</code>HMAC-SHA256(secret, <code>{'{X-Acms-Timestamp}.{body}'}</code>).</span>
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
<div class="res-grid">
|
|
126
|
+
<div>
|
|
127
|
+
<section class="card card-pad">
|
|
128
|
+
<div class="card-head"><b>API keys</b>{isOwner && <button class="btn sm" type="button" data-modal="new-key">New key</button>}</div>
|
|
129
|
+
{keys.length === 0 && <p class="hint">No keys yet. Public calendars and bookings don’t need one — keys unlock private data and changes.</p>}
|
|
130
|
+
{keys.map((k) => (
|
|
131
|
+
<div class="int-row">
|
|
132
|
+
<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>
|
|
133
|
+
<span class="hint">used {ago(k.lastUsedAt)}</span>
|
|
134
|
+
{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>}
|
|
135
|
+
</div>
|
|
136
|
+
))}
|
|
137
|
+
<p class="hint">Content-agent keys can access calendar integrations. Design and media APIs require explicitly scoped keys.</p>
|
|
138
|
+
</section>
|
|
139
|
+
|
|
140
|
+
<section class="card card-pad">
|
|
141
|
+
<div class="card-head"><b>Webhooks</b>{isOwner && <button class="btn sm" type="button" data-modal="new-hook">Add endpoint</button>}</div>
|
|
142
|
+
{hooks.length === 0 && <p class="hint">Get a signed POST when content goes live, a design is published, media changes or a booking comes in.</p>}
|
|
143
|
+
{hooks.map((h) => {
|
|
144
|
+
const log = logs.get(h.id) ?? [];
|
|
145
|
+
const ok = h.lastStatus !== null && h.lastStatus >= 200 && h.lastStatus < 300;
|
|
146
|
+
return (
|
|
147
|
+
<div class="int-hook">
|
|
148
|
+
<div class="int-row">
|
|
149
|
+
<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>
|
|
150
|
+
<span class={`int-status ${h.lastStatus === null ? '' : ok ? 'ok' : 'bad'}`}>{h.lastStatus === null ? 'no deliveries yet' : `${h.lastStatus || 'no answer'} · ${ago(h.lastAt)}`}</span>
|
|
151
|
+
{isOwner && (
|
|
152
|
+
<form method="post" class="int-acts">
|
|
153
|
+
<input type="hidden" name="id" value={h.id} />
|
|
154
|
+
<button class="btn sm" name="op" value="test">Send test</button>
|
|
155
|
+
<button class="btn sm ghost" name="op" value="toggle">{h.active ? 'Pause' : 'Resume'}</button>
|
|
156
|
+
<button class="btn sm ghost danger" name="op" value="delhook" data-confirm="Remove this webhook?">Remove</button>
|
|
157
|
+
</form>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
{isOwner && <details class="int-log">
|
|
161
|
+
<summary>Edit event subscriptions</summary>
|
|
162
|
+
<form method="post">
|
|
163
|
+
<input type="hidden" name="op" value="subscriptions" />
|
|
164
|
+
<input type="hidden" name="id" value={h.id} />
|
|
165
|
+
{WEBHOOK_EVENTS.map(e => <label class="check-row"><input type="checkbox" name="events" value={e} checked={h.events.includes(e) || h.events.includes('*')} /> <code>{e}</code></label>)}
|
|
166
|
+
<button class="btn sm" type="submit">Save events</button>
|
|
167
|
+
</form>
|
|
168
|
+
</details>}
|
|
169
|
+
{log.length > 0 && (
|
|
170
|
+
<details class="int-log"><summary>Last {log.length} deliveries</summary>
|
|
171
|
+
{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>)}
|
|
172
|
+
</details>
|
|
173
|
+
)}
|
|
174
|
+
{!h.active && <p class="hint">Paused — nothing is sent.</p>}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
</section>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div class="res-side">
|
|
182
|
+
<section class="card card-pad">
|
|
183
|
+
<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>
|
|
184
|
+
{provider === 'none' ? (
|
|
185
|
+
<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>
|
|
186
|
+
) : <p class="hint">Sending as <code>{emailConfig.config.from}</code>. Guests get confirmations, updates and a reminder the day before.</p>}
|
|
187
|
+
</section>
|
|
188
|
+
{CALENDAR.enabled && <section class="card card-pad">
|
|
189
|
+
<div class="card-head"><b>Subscribe feeds</b></div>
|
|
190
|
+
{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>)}
|
|
191
|
+
<p class="hint">Private calendars and resource staff feeds have secret links on their own screens.</p>
|
|
192
|
+
</section>}
|
|
193
|
+
<section class="card card-pad">
|
|
194
|
+
<div class="card-head"><b>Quick start</b></div>
|
|
195
|
+
<p class="hint">Use publication events to refresh search indexes, clear caches or trigger builds. Media events can start asset processing. A restore event tells consumers to resync.</p>
|
|
196
|
+
<p><a href="/api/webhooks/docs" target="_blank" rel="noopener">Webhook events and payloads ↗</a></p>
|
|
197
|
+
{CALENDAR.enabled && <p><a href="/api/calendar/docs" target="_blank" rel="noopener">Calendar API reference ↗</a></p>}
|
|
198
|
+
</section>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<Modal id="new-key" title="New API key">
|
|
203
|
+
<form method="post">
|
|
204
|
+
<input type="hidden" name="op" value="key" />
|
|
205
|
+
<label class="field"><span>Name</span><input type="text" name="name" placeholder="Zapier, lobby screen…" required /></label>
|
|
206
|
+
<div class="field"><span>Permissions</span>
|
|
207
|
+
{API_SCOPES.filter((s) => (CALENDAR.enabled || !s.startsWith('events:')) && (bookingsEnabled() || !s.startsWith('bookings:') && !s.startsWith('resources:'))).map((s) => (
|
|
208
|
+
<label class="check-row"><input type="checkbox" name="scopes" value={s} checked={s.endsWith(':read')} /> {SCOPE_LABEL[s] ?? s} <code>{s}</code></label>
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Create key</button></div>
|
|
212
|
+
</form>
|
|
213
|
+
</Modal>
|
|
214
|
+
<Modal id="new-hook" title="Add webhook endpoint">
|
|
215
|
+
<form method="post">
|
|
216
|
+
<input type="hidden" name="op" value="hook" />
|
|
217
|
+
<label class="field"><span>Endpoint URL</span><input type="url" class="mono" name="url" placeholder="https://hooks.zapier.com/…" required /></label>
|
|
218
|
+
<div class="field"><span>Send these events</span>
|
|
219
|
+
{WEBHOOK_EVENTS.filter((e) => (CALENDAR.enabled || !e.startsWith('event.')) && (bookingsEnabled() || !e.startsWith('booking.'))).map((e) => <label class="check-row"><input type="checkbox" name="events" value={e} checked={e === 'post.published' || e === 'page.published'} /> <code>{e}</code></label>)}
|
|
220
|
+
</div>
|
|
221
|
+
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Add endpoint</button></div>
|
|
222
|
+
<p class="hint modal-note">Deliveries are signed and retried 3 times over about an hour.</p>
|
|
223
|
+
</form>
|
|
224
|
+
</Modal>
|
|
225
|
+
</Admin>
|
|
226
|
+
|
|
227
|
+
<script>
|
|
228
|
+
document.addEventListener('click', async (e) => {
|
|
229
|
+
const c = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
|
|
230
|
+
if (c && !confirm(c.dataset.confirm)) { e.preventDefault(); return; }
|
|
231
|
+
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
232
|
+
if (!b) return;
|
|
233
|
+
await navigator.clipboard.writeText(b.dataset.copy ?? '');
|
|
234
|
+
b.textContent = 'Copied';
|
|
235
|
+
setTimeout(() => (b.textContent = 'Copy'), 1200);
|
|
236
|
+
});
|
|
237
|
+
</script>
|
|
@@ -11,11 +11,11 @@ const [links, settings] = await Promise.all([repo.listLinks(), repo.getSettings(
|
|
|
11
11
|
const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
-
<Admin title="Links" section="links">
|
|
14
|
+
<Admin title="Links page" section="links">
|
|
15
15
|
<div class="page-head">
|
|
16
|
-
<h1>Links</h1>
|
|
16
|
+
<div><h1>Links page</h1><p class="sub">Your link-in-bio page at /links</p></div>
|
|
17
17
|
<a class="btn sm" href="/links" target="_blank" rel="noopener">View links page ↗</a>
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
-
<LinksManager client:only="react" links={links} settings={{ ...settings, draftsApiKey: '' }} unsplashEnabled={unsplashEnabled} />
|
|
20
|
+
<LinksManager client:only="react" links={links} settings={{ ...settings, draftsApiKey: '', ai: { ...settings.ai, encryptedKey: '' }, agent: { ...settings.agent, keyHash: '' } }} unsplashEnabled={unsplashEnabled} />
|
|
21
21
|
</Admin>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import LiveManager from '../../components/LiveManager';
|
|
4
|
+
import { getRepo } from '../../lib/repo';
|
|
5
|
+
import { requireSession } from '../../lib/api';
|
|
6
|
+
const repo = await getRepo(Astro.locals);
|
|
7
|
+
try { await requireSession(Astro.request, Astro.cookies, repo, true); }
|
|
8
|
+
catch { return new Response('Owner access is required.', { status: 403 }); }
|
|
9
|
+
---
|
|
10
|
+
<Admin title="Live video" section="integrations">
|
|
11
|
+
<LiveManager client:only="react" />
|
|
12
|
+
</Admin>
|