@acms-subir/core 0.3.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 +9 -0
- package/docs/live-video.md +91 -0
- package/integration.mjs +7 -0
- package/package.json +1 -1
- package/src/actions/index.ts +3 -2
- package/src/components/LinksManager.tsx +5 -1
- package/src/components/LiveCard.astro +174 -0
- package/src/components/LiveManager.tsx +910 -0
- package/src/components/ThemeSections.astro +2 -1
- package/src/components/WidgetRenderer.astro +3 -0
- package/src/layouts/Admin.astro +1 -1
- package/src/layouts/Site.astro +28 -0
- package/src/lib/api.ts +6 -2
- package/src/lib/backup/content.ts +1 -1
- 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/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +58 -12
- package/src/lib/repo/types.ts +3 -0
- package/src/lib/scheduler.ts +2 -0
- package/src/lib/schema.ts +7 -1
- package/src/lib/themeTokens.ts +9 -0
- package/src/lib/webhook-sql.ts +68 -0
- package/src/lib/webhooks.ts +15 -7
- package/src/lib/widgets.ts +1 -0
- package/src/middleware.ts +10 -1
- package/src/pages/[...slug].astro +6 -0
- package/src/pages/admin/calendar/integrations.astro +2 -219
- package/src/pages/admin/integrations.astro +237 -0
- package/src/pages/admin/live.astro +12 -0
- package/src/pages/api/calendar/docs.ts +4 -2
- 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/webhooks/docs.ts +61 -0
- package/src/pages/index.astro +6 -0
- package/src/pages/links.astro +6 -0
- package/src/styles/live-admin.css +126 -0
- package/src/styles/live.css +199 -0
- package/src/theme-sdk/contract.mjs +1 -0
- package/src/theme-sdk/tokens.mjs +3 -0
- package/src/themes/editorial/theme.css +5 -0
- package/src/themes/editorial/theme.json +2 -0
- package/src/themes/field/theme.json +1 -0
- package/src/themes/night/theme.json +1 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { subscribeLive } from './client';
|
|
2
|
+
import type { PublicLiveSnapshot } from './types';
|
|
3
|
+
|
|
4
|
+
// Site-wide live signals (Site.astro renders them hidden when the owner
|
|
5
|
+
// enables them): the live bar, the header marker and the floating dock. All
|
|
6
|
+
// three appear only while a broadcast is actually live, and dismissals last
|
|
7
|
+
// for that broadcast in this browser session.
|
|
8
|
+
|
|
9
|
+
const dismissKey = (what: string, s: PublicLiveSnapshot) => `acms-live-${what}-${s.provider}-${s.id || s.watchUrl}`;
|
|
10
|
+
const dismissed = (what: string, s: PublicLiveSnapshot) => {
|
|
11
|
+
try { return sessionStorage.getItem(dismissKey(what, s)) === '1'; } catch { return false; }
|
|
12
|
+
};
|
|
13
|
+
const dismiss = (what: string, s: PublicLiveSnapshot) => {
|
|
14
|
+
try { sessionStorage.setItem(dismissKey(what, s), '1'); } catch { /* private mode: dismiss for this page only */ }
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// a live card already on the page makes the dock redundant and gives the
|
|
18
|
+
// header marker a local target
|
|
19
|
+
function pageCard(): HTMLElement | null {
|
|
20
|
+
const card = Array.from(document.querySelectorAll<HTMLElement>('[data-live-card]')).find((c) => !c.hidden && c.dataset.status === 'live');
|
|
21
|
+
if (card && !card.id) card.id = 'live-video';
|
|
22
|
+
return card ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function openExternally(a: HTMLAnchorElement, url: string) {
|
|
26
|
+
a.href = url;
|
|
27
|
+
a.target = '_blank';
|
|
28
|
+
a.rel = 'noopener noreferrer';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function mountLiveSignals() {
|
|
32
|
+
const bar = document.querySelector<HTMLElement>('[data-live-bar]:not([data-ready])');
|
|
33
|
+
const marker = document.querySelector<HTMLAnchorElement>('[data-live-header]:not([data-ready])');
|
|
34
|
+
const dock = document.querySelector<HTMLElement>('[data-live-dock]:not([data-ready])');
|
|
35
|
+
let current: PublicLiveSnapshot | null = null;
|
|
36
|
+
|
|
37
|
+
if (bar) {
|
|
38
|
+
bar.dataset.ready = '';
|
|
39
|
+
bar.querySelector('.pc-live-x')?.addEventListener('click', () => {
|
|
40
|
+
if (current) dismiss('bar', current);
|
|
41
|
+
bar.hidden = true;
|
|
42
|
+
delete document.documentElement.dataset.liveBar;
|
|
43
|
+
});
|
|
44
|
+
subscribeLive(bar, (s) => {
|
|
45
|
+
current = s;
|
|
46
|
+
const show = s?.status === 'live' && !dismissed('bar', s);
|
|
47
|
+
bar.hidden = !show;
|
|
48
|
+
if (show) document.documentElement.dataset.liveBar = '';
|
|
49
|
+
else delete document.documentElement.dataset.liveBar;
|
|
50
|
+
if (!s || !show) return;
|
|
51
|
+
bar.querySelector('[data-live-bar-title]')!.textContent = s.title;
|
|
52
|
+
const card = pageCard();
|
|
53
|
+
const a = bar.querySelector<HTMLAnchorElement>('[data-live-bar-watch]')!;
|
|
54
|
+
if (card) { a.href = `#${card.id}`; a.removeAttribute('target'); } else openExternally(a, s.watchUrl);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (marker) {
|
|
59
|
+
marker.dataset.ready = '';
|
|
60
|
+
subscribeLive(marker, (s) => {
|
|
61
|
+
marker.hidden = s?.status !== 'live';
|
|
62
|
+
if (!s || marker.hidden) return;
|
|
63
|
+
const card = pageCard();
|
|
64
|
+
marker.title = s.title;
|
|
65
|
+
if (card) { marker.href = `#${card.id}`; marker.removeAttribute('target'); } else openExternally(marker, s.watchUrl);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (dock) {
|
|
70
|
+
dock.dataset.ready = '';
|
|
71
|
+
const link = dock.querySelector<HTMLAnchorElement>('a')!;
|
|
72
|
+
const img = dock.querySelector<HTMLImageElement>('img')!;
|
|
73
|
+
img.addEventListener('load', () => { img.hidden = false; });
|
|
74
|
+
img.addEventListener('error', () => { img.hidden = true; });
|
|
75
|
+
dock.querySelector('.pc-live-x')?.addEventListener('click', () => {
|
|
76
|
+
if (current) dismiss('dock', current);
|
|
77
|
+
dock.hidden = true;
|
|
78
|
+
});
|
|
79
|
+
subscribeLive(dock, (s) => {
|
|
80
|
+
current = s;
|
|
81
|
+
dock.hidden = s?.status !== 'live' || dismissed('dock', s) || !!pageCard();
|
|
82
|
+
if (!s || dock.hidden) return;
|
|
83
|
+
openExternally(link, s.watchUrl);
|
|
84
|
+
dock.querySelector('[data-live-dock-title]')!.textContent = s.title;
|
|
85
|
+
if (!s.thumbUrl) { img.hidden = true; img.removeAttribute('src'); }
|
|
86
|
+
else if (img.getAttribute('src') !== s.thumbUrl) img.src = s.thumbUrl;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import type { SqlDriver } from '../repo/sql';
|
|
3
|
+
import type { Connection, Provider, LiveSnapshot, Channel } from './types';
|
|
4
|
+
const parse = <T>(raw: unknown, fallback: T): T => { try { return raw ? JSON.parse(String(raw)) : fallback; } catch { return fallback; } };
|
|
5
|
+
function connection(r: Record<string, unknown>): Connection {
|
|
6
|
+
return { provider: r.provider as Provider, clientId: String(r.client_id), channelInput: String(r.channel_input), channelId: String(r.channel_id), channelName: String(r.channel_name), encrypted: String(r.encrypted), enabled: !!r.enabled, revision: Number(r.revision), channels: parse<Channel[]>(r.channels, []), googleStatus: r.google_status as Connection['googleStatus'], testedAt: Number(r.tested_at), checkedAt: Number(r.checked_at), lastAttemptAt: Number(r.last_attempt_at), nextAt: Number(r.next_at), failures: Number(r.failures), error: String(r.error), lease: String(r.lease), leaseUntil: Number(r.lease_until), snapshot: parse<LiveSnapshot | null>(r.snapshot, null), upcoming: parse<LiveSnapshot | null>(r.upcoming, null), upcomingCheckedAt: Number(r.upcoming_checked_at) };
|
|
7
|
+
}
|
|
8
|
+
const columns: Record<string, string> = { clientId:'client_id', channelInput:'channel_input', channelId:'channel_id', channelName:'channel_name', encrypted:'encrypted', enabled:'enabled', channels:'channels', googleStatus:'google_status', testedAt:'tested_at', checkedAt:'checked_at', lastAttemptAt:'last_attempt_at', nextAt:'next_at', failures:'failures', error:'error', snapshot:'snapshot', upcoming:'upcoming', upcomingCheckedAt:'upcoming_checked_at' };
|
|
9
|
+
const serialized = new Set(['channels','snapshot','upcoming']);
|
|
10
|
+
export class LiveStore {
|
|
11
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
12
|
+
async list() { return (await (await this.driver()).all('SELECT * FROM live_connections ORDER BY provider')).map(connection); }
|
|
13
|
+
async get(provider: Provider) { return (await this.list()).find(c => c.provider === provider) ?? null; }
|
|
14
|
+
// Revisions invalidate in-flight provider requests and OAuth callbacks when a setting changes.
|
|
15
|
+
async save(provider: Provider, data: { clientId: string; channelInput: string; encrypted: string; googleStatus: string }) {
|
|
16
|
+
// A recreated connection must not reuse the revision of an in-flight OAuth callback.
|
|
17
|
+
const revision = randomInt(1, 2 ** 48 - 1);
|
|
18
|
+
await (await this.driver()).run(`INSERT INTO live_connections (provider,client_id,channel_input,encrypted,google_status,revision) VALUES (?,?,?,?,?,?)
|
|
19
|
+
ON CONFLICT(provider) DO UPDATE SET client_id=excluded.client_id,channel_input=excluded.channel_input,encrypted=excluded.encrypted,google_status=excluded.google_status,
|
|
20
|
+
enabled=0,tested_at=0,revision=revision+1,lease='',lease_until=0,snapshot=NULL,upcoming=NULL,upcoming_checked_at=0,error='',failures=0,next_at=0`, [provider,data.clientId,data.channelInput,data.encrypted,data.googleStatus,revision]);
|
|
21
|
+
}
|
|
22
|
+
async patch(provider: Provider, data: Partial<Connection>, revision: number, lease?: string): Promise<boolean> {
|
|
23
|
+
const entries = Object.entries(data).filter(([k]) => columns[k]);
|
|
24
|
+
if (!entries.length) return false;
|
|
25
|
+
const args = entries.map(([k,v]) => serialized.has(k) ? v == null ? null : JSON.stringify(v) : typeof v === 'boolean' ? Number(v) : v);
|
|
26
|
+
const rows = await (await this.driver()).all(`UPDATE live_connections SET ${entries.map(([k]) => `${columns[k]}=?`).join(',')},
|
|
27
|
+
${lease ? "lease='',lease_until=0" : "revision=revision+1,lease='',lease_until=0"}
|
|
28
|
+
WHERE provider=? AND revision=?${lease ? ' AND lease=?' : ''} RETURNING provider`, [...args,provider,revision,...(lease ? [lease] : [])]);
|
|
29
|
+
return rows.length > 0;
|
|
30
|
+
}
|
|
31
|
+
async claim(provider: Provider, now: number, force = false) {
|
|
32
|
+
const lease = crypto.randomUUID();
|
|
33
|
+
const rows = await (await this.driver()).all(`UPDATE live_connections SET lease=?,lease_until=?,last_attempt_at=?
|
|
34
|
+
WHERE provider=? AND lease_until<=? AND ${force ? '(last_attempt_at=0 OR last_attempt_at<=?)' : 'enabled=1 AND next_at<=?'} RETURNING *`, [lease,now+90_000,now,provider,now,force ? now-10_000 : now]);
|
|
35
|
+
return rows[0] ? connection(rows[0]) : null;
|
|
36
|
+
}
|
|
37
|
+
async remove(provider: Provider) {
|
|
38
|
+
await (await this.driver()).batch([{sql:'DELETE FROM live_connections WHERE provider=?',args:[provider]}, ...(provider === 'youtube' ? [{sql:'DELETE FROM live_oauth_states'}] : [])]);
|
|
39
|
+
}
|
|
40
|
+
async state(hash: string, sessionHash: string, revision: number, encrypted: string, expiresAt: number) {
|
|
41
|
+
await (await this.driver()).batch([{sql:'DELETE FROM live_oauth_states WHERE expires_at<? OR session_hash=?',args:[Date.now(),sessionHash]}, {sql:'INSERT INTO live_oauth_states (hash,session_hash,revision,encrypted,expires_at) VALUES (?,?,?,?,?)',args:[hash,sessionHash,revision,encrypted,expiresAt]}]);
|
|
42
|
+
}
|
|
43
|
+
async consumeState(hash: string, sessionHash: string, now: number) {
|
|
44
|
+
const rows = await (await this.driver()).all('DELETE FROM live_oauth_states WHERE hash=? AND session_hash=? AND expires_at>? RETURNING revision,encrypted', [hash,sessionHash,now]);
|
|
45
|
+
return rows[0] ? {revision:Number(rows[0].revision),encrypted:String(rows[0].encrypted)} : null;
|
|
46
|
+
}
|
|
47
|
+
async heartbeat(now: number) { await (await this.driver()).run('UPDATE live_health SET last_at=? WHERE id=1',[now]); }
|
|
48
|
+
async lastHeartbeat() { return Number((await (await this.driver()).all('SELECT last_at FROM live_health WHERE id=1'))[0]?.last_at ?? 0); }
|
|
49
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const Provider = z.enum(['youtube', 'twitch']);
|
|
3
|
+
export type Provider = z.infer<typeof Provider>;
|
|
4
|
+
const httpsUrl = z.string().trim().max(2048).refine(v => { if (!v) return true; try { const u = new URL(v); return u.protocol === 'https:' && !u.username && !u.password; } catch { return false; } }, 'Use an HTTPS URL without credentials.');
|
|
5
|
+
export const LiveConfig = z.object({
|
|
6
|
+
mode: z.enum(['off', 'automatic', 'manual']).default('off'),
|
|
7
|
+
priority: z.array(Provider).max(2).default(['youtube', 'twitch']).refine(v => new Set(v).size === v.length, 'Choose each provider only once.'),
|
|
8
|
+
fallbackUrl: httpsUrl.default(''),
|
|
9
|
+
showUpcoming: z.boolean().default(false),
|
|
10
|
+
manual: z.object({ watchUrl: httpsUrl.default(''), title: z.string().trim().max(200).default('We’re live'), until: z.number().int().nonnegative().default(0) }).strict().default({}),
|
|
11
|
+
// How the public site presents the snapshot. Site-wide signals start off so
|
|
12
|
+
// an upgrade never changes an existing site without the owner opting in.
|
|
13
|
+
display: z.object({
|
|
14
|
+
style: z.enum(['poster', 'split', 'row']).default('poster'),
|
|
15
|
+
thumbnail: z.boolean().default(true),
|
|
16
|
+
viewers: z.boolean().default(false),
|
|
17
|
+
bar: z.boolean().default(false),
|
|
18
|
+
header: z.boolean().default(false),
|
|
19
|
+
dock: z.boolean().default(false),
|
|
20
|
+
}).strict().default({}),
|
|
21
|
+
}).strict();
|
|
22
|
+
export type LiveConfig = z.infer<typeof LiveConfig>;
|
|
23
|
+
export interface LiveSnapshot {
|
|
24
|
+
status: 'live' | 'offline' | 'upcoming' | 'unknown';
|
|
25
|
+
source: 'automatic' | 'manual';
|
|
26
|
+
provider: Provider | 'facebook' | 'other';
|
|
27
|
+
id: string; title: string; watchUrl: string; embedId: string;
|
|
28
|
+
startedAt: number; checkedAt: number; expiresAt: number;
|
|
29
|
+
// optional: snapshots stored before these existed parse unchanged
|
|
30
|
+
channel?: string; viewers?: number;
|
|
31
|
+
}
|
|
32
|
+
// What visitors receive: viewers only when the owner opts in, and a same-site
|
|
33
|
+
// thumbnail URL (empty when disabled or the provider has no image).
|
|
34
|
+
export interface PublicLiveSnapshot extends Omit<LiveSnapshot, 'viewers'> { viewers?: number; thumbUrl: string }
|
|
35
|
+
export interface Channel { id: string; name: string }
|
|
36
|
+
export interface Credentials { clientSecret: string; accessToken?: string; refreshToken?: string; tokenExpiresAt?: number; tokenValidatedAt?: number; callbackUrl?: string }
|
|
37
|
+
export interface Connection {
|
|
38
|
+
provider: Provider; clientId: string; channelInput: string; channelId: string; channelName: string;
|
|
39
|
+
encrypted: string; enabled: boolean; revision: number; channels: Channel[];
|
|
40
|
+
googleStatus: 'testing' | 'production'; testedAt: number; checkedAt: number; lastAttemptAt: number;
|
|
41
|
+
nextAt: number; failures: number; error: string; lease: string; leaseUntil: number;
|
|
42
|
+
snapshot: LiveSnapshot | null; upcoming: LiveSnapshot | null; upcomingCheckedAt: number;
|
|
43
|
+
}
|
|
44
|
+
export interface PublicConnection extends Omit<Connection, 'encrypted' | 'lease' | 'leaseUntil' | 'snapshot' | 'upcoming'> {
|
|
45
|
+
hasSecret: boolean; authorized: boolean; broadcastStatus: LiveSnapshot['status'];
|
|
46
|
+
}
|
|
47
|
+
export interface LiveAdminState {
|
|
48
|
+
config: LiveConfig; connections: PublicConnection[]; callbackUrl: string;
|
|
49
|
+
encryptionReady: boolean; scheduler: { lastAt: number; healthy: boolean };
|
|
50
|
+
snapshot: LiveSnapshot | null;
|
|
51
|
+
}
|
|
52
|
+
export const emptySnapshot = (provider: Provider, now: number): LiveSnapshot => ({ status: 'offline', source: 'automatic', provider, id: '', title: '', watchUrl: '', embedId: '', startedAt: 0, checkedAt: now, expiresAt: now + 120_000 });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { PublicLiveSnapshot } from './types';
|
|
2
|
+
|
|
3
|
+
// Display text for a live snapshot. Pure and DOM-free: LiveCard renders it on
|
|
4
|
+
// the server and the browser re-applies it on every status change, so both
|
|
5
|
+
// always agree.
|
|
6
|
+
|
|
7
|
+
export const PROVIDER_NAMES: Record<string, string> = { youtube: 'YouTube', twitch: 'Twitch', facebook: 'Facebook', other: '' };
|
|
8
|
+
|
|
9
|
+
export interface LiveView {
|
|
10
|
+
status: 'live' | 'upcoming' | 'offline';
|
|
11
|
+
provider: string;
|
|
12
|
+
// poster tag: "LIVE" or "Sat 7:00 PM · in 2 h"
|
|
13
|
+
tag: string;
|
|
14
|
+
// marker line above the title: "LIVE ON TWITCH"
|
|
15
|
+
kicker: string;
|
|
16
|
+
title: string;
|
|
17
|
+
// "heymunmusic · started 42 min ago"
|
|
18
|
+
meta: string;
|
|
19
|
+
viewers: string;
|
|
20
|
+
watchLabel: string;
|
|
21
|
+
watchUrl: string;
|
|
22
|
+
thumbUrl: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MIN = 60_000, HOUR = 60 * MIN, DAY = 24 * HOUR;
|
|
26
|
+
|
|
27
|
+
export function compactCount(n: number): string {
|
|
28
|
+
if (n < 1000) return String(n);
|
|
29
|
+
const [v, unit] = n < 1e6 ? [n / 1e3, 'K'] : [n / 1e6, 'M'];
|
|
30
|
+
return `${v < 10 ? Math.floor(v * 10) / 10 : Math.floor(v)}${unit}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ago(ms: number): string {
|
|
34
|
+
if (ms < MIN) return 'just now';
|
|
35
|
+
if (ms < HOUR) return `${Math.floor(ms / MIN)} min ago`;
|
|
36
|
+
const h = Math.floor(ms / HOUR), m = Math.floor((ms % HOUR) / MIN);
|
|
37
|
+
return m ? `${h} h ${m} min ago` : `${h} h ago`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function until(ms: number): string {
|
|
41
|
+
if (ms < MIN) return 'starting now';
|
|
42
|
+
if (ms < HOUR) return `in ${Math.ceil(ms / MIN)} min`;
|
|
43
|
+
if (ms < DAY) return `in ${Math.round(ms / HOUR)} h`;
|
|
44
|
+
const d = Math.round(ms / DAY);
|
|
45
|
+
return `in ${d} day${d === 1 ? '' : 's'}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function when(ts: number, now: number): string {
|
|
49
|
+
const d = new Date(ts);
|
|
50
|
+
const time = d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
51
|
+
if (ts - now < 6 * DAY) return `${d.toLocaleDateString(undefined, { weekday: 'short' })} ${time}`;
|
|
52
|
+
return `${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function liveView(s: PublicLiveSnapshot | null, now: number): LiveView | null {
|
|
56
|
+
if (!s || (s.status !== 'live' && s.status !== 'upcoming' && s.status !== 'offline')) return null;
|
|
57
|
+
const name = PROVIDER_NAMES[s.provider] ?? '';
|
|
58
|
+
const channel = s.channel?.trim() ?? '';
|
|
59
|
+
const base = { status: s.status, provider: s.provider, title: s.title, watchUrl: s.watchUrl, thumbUrl: s.thumbUrl };
|
|
60
|
+
if (s.status === 'offline') {
|
|
61
|
+
return { ...base, tag: '', kicker: '', meta: channel, viewers: '', thumbUrl: '', watchLabel: 'Watch our channel ↗' };
|
|
62
|
+
}
|
|
63
|
+
const watchLabel = name ? `Watch on ${name} ↗` : 'Watch now ↗';
|
|
64
|
+
if (s.status === 'upcoming') {
|
|
65
|
+
const start = s.startedAt > 0 ? `${when(s.startedAt, now)} · ${until(s.startedAt - now)}` : '';
|
|
66
|
+
return {
|
|
67
|
+
...base, tag: start || 'UPCOMING', kicker: name ? `UPCOMING ON ${name.toUpperCase()}` : 'UPCOMING',
|
|
68
|
+
meta: [channel, name && 'scheduled'].filter(Boolean).join(' · '), viewers: '', watchLabel: name ? `Open on ${name} ↗` : 'Open ↗',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const started = s.startedAt > 0 && s.startedAt <= now ? `started ${ago(now - s.startedAt)}` : '';
|
|
72
|
+
return {
|
|
73
|
+
...base, tag: 'LIVE', kicker: name ? `LIVE ON ${name.toUpperCase()}` : 'LIVE NOW',
|
|
74
|
+
meta: [channel, started].filter(Boolean).join(' · '),
|
|
75
|
+
viewers: typeof s.viewers === 'number' ? `${compactCount(s.viewers)} watching` : '',
|
|
76
|
+
watchLabel,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { LIVE_MIGRATION } from '../live/migration';
|
|
1
2
|
import { EMAIL_MIGRATION } from '../email/migration';
|
|
3
|
+
import { WEBHOOK_MIGRATION } from '../webhook-sql';
|
|
2
4
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
3
5
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
4
6
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -495,6 +497,8 @@ export const MIGRATIONS: Migration[] = [
|
|
|
495
497
|
actual_revision INTEGER NOT NULL CHECK (expected_revision = actual_revision)
|
|
496
498
|
)`,
|
|
497
499
|
] },
|
|
500
|
+
WEBHOOK_MIGRATION,
|
|
501
|
+
LIVE_MIGRATION,
|
|
498
502
|
];
|
|
499
503
|
|
|
500
504
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { LiveStore } from '../live/store';
|
|
2
|
+
import { LiveConfig } from '../live/types';
|
|
1
3
|
import { ApiError } from '../api';
|
|
2
4
|
import { EmailStore, bookingJobs } from '../email/store';
|
|
3
5
|
import type { ContentRepository, DesignVersion, EventUpdate, SearchResults, Stats } from './types';
|
|
@@ -9,6 +11,7 @@ import { MIGRATIONS, MIGRATIONS_TABLE } from './migrations';
|
|
|
9
11
|
import { htmlToPlaintext } from '../plaintext';
|
|
10
12
|
import { SEED } from '../brand';
|
|
11
13
|
import { CONTENT, postsIndexUrl } from '../content';
|
|
14
|
+
import { webhookStatement } from '../webhook-sql';
|
|
12
15
|
|
|
13
16
|
// Shared SQL implementation of ContentRepository. Cloudflare D1 and libSQL
|
|
14
17
|
// are both SQLite, so every statement, mapper and behavior lives here once;
|
|
@@ -215,9 +218,11 @@ export class SqlRepository implements ContentRepository {
|
|
|
215
218
|
private driver!: SqlDriver;
|
|
216
219
|
private ready: Promise<void>;
|
|
217
220
|
readonly email: EmailStore;
|
|
221
|
+
readonly live: LiveStore;
|
|
218
222
|
|
|
219
223
|
constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
220
224
|
this.ready = this.init(driver);
|
|
225
|
+
this.live = new LiveStore(async () => { await this.ready; return this.driver; });
|
|
221
226
|
this.email = new EmailStore(async () => { await this.ready; return this.driver; });
|
|
222
227
|
}
|
|
223
228
|
|
|
@@ -435,6 +440,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
435
440
|
}
|
|
436
441
|
async updatePage(id: string, data: Parameters<ContentRepository['updatePage']>[1]) {
|
|
437
442
|
await this.ready;
|
|
443
|
+
if (!(await this.getPage(id))) return;
|
|
444
|
+
const writes: SqlStatement[] = [];
|
|
438
445
|
// a direct live write supersedes any staged edits (applyPageDraft routes
|
|
439
446
|
// through here too, so applying clears the snapshot in the same UPDATE)
|
|
440
447
|
const sets: string[] = ['updated_at = ?', 'draft = NULL'];
|
|
@@ -457,12 +464,13 @@ export class SqlRepository implements ContentRepository {
|
|
|
457
464
|
data.locale !== undefined
|
|
458
465
|
? data.locale
|
|
459
466
|
: String((await this.first('SELECT locale FROM pages WHERE id = ?', [id]))?.locale ?? '');
|
|
460
|
-
|
|
467
|
+
writes.push({ sql: 'UPDATE pages SET is_home = 0 WHERE locale = ? AND id != ? AND is_home = 1', args: [locale, id] });
|
|
461
468
|
}
|
|
462
469
|
sets.push('is_home = ?'); args.push(data.isHome ? 1 : 0);
|
|
463
470
|
}
|
|
464
471
|
args.push(id);
|
|
465
|
-
|
|
472
|
+
writes.push({ sql: `UPDATE pages SET ${sets.join(', ')} WHERE id = ?`, args });
|
|
473
|
+
await this.driver.batch(writes);
|
|
466
474
|
await this.syncPageIndex(id);
|
|
467
475
|
}
|
|
468
476
|
// stage edits on a live page: only the `draft` JSON changes — live columns,
|
|
@@ -577,8 +585,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
577
585
|
if (data.featured !== undefined) { sets.push('featured = ?'); args.push(data.featured ? 1 : 0); }
|
|
578
586
|
if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
|
|
579
587
|
// authorIds is the source of truth; author_id mirrors the first entry.
|
|
580
|
-
//
|
|
581
|
-
// update (e.g. duplicate slug) can't leave the two out of sync.
|
|
588
|
+
// Main row, join rows and webhook notifications share one transaction.
|
|
582
589
|
let authorJoin: string[] | null = null;
|
|
583
590
|
if (data.authorIds !== undefined) {
|
|
584
591
|
authorJoin = data.authorIds;
|
|
@@ -596,16 +603,28 @@ export class SqlRepository implements ContentRepository {
|
|
|
596
603
|
}
|
|
597
604
|
}
|
|
598
605
|
args.push(id);
|
|
599
|
-
|
|
606
|
+
const writes: SqlStatement[] = [{ sql: `UPDATE posts SET ${sets.join(', ')} WHERE id = ?`, args }];
|
|
600
607
|
if (authorJoin !== null) {
|
|
601
|
-
|
|
608
|
+
writes.push(
|
|
602
609
|
{ sql: 'DELETE FROM post_authors WHERE post_id = ?', args: [id] },
|
|
603
610
|
...authorJoin.map((aid, i) => ({
|
|
604
611
|
sql: 'INSERT INTO post_authors (post_id, author_id, ord) VALUES (?, ?, ?)',
|
|
605
612
|
args: [id, aid, i],
|
|
606
613
|
})),
|
|
607
|
-
|
|
614
|
+
);
|
|
615
|
+
// The main-row trigger detects primary-author changes. Additional
|
|
616
|
+
// authors live in a join table, so emit once for an authors-only edit.
|
|
617
|
+
const authorsOnly = current.status === 'published'
|
|
618
|
+
&& (authorJoin[0] ?? null) === current.authorId
|
|
619
|
+
&& JSON.stringify(authorJoin) !== JSON.stringify(current.authorIds)
|
|
620
|
+
&& Object.entries(data).every(([key, value]) => key === 'authorIds' || value === undefined
|
|
621
|
+
|| JSON.stringify(value) === JSON.stringify(current[key as keyof Post]));
|
|
622
|
+
if (authorsOnly) {
|
|
623
|
+
const post = { id, slug: current.slug, title: current.title, status: current.status, collection: current.collection };
|
|
624
|
+
writes.push(webhookStatement('post.updated', { post, previous: post }));
|
|
625
|
+
}
|
|
608
626
|
}
|
|
627
|
+
await this.driver.batch(writes);
|
|
609
628
|
await this.syncPostIndex(id);
|
|
610
629
|
}
|
|
611
630
|
// stage edits on a live post: only the `draft` JSON changes — live columns,
|
|
@@ -1192,6 +1211,15 @@ export class SqlRepository implements ContentRepository {
|
|
|
1192
1211
|
);
|
|
1193
1212
|
return rows.map(rowToDelivery);
|
|
1194
1213
|
}
|
|
1214
|
+
async claimDelivery(id: string, now: number) {
|
|
1215
|
+
await this.ready;
|
|
1216
|
+
// A lease outlasts the HTTP timeout. Competing request/cron flushes must
|
|
1217
|
+
// not send the same pending delivery at the same time.
|
|
1218
|
+
const r = await this.first(`UPDATE webhook_deliveries SET next_at = ?
|
|
1219
|
+
WHERE id = ? AND status = 'pending' AND (next_at IS NULL OR next_at <= ?)
|
|
1220
|
+
RETURNING *`, [now + 30_000, id, now]);
|
|
1221
|
+
return r ? rowToDelivery(r) : null;
|
|
1222
|
+
}
|
|
1195
1223
|
async recordDelivery(id: string, data: Parameters<ContentRepository['recordDelivery']>[1]) {
|
|
1196
1224
|
await this.ready;
|
|
1197
1225
|
const r = await this.first('SELECT webhook_id FROM webhook_deliveries WHERE id = ?', [id]);
|
|
@@ -1546,6 +1574,9 @@ export class SqlRepository implements ContentRepository {
|
|
|
1546
1574
|
async restoreAll(content: Parameters<ContentRepository['restoreAll']>[0]) {
|
|
1547
1575
|
await this.ready;
|
|
1548
1576
|
const stmts: SqlStatement[] = [
|
|
1577
|
+
// Suppression and re-enabling share the restore transaction. A failed
|
|
1578
|
+
// restore cannot leave notifications disabled or emit phantom deletes.
|
|
1579
|
+
{ sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
|
|
1549
1580
|
...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests'].map((t) => ({
|
|
1550
1581
|
sql: `DELETE FROM ${t}`,
|
|
1551
1582
|
})),
|
|
@@ -1614,8 +1645,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
1614
1645
|
args: [r.id, r.fromPath, r.toPath, r.code, r.createdAt],
|
|
1615
1646
|
})),
|
|
1616
1647
|
];
|
|
1648
|
+
const settingsWrite = this.settingsStatement({ ...content.settings, live: { ...LiveConfig.parse(content.settings.live ?? {}), mode: 'off', manual: { watchUrl: '', title: 'We’re live', until: 0 } } });
|
|
1649
|
+
stmts.push({ sql: "UPDATE live_connections SET enabled=0,tested_at=0,revision=revision+1,lease='',lease_until=0,snapshot=NULL,upcoming=NULL,error='Restored content. Test this connection before enabling automatic checks.'" }, { sql: 'DELETE FROM live_oauth_states' });
|
|
1650
|
+
if (settingsWrite) stmts.push(settingsWrite);
|
|
1651
|
+
stmts.push(
|
|
1652
|
+
{ sql: 'UPDATE webhook_control SET suppressed=0 WHERE id=1' },
|
|
1653
|
+
webhookStatement('site.restored', { counts: { posts: content.posts.length, pages: content.pages.length, media: content.media.length } }),
|
|
1654
|
+
);
|
|
1617
1655
|
await this.driver.batch(stmts);
|
|
1618
|
-
await this.updateSettings(content.settings);
|
|
1619
1656
|
await this.reindexSearch();
|
|
1620
1657
|
}
|
|
1621
1658
|
|
|
@@ -1666,6 +1703,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1666
1703
|
headCode: r?.head_code ?? '', footerCode: r?.footer_code ?? '',
|
|
1667
1704
|
timezone: r?.timezone ?? '', locale: r?.locale ?? '',
|
|
1668
1705
|
ai: JSON.parse(String(r?.ai ?? '{}')),
|
|
1706
|
+
live: JSON.parse(String(r?.live ?? '{}')),
|
|
1669
1707
|
agent: JSON.parse(String(r?.agent ?? '{}')),
|
|
1670
1708
|
draftsApiKey: r?.drafts_api_key ?? '',
|
|
1671
1709
|
draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
|
|
@@ -1674,18 +1712,22 @@ export class SqlRepository implements ContentRepository {
|
|
|
1674
1712
|
}
|
|
1675
1713
|
async updateSettings(data: Partial<Settings>) {
|
|
1676
1714
|
await this.ready;
|
|
1715
|
+
const statement = this.settingsStatement(data);
|
|
1716
|
+
if (statement) await this.driver.run(statement.sql, statement.args);
|
|
1717
|
+
}
|
|
1718
|
+
private settingsStatement(data: Partial<Settings>): SqlStatement | undefined {
|
|
1677
1719
|
const columns: Record<string, string> = {
|
|
1678
1720
|
siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
|
|
1679
1721
|
activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
|
|
1680
1722
|
announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
|
|
1681
|
-
draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent',
|
|
1723
|
+
draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live',
|
|
1682
1724
|
};
|
|
1683
|
-
const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent']);
|
|
1725
|
+
const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live']);
|
|
1684
1726
|
const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
|
|
1685
1727
|
if (!entries.length) return;
|
|
1686
1728
|
const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
|
|
1687
|
-
|
|
1688
|
-
entries.map(([k,v]) => jsonKeys.has(k) && v !== null ? JSON.stringify(v) : v)
|
|
1729
|
+
return { sql: `UPDATE settings SET ${entries.map(([k]) => `${columns[k]} = ?`).join(', ')}${design ? ', design_revision = design_revision + 1' : ''} WHERE id = 1`,
|
|
1730
|
+
args: entries.map(([k,v]) => jsonKeys.has(k) && v !== null ? JSON.stringify(v) : v) };
|
|
1689
1731
|
}
|
|
1690
1732
|
private async designWrite(expected: number, actor: string, action: string, statements: SqlStatement[]) {
|
|
1691
1733
|
try {
|
|
@@ -1739,6 +1781,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
1739
1781
|
if (nav) statements.push({ sql: 'DELETE FROM nav_items' }, ...nav.map((n,i) => ({
|
|
1740
1782
|
sql: 'INSERT INTO nav_items (id,label,url,area,ord) VALUES (?,?,?,?,?)', args: [n.id.startsWith('tmp-') ? crypto.randomUUID() : n.id,n.label,n.url,n.area,i],
|
|
1741
1783
|
})));
|
|
1784
|
+
statements.push(webhookStatement('design.published', { design: {
|
|
1785
|
+
revision: current.designRevision + 1, activeTheme: design.activeTheme,
|
|
1786
|
+
navigationChanged: nav !== null, sectionPageIds: Object.keys(design.sectionPages),
|
|
1787
|
+
} }));
|
|
1742
1788
|
await this.designWrite(opts.expectedRevision ?? current.designRevision, opts.author ?? 'admin', 'publish', statements);
|
|
1743
1789
|
}
|
|
1744
1790
|
async discardDesignDraft() { await this.setDesignDraft(null); }
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LiveStore } from '../live/store';
|
|
1
2
|
import type { BookingRequest } from '../backup/content';
|
|
2
3
|
import type { EmailStore } from '../email/store';
|
|
3
4
|
export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
|
|
@@ -42,6 +43,7 @@ export type EventUpdate = Partial<
|
|
|
42
43
|
// this — never a concrete adapter.
|
|
43
44
|
export interface ContentRepository {
|
|
44
45
|
readonly email: EmailStore;
|
|
46
|
+
readonly live: LiveStore;
|
|
45
47
|
// Bounded public catalog; fetch limit + 1 IDs without loading every body.
|
|
46
48
|
listPublicContent(options: { page: number; limit: number; kind?: 'post' | 'page'; collection?: string; locale?: string; collections: string[]; defaultLocale: string }): Promise<{ id: string; kind: 'post' | 'page' }[]>;
|
|
47
49
|
exportBookingState(): Promise<{ bookings: Booking[]; bookingRequests: BookingRequest[] }>;
|
|
@@ -172,6 +174,7 @@ export interface ContentRepository {
|
|
|
172
174
|
createDeliveries(rows: Omit<WebhookDelivery, 'attempts' | 'responseCode' | 'deliveredAt'>[]): Promise<void>;
|
|
173
175
|
listDueDeliveries(now: number, limit: number): Promise<WebhookDelivery[]>;
|
|
174
176
|
listDeliveries(webhookId: string, limit: number): Promise<WebhookDelivery[]>;
|
|
177
|
+
claimDelivery(id: string, now: number): Promise<WebhookDelivery | null>;
|
|
175
178
|
recordDelivery(id: string, data: Pick<WebhookDelivery, 'status' | 'attempts' | 'nextAt' | 'responseCode' | 'deliveredAt'>): Promise<void>;
|
|
176
179
|
|
|
177
180
|
// media
|
package/src/lib/scheduler.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { syncDueLive } from './live/service';
|
|
1
2
|
import type { ContentRepository } from './repo/types';
|
|
2
3
|
import { processDueDeliveries } from './webhooks';
|
|
3
4
|
import { dispatchEmail } from './email/service';
|
|
@@ -13,6 +14,7 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
13
14
|
webhooks: () => processDueDeliveries(repo),
|
|
14
15
|
reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
15
16
|
email: () => dispatchEmail(repo, locals, 10),
|
|
17
|
+
live: () => syncDueLive(repo, locals),
|
|
16
18
|
};
|
|
17
19
|
const results: Record<string, { ok: boolean; count?: number }> = {};
|
|
18
20
|
for (const [name, run] of Object.entries(jobs)) {
|
package/src/lib/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LiveConfig } from './live/types';
|
|
1
2
|
import { SectionPages } from '../theme-sdk/contract.mjs';
|
|
2
3
|
import { AiConfig, AgentConfig } from './ai-schema';
|
|
3
4
|
import { z } from 'zod';
|
|
@@ -8,6 +9,7 @@ import { z } from 'zod';
|
|
|
8
9
|
// component in src/components/widgets/ + an editor section in the admin.
|
|
9
10
|
|
|
10
11
|
export const widgetConfigs = {
|
|
12
|
+
live: z.object({ display: z.enum(['card','player']).default('card') }),
|
|
11
13
|
hero: z.object({
|
|
12
14
|
heading: z.string().default(''),
|
|
13
15
|
sub: z.string().default(''),
|
|
@@ -195,7 +197,7 @@ export const authorSlug = (u: Pick<User, 'name' | 'slug'>) => u.slug || slugify(
|
|
|
195
197
|
// `<svg…>` markup (brand icons), or the keyword 'favicon'. `meta` is per-kind
|
|
196
198
|
// extras: { color, textColor } button colors, { size } spacer height, vcard
|
|
197
199
|
// fields, etc.
|
|
198
|
-
export const LinkKind = z.enum(['link', 'email', 'phone', 'heading', 'text', 'spacer', 'vcard', 'html']);
|
|
200
|
+
export const LinkKind = z.enum(['link', 'email', 'phone', 'heading', 'text', 'spacer', 'vcard', 'html', 'live']);
|
|
199
201
|
export type LinkKind = z.infer<typeof LinkKind>;
|
|
200
202
|
|
|
201
203
|
export const Link = z.object({
|
|
@@ -509,6 +511,9 @@ export const ApiKey = z.object({
|
|
|
509
511
|
export type ApiKey = z.infer<typeof ApiKey>;
|
|
510
512
|
|
|
511
513
|
export const WEBHOOK_EVENTS = [
|
|
514
|
+
'post.published', 'post.updated', 'post.unpublished', 'post.deleted',
|
|
515
|
+
'page.published', 'page.updated', 'page.unpublished', 'page.deleted',
|
|
516
|
+
'design.published', 'media.created', 'media.updated', 'media.deleted', 'site.restored',
|
|
512
517
|
'event.published', 'event.updated', 'event.unpublished', 'event.deleted',
|
|
513
518
|
'booking.created', 'booking.confirmed', 'booking.declined', 'booking.cancelled', 'booking.rescheduled',
|
|
514
519
|
] as const;
|
|
@@ -554,6 +559,7 @@ export function normalizeRedirectPath(input: string): string {
|
|
|
554
559
|
}
|
|
555
560
|
|
|
556
561
|
export const Settings = z.object({
|
|
562
|
+
live: LiveConfig.default({}),
|
|
557
563
|
siteTitle: z.string().default('My Site'),
|
|
558
564
|
tagline: z.string().default(''),
|
|
559
565
|
// site-wide footer, markdown allowed; empty falls back to "title — tagline"
|
package/src/lib/themeTokens.ts
CHANGED
|
@@ -138,11 +138,20 @@ export function paletteVars(p: Palette, t: ThemeTokens, dark: boolean): string[]
|
|
|
138
138
|
`--btn:${t.button === 'accent' ? 'var(--accent)' : 'var(--ink)'}`, `--btn-ink:${btnInk}`,
|
|
139
139
|
`--code-bg:${dark ? '#0d0e11' : '#1b1c21'}`,
|
|
140
140
|
`--success:${dark ? '#5cc98e' : '#127442'}`, `--danger:${dark ? '#f07b7b' : '#b42f2f'}`, `--warning:${dark ? '#e3b456' : '#8a5f0a'}`,
|
|
141
|
+
...liveVars(dark ? t.dark?.live || (t.live ? ensureContrast(t.live, p.bg, 3, '#ffffff') : '#ff5a4e') : t.live || '#d42f25', p.bg),
|
|
141
142
|
`--sh-1:${s1}`, `--sh-2:${s2}`, `--sh-3:${s3}`,
|
|
142
143
|
`color-scheme:${dark ? 'dark' : 'light'}`,
|
|
143
144
|
];
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
// The live signal color stays visible on the canvas (3:1, it's a marker, not
|
|
148
|
+
// body text) and gets whichever text color reads on it.
|
|
149
|
+
function liveVars(live: string, bg: string): string[] {
|
|
150
|
+
const c = ensureContrast(live, bg, 3);
|
|
151
|
+
const on = contrast('#ffffff', c) >= 4.5 || contrast('#ffffff', c) >= contrast('#111214', c) ? '#ffffff' : '#111214';
|
|
152
|
+
return [`--live:${c}`, `--live-on:${on}`];
|
|
153
|
+
}
|
|
154
|
+
|
|
146
155
|
// Scheme-independent variables: type, shape, rhythm, layout widths.
|
|
147
156
|
export function staticVars(t: ThemeTokens): string[] {
|
|
148
157
|
const [r1, r2, r3] = RADII[t.radius];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { SqlStatement } from './repo/sql';
|
|
2
|
+
import type { WebhookEventType } from './schema';
|
|
3
|
+
|
|
4
|
+
// Shared by triggers and transactional design/restore writes. SQL expressions
|
|
5
|
+
// passed here are authored in this module, never supplied by a request.
|
|
6
|
+
const now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
|
|
7
|
+
function enqueue(event: string, data: string) {
|
|
8
|
+
return `INSERT INTO webhook_deliveries (id,webhook_id,event,payload,status,attempts,next_at,created_at)
|
|
9
|
+
SELECT lower(hex(randomblob(16))),h.id,${event},
|
|
10
|
+
json_object('type',${event},'createdAt',strftime('%Y-%m-%dT%H:%M:%fZ','now'),'data',json(${data})),
|
|
11
|
+
'pending',0,${now},${now}
|
|
12
|
+
FROM webhooks h WHERE h.active=1 AND EXISTS (
|
|
13
|
+
SELECT 1 FROM json_each(h.events) WHERE value=${event} OR value='*'
|
|
14
|
+
) AND (SELECT suppressed FROM webhook_control WHERE id=1)=0;`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function webhookStatement(event: WebhookEventType, data: Record<string, unknown>): SqlStatement {
|
|
18
|
+
return {
|
|
19
|
+
sql: enqueue(`'${event}'`, '?'),
|
|
20
|
+
args: [JSON.stringify(data)],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function trigger(name: string, action: string, table: string, when: string, event: string, data: string) {
|
|
25
|
+
return `CREATE TRIGGER ${name} AFTER ${action} ON ${table} WHEN ${when}
|
|
26
|
+
BEGIN ${enqueue(event, data)} END`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function content(kind: 'post' | 'page') {
|
|
30
|
+
const table = `${kind}s`;
|
|
31
|
+
const fields = kind === 'post'
|
|
32
|
+
? ['collection','slug','title','status','body','body_json','body_html','excerpt','tags','cover','cover_alt','seo_title','seo_desc','featured','author_id','template','published_at']
|
|
33
|
+
: ['slug','title','status','is_home','locale','blocks','body_json','body_html','seo_title','seo_desc','template'];
|
|
34
|
+
// Metadata only: draft bodies, unpublished edits, credentials and user
|
|
35
|
+
// details never enter a publication webhook. Receivers fetch public content.
|
|
36
|
+
const snapshot = (row: string) => `json_object('id',${row}.id,'slug',${row}.slug,'title',${row}.title,'status',${row}.status,
|
|
37
|
+
${kind === 'post' ? `'collection',${row}.collection` : `'locale',${row}.locale,'isHome',json(CASE WHEN ${row}.is_home THEN 'true' ELSE 'false' END)`})`;
|
|
38
|
+
const changed = fields.map(f => `OLD.${f} IS NOT NEW.${f}`).join(' OR ');
|
|
39
|
+
const event = `CASE WHEN OLD.status!='published' THEN '${kind}.published'
|
|
40
|
+
WHEN NEW.status!='published' THEN '${kind}.unpublished' ELSE '${kind}.updated' END`;
|
|
41
|
+
return [
|
|
42
|
+
trigger(`webhook_${kind}_insert`, 'INSERT', table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${snapshot('NEW')})`),
|
|
43
|
+
trigger(`webhook_${kind}_update`, 'UPDATE', table,
|
|
44
|
+
`(OLD.status='published' OR NEW.status='published') AND (${changed})`, event,
|
|
45
|
+
`json_object('${kind}',json(CASE WHEN NEW.status='published' THEN ${snapshot('NEW')}
|
|
46
|
+
ELSE json_set(${snapshot('OLD')},'$.status',NEW.status) END),
|
|
47
|
+
'previous',json(CASE WHEN OLD.status='published' THEN ${snapshot('OLD')} ELSE 'null' END))`),
|
|
48
|
+
trigger(`webhook_${kind}_delete`, 'DELETE', table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${snapshot('OLD')})`),
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const media = (row: string) => `json_object('media',json_object('id',${row}.id,'key',${row}.key,'filename',${row}.filename,
|
|
53
|
+
'mime',${row}.mime,'width',${row}.width,'height',${row}.height,'size',${row}.size,'alt',${row}.alt))`;
|
|
54
|
+
|
|
55
|
+
export const WEBHOOK_MIGRATION = { version: 33, statements: [
|
|
56
|
+
`CREATE TABLE webhook_control (id INTEGER PRIMARY KEY CHECK(id=1), suppressed INTEGER NOT NULL DEFAULT 0)`,
|
|
57
|
+
`INSERT INTO webhook_control (id,suppressed) VALUES (1,0)`,
|
|
58
|
+
// Populate the envelope from the inserted delivery's stable ID. Retries
|
|
59
|
+
// send these exact stored bytes, including for SQL-generated notifications.
|
|
60
|
+
`CREATE TRIGGER webhook_delivery_envelope AFTER INSERT ON webhook_deliveries
|
|
61
|
+
WHEN json_extract(NEW.payload,'$.id') IS NULL BEGIN
|
|
62
|
+
UPDATE webhook_deliveries SET payload=json_set(payload,'$.id',NEW.id) WHERE id=NEW.id;
|
|
63
|
+
END`,
|
|
64
|
+
...content('post'), ...content('page'),
|
|
65
|
+
trigger('webhook_media_create','INSERT','media','1',"'media.created'",media('NEW')),
|
|
66
|
+
trigger('webhook_media_update','UPDATE','media','OLD.alt IS NOT NEW.alt',"'media.updated'",media('NEW')),
|
|
67
|
+
trigger('webhook_media_delete','DELETE','media','1',"'media.deleted'",media('OLD')),
|
|
68
|
+
] };
|