@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/live-video.md +91 -0
  3. package/integration.mjs +7 -0
  4. package/package.json +1 -1
  5. package/src/actions/index.ts +3 -2
  6. package/src/components/LinksManager.tsx +5 -1
  7. package/src/components/LiveCard.astro +174 -0
  8. package/src/components/LiveManager.tsx +910 -0
  9. package/src/components/ThemeSections.astro +2 -1
  10. package/src/components/WidgetRenderer.astro +3 -0
  11. package/src/layouts/Admin.astro +1 -1
  12. package/src/layouts/Site.astro +28 -0
  13. package/src/lib/api.ts +6 -2
  14. package/src/lib/backup/content.ts +1 -1
  15. package/src/lib/live/client.ts +88 -0
  16. package/src/lib/live/crypto.ts +28 -0
  17. package/src/lib/live/migration.ts +15 -0
  18. package/src/lib/live/providers.ts +152 -0
  19. package/src/lib/live/service.ts +170 -0
  20. package/src/lib/live/signals.ts +89 -0
  21. package/src/lib/live/store.ts +49 -0
  22. package/src/lib/live/types.ts +52 -0
  23. package/src/lib/live/view.ts +78 -0
  24. package/src/lib/repo/migrations.ts +4 -0
  25. package/src/lib/repo/sql.ts +58 -12
  26. package/src/lib/repo/types.ts +3 -0
  27. package/src/lib/scheduler.ts +2 -0
  28. package/src/lib/schema.ts +7 -1
  29. package/src/lib/themeTokens.ts +9 -0
  30. package/src/lib/webhook-sql.ts +68 -0
  31. package/src/lib/webhooks.ts +15 -7
  32. package/src/lib/widgets.ts +1 -0
  33. package/src/middleware.ts +10 -1
  34. package/src/pages/[...slug].astro +6 -0
  35. package/src/pages/admin/calendar/integrations.astro +2 -219
  36. package/src/pages/admin/integrations.astro +237 -0
  37. package/src/pages/admin/live.astro +12 -0
  38. package/src/pages/api/calendar/docs.ts +4 -2
  39. package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
  40. package/src/pages/api/live/settings.ts +45 -0
  41. package/src/pages/api/live/status.ts +7 -0
  42. package/src/pages/api/live/thumb.ts +48 -0
  43. package/src/pages/api/webhooks/docs.ts +61 -0
  44. package/src/pages/index.astro +6 -0
  45. package/src/pages/links.astro +6 -0
  46. package/src/styles/live-admin.css +126 -0
  47. package/src/styles/live.css +199 -0
  48. package/src/theme-sdk/contract.mjs +1 -0
  49. package/src/theme-sdk/tokens.mjs +3 -0
  50. package/src/themes/editorial/theme.css +5 -0
  51. package/src/themes/editorial/theme.json +2 -0
  52. package/src/themes/field/theme.json +1 -0
  53. package/src/themes/night/theme.json +1 -0
@@ -0,0 +1,910 @@
1
+ import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from 'react';
2
+ import type { LiveAdminState, LiveConfig, Provider, PublicConnection } from '../lib/live/types';
3
+ import '../styles/live-admin.css';
4
+
5
+ // Live video admin: settings (automatic / manual / off) plus one card per
6
+ // provider where the owner connects their own developer app.
7
+ //
8
+ // Secrets are write-only: the server never returns them, inputs start blank,
9
+ // nothing touches browser storage, and a saved secret is cleared from state.
10
+ // Status reloads keep unsaved form edits; only a save/disconnect resets a form.
11
+
12
+ type Mode = LiveConfig['mode'];
13
+ type GoogleStatus = PublicConnection['googleStatus'];
14
+ type Msg = { ok: string; err: string };
15
+ type Tone = 'live' | 'ok' | 'warn' | 'bad' | 'idle';
16
+
17
+ interface ConnForm { clientId: string; clientSecret: string; channelInput: string; googleStatus: GoogleStatus; dirty: boolean }
18
+ interface ConfigForm {
19
+ mode: Mode; primary: Provider; fallbackUrl: string; showUpcoming: boolean;
20
+ watchUrl: string; title: string; hours: string; display: Display; dirty: boolean;
21
+ }
22
+ type Display = LiveConfig['display'];
23
+
24
+ const NO_MSG: Msg = { ok: '', err: '' };
25
+ const PROVIDERS: Provider[] = ['twitch', 'youtube'];
26
+ const NAME: Record<string, string> = { twitch: 'Twitch', youtube: 'YouTube', facebook: 'Facebook', other: 'another site' };
27
+ const HOURS = ['1', '2', '4', '8', '12'];
28
+ const HOUR = 3_600_000;
29
+ const ENDPOINT = '/api/live/settings';
30
+
31
+ const STYLES: { id: Display['style']; name: string; desc: string }[] = [
32
+ { id: 'poster', name: 'Poster', desc: 'Big thumbnail with a live tag, title below.' },
33
+ { id: 'split', name: 'Split', desc: 'Thumbnail beside the title. Good for homepages.' },
34
+ { id: 'row', name: 'Compact row', desc: 'Small thumbnail in one line.' },
35
+ ];
36
+
37
+ const SIGNALS: { id: 'bar' | 'header' | 'dock'; name: string; desc: string }[] = [
38
+ { id: 'bar', name: 'Live bar at the top', desc: 'Temporarily replaces your announcement bar.' },
39
+ { id: 'header', name: '“Live” marker in the header', desc: 'Jumps to your live video, or opens the stream.' },
40
+ { id: 'dock', name: 'Small floating card in the corner', desc: 'Hidden on pages that already show the live video.' },
41
+ ];
42
+
43
+ const MODES: { id: Mode; name: string; desc: string }[] = [
44
+ { id: 'automatic', name: 'Automatic', desc: 'Show your broadcast when a connected channel goes live.' },
45
+ { id: 'manual', name: 'Manual', desc: 'Show a link you choose, for a set time.' },
46
+ { id: 'off', name: 'Off', desc: 'Hide live video everywhere on your site.' },
47
+ ];
48
+
49
+ async function api<T>(body?: Record<string, unknown>): Promise<T> {
50
+ const res = await fetch(ENDPOINT, {
51
+ method: body ? 'POST' : 'GET',
52
+ credentials: 'same-origin',
53
+ signal: AbortSignal.timeout(75_000),
54
+ headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
55
+ body: body ? JSON.stringify(body) : undefined,
56
+ });
57
+ const data = await res.json().catch(() => null);
58
+ if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
59
+ if (!data) throw new Error('The server sent an empty response.');
60
+ return data as T;
61
+ }
62
+
63
+ const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
64
+ const isHttps = (v: string) => { try { return new URL(v).protocol === 'https:'; } catch { return false; } };
65
+ const conn = (s: LiveAdminState | null, p: Provider) => s?.connections.find((c) => c.provider === p);
66
+
67
+ const toConnForm = (c?: PublicConnection): ConnForm => ({
68
+ clientId: c?.clientId ?? '', clientSecret: '', channelInput: c?.channelInput ?? '',
69
+ googleStatus: c?.googleStatus ?? 'testing', dirty: false,
70
+ });
71
+
72
+ const toConfigForm = (c: LiveConfig, now: number): ConfigForm => ({
73
+ mode: c.mode,
74
+ primary: c.priority[0] ?? 'youtube',
75
+ fallbackUrl: c.fallbackUrl,
76
+ showUpcoming: c.showUpcoming,
77
+ watchUrl: c.manual.watchUrl,
78
+ title: c.manual.title,
79
+ hours: c.mode === 'manual' && c.manual.until > now ? 'keep' : '4',
80
+ display: c.display,
81
+ dirty: false,
82
+ });
83
+
84
+ function rel(ts: number, now: number) {
85
+ const d = Math.round((ts - now) / 1000);
86
+ const a = Math.abs(d);
87
+ if (a < 45) return d <= 0 ? 'just now' : 'in under a minute';
88
+ const f = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
89
+ if (a < 3600) return f.format(Math.round(d / 60), 'minute');
90
+ if (a < 86400) return f.format(Math.round(d / 3600), 'hour');
91
+ return f.format(Math.round(d / 86400), 'day');
92
+ }
93
+ const fullTime = (ts: number) => new Date(ts).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
94
+
95
+ function When({ ts, now, empty = 'Never' }: { ts: number; now: number; empty?: string }) {
96
+ if (!ts) return <>{empty}</>;
97
+ return <time dateTime={new Date(ts).toISOString()} title={fullTime(ts)}>{rel(ts, now)}</time>;
98
+ }
99
+
100
+ function health(c: PublicConnection | undefined, s: LiveAdminState): { tone: Tone; label: string } {
101
+ if (!c) return { tone: 'idle', label: 'Not connected' };
102
+ if (c.provider === 'youtube' && !c.authorized) return { tone: 'warn', label: 'Needs authorization' };
103
+ if (c.error) return { tone: 'bad', label: 'Needs attention' };
104
+ if (!c.testedAt) return { tone: 'warn', label: 'Not tested yet' };
105
+ if (!c.enabled) return { tone: 'idle', label: 'Tested, turned off' };
106
+ if (!s.scheduler.healthy || c.broadcastStatus === 'unknown') return { tone: 'warn', label: 'Awaiting a fresh check' };
107
+ if (c.broadcastStatus === 'upcoming') return { tone: 'ok', label: 'Upcoming broadcast' };
108
+ if (c.broadcastStatus === 'live') return { tone: 'live', label: 'Live now' };
109
+ return { tone: 'ok', label: 'Connected, offline' };
110
+ }
111
+
112
+ function Status({ tone, label }: { tone: Tone; label: string }) {
113
+ return <span className={`live-admin-status is-${tone}`}><i aria-hidden="true" />{label}</span>;
114
+ }
115
+
116
+ export default function LiveManager() {
117
+ const uid = useId();
118
+ const id = (s: string) => `${uid}-${s}`;
119
+ const [data, setData] = useState<LiveAdminState | null>(null);
120
+ const [loadError, setLoadError] = useState('');
121
+ const [cfg, setCfg] = useState<ConfigForm | null>(null);
122
+ const [forms, setForms] = useState<Record<Provider, ConnForm>>({ twitch: toConnForm(), youtube: toConnForm() });
123
+ const [channelPick, setChannelPick] = useState('');
124
+ const [tab, setTab] = useState<Provider>('twitch');
125
+ const [now, setNow] = useState(() => Date.now());
126
+
127
+ // one pending action at a time; the ref blocks double-clicks that land
128
+ // before React re-renders the disabled buttons
129
+ const [busy, setBusy] = useState<string | null>(null);
130
+ const busyRef = useRef<string | null>(null);
131
+ const [pageMsg, setPageMsg] = useState<Msg>(NO_MSG);
132
+ const [cfgMsg, setCfgMsg] = useState<Msg>(NO_MSG);
133
+ const [statusMsg, setStatusMsg] = useState<Msg>(NO_MSG);
134
+ const [connMsg, setConnMsgs] = useState<Record<Provider, Msg>>({ twitch: NO_MSG, youtube: NO_MSG });
135
+ const [copied, setCopied] = useState('');
136
+ const tabRefs = useRef<Record<Provider, HTMLButtonElement | null>>({ twitch: null, youtube: null });
137
+ const tabFromUrl = useRef(false);
138
+
139
+ const setConnMsg = (p: Provider, m: Msg) => setConnMsgs((x) => ({ ...x, [p]: m }));
140
+
141
+ function adopt(s: LiveAdminState, reset: { config?: boolean; provider?: Provider; channel?: boolean } = {}) {
142
+ const t = Date.now();
143
+ setData(s);
144
+ setNow(t);
145
+ setCfg((prev) => (!prev || !prev.dirty || reset.config ? toConfigForm(s.config, t) : prev));
146
+ setForms((prev) => {
147
+ const next = { ...prev };
148
+ for (const p of PROVIDERS) if (!prev[p].dirty || reset.provider === p) next[p] = toConnForm(conn(s, p));
149
+ return next;
150
+ });
151
+ const yt = conn(s, 'youtube');
152
+ const saved = yt?.channelId || yt?.channels[0]?.id || '';
153
+ setChannelPick((prev) => (reset.channel || !prev || !yt?.channels.some((c) => c.id === prev) ? saved : prev));
154
+ }
155
+
156
+ async function load() {
157
+ setLoadError('');
158
+ try {
159
+ const s = await api<LiveAdminState>();
160
+ if (!data && !tabFromUrl.current) setTab(s.config.priority[0] ?? 'twitch');
161
+ adopt(s);
162
+ } catch (e) {
163
+ setLoadError(errText(e));
164
+ }
165
+ }
166
+
167
+ useEffect(() => {
168
+ const q = new URLSearchParams(location.search);
169
+ const r = q.get('live');
170
+ if (r === 'connected') {
171
+ setPageMsg({ ok: 'YouTube is authorized. Confirm your channel below, then test the connection.', err: '' });
172
+ } else if (r === 'error') {
173
+ setPageMsg({ ok: '', err: 'YouTube authorization didn’t finish. Check that the redirect URI in Google matches exactly, then try Authorize again.' });
174
+ }
175
+ if (r) {
176
+ tabFromUrl.current = true;
177
+ setTab('youtube');
178
+ q.delete('live');
179
+ const qs = q.toString();
180
+ history.replaceState(history.state, '', location.pathname + (qs ? `?${qs}` : '') + location.hash);
181
+ }
182
+ void load();
183
+ const tick = setInterval(() => setNow(Date.now()), 30_000);
184
+ return () => clearInterval(tick);
185
+ }, []);
186
+
187
+ async function run(kind: string, fn: () => Promise<void>) {
188
+ if (busyRef.current) return;
189
+ busyRef.current = kind;
190
+ setBusy(kind);
191
+ try {
192
+ await fn();
193
+ } finally {
194
+ busyRef.current = null;
195
+ setBusy(null);
196
+ }
197
+ }
198
+
199
+ async function copy(text: string, what: string) {
200
+ try {
201
+ await navigator.clipboard.writeText(text);
202
+ setCopied(`${what} copied.`);
203
+ } catch {
204
+ setCopied('Copy failed. Select the text and copy it by hand.');
205
+ }
206
+ setTimeout(() => setCopied(''), 2500);
207
+ }
208
+
209
+ if (!data || !cfg) {
210
+ return (
211
+ <div className="live-admin">
212
+ {loadError ? (
213
+ <div className="flash error" role="alert">
214
+ Couldn’t load live video settings: {loadError}{' '}
215
+ <button type="button" className="btn sm" onClick={() => void load()}>Retry</button>
216
+ </div>
217
+ ) : (
218
+ <p className="hint" role="status">Loading live video settings…</p>
219
+ )}
220
+ </div>
221
+ );
222
+ }
223
+
224
+ const state = data;
225
+ const locked = busy !== null;
226
+ const enabledConns = state.connections.filter((c) => c.enabled && c.testedAt > 0);
227
+ const manualActive = state.config.mode === 'manual' && state.config.manual.until > now;
228
+ const twitchRedirect = `${location.origin}/admin/live`;
229
+
230
+ // ---- settings ----
231
+
232
+ const setCfgField = <K extends keyof ConfigForm>(k: K, v: ConfigForm[K]) => setCfg((x) => (x ? { ...x, [k]: v, dirty: true } : x));
233
+ const setDisplay = (patch: Partial<Display>) => setCfg((x) => (x ? { ...x, display: { ...x.display, ...patch }, dirty: true } : x));
234
+
235
+ function configBody(f: ConfigForm) {
236
+ const other: Provider = f.primary === 'youtube' ? 'twitch' : 'youtube';
237
+ const until = f.mode !== 'manual' ? 0 : f.hours === 'keep' ? state.config.manual.until : Date.now() + Number(f.hours) * HOUR;
238
+ return {
239
+ mode: f.mode,
240
+ priority: [f.primary, other],
241
+ fallbackUrl: f.fallbackUrl.trim(),
242
+ showUpcoming: f.showUpcoming,
243
+ manual: { watchUrl: f.watchUrl.trim(), title: f.title.trim(), until },
244
+ display: f.display,
245
+ };
246
+ }
247
+
248
+ async function saveConfig(e: FormEvent) {
249
+ e.preventDefault();
250
+ const f = cfg!;
251
+ setCfgMsg(NO_MSG);
252
+ if (f.fallbackUrl.trim() && !isHttps(f.fallbackUrl.trim())) return setCfgMsg({ ok: '', err: 'The offline link must start with https://' });
253
+ if (f.mode === 'manual') {
254
+ if (!f.watchUrl.trim()) return setCfgMsg({ ok: '', err: 'Paste the link to your live video.' });
255
+ if (!isHttps(f.watchUrl.trim())) return setCfgMsg({ ok: '', err: 'The live video link must start with https://' });
256
+ }
257
+ await run('config', async () => {
258
+ try {
259
+ const s = await api<LiveAdminState>({ action: 'saveConfig', config: configBody(f) });
260
+ adopt(s, { config: true });
261
+ setCfgMsg({
262
+ ok: s.config.mode === 'manual' ? `Saved. Your link shows until ${fullTime(s.config.manual.until)}.`
263
+ : s.config.mode === 'automatic' ? 'Saved. Your site will show broadcasts from your connected channels.'
264
+ : 'Saved. Live video is hidden on your site.',
265
+ err: '',
266
+ });
267
+ } catch (err) {
268
+ setCfgMsg({ ok: '', err: errText(err) });
269
+ }
270
+ });
271
+ }
272
+
273
+ async function endManual() {
274
+ setStatusMsg(NO_MSG);
275
+ const c = state.config;
276
+ await run('endManual', async () => {
277
+ try {
278
+ const s = await api<LiveAdminState>({
279
+ action: 'saveConfig',
280
+ config: {
281
+ mode: 'automatic', priority: c.priority, fallbackUrl: c.fallbackUrl, showUpcoming: c.showUpcoming,
282
+ manual: { watchUrl: c.manual.watchUrl, title: c.manual.title, until: 0 }, display: c.display,
283
+ },
284
+ });
285
+ adopt(s, { config: true });
286
+ setStatusMsg({ ok: 'Manual link ended. Back to automatic.', err: '' });
287
+ } catch (err) {
288
+ setStatusMsg({ ok: '', err: errText(err) });
289
+ }
290
+ });
291
+ }
292
+
293
+ async function refresh() {
294
+ setStatusMsg(NO_MSG);
295
+ await run('refresh', async () => {
296
+ try {
297
+ adopt(await api<LiveAdminState>());
298
+ setStatusMsg({ ok: 'Status updated.', err: '' });
299
+ } catch (err) {
300
+ setStatusMsg({ ok: '', err: errText(err) });
301
+ }
302
+ });
303
+ }
304
+
305
+ // ---- connections ----
306
+
307
+ const setForm = (p: Provider, patch: Partial<ConnForm>) => setForms((x) => ({ ...x, [p]: { ...x[p], ...patch, dirty: true } }));
308
+
309
+ async function saveConnection(p: Provider, e: FormEvent) {
310
+ e.preventDefault();
311
+ const f = forms[p];
312
+ const c = conn(state, p);
313
+ const clientId = f.clientId.trim();
314
+ const sameClient = !!c && c.clientId === clientId;
315
+ setConnMsg(p, NO_MSG);
316
+ if (!clientId) return setConnMsg(p, { ok: '', err: 'Enter the Client ID from your app.' });
317
+ if (!f.clientSecret.trim() && !(sameClient && c!.hasSecret)) {
318
+ return setConnMsg(p, { ok: '', err: c ? 'A new Client ID needs its Client Secret too.' : 'Enter the Client Secret from your app.' });
319
+ }
320
+ if (p === 'twitch' && !f.channelInput.trim()) return setConnMsg(p, { ok: '', err: 'Enter your Twitch channel link or username.' });
321
+ if (p === 'youtube' && c?.authorized && !sameClient && !confirm('Changing the Client ID removes the current YouTube authorization. Continue?')) return;
322
+ await run(`save-${p}`, async () => {
323
+ try {
324
+ const s = await api<LiveAdminState>({
325
+ action: 'saveConnection', provider: p, clientId, clientSecret: f.clientSecret.trim(),
326
+ channelInput: p === 'twitch' ? f.channelInput.trim() : '', googleStatus: f.googleStatus,
327
+ });
328
+ adopt(s, { provider: p, channel: p === 'youtube' });
329
+ const n = conn(s, p);
330
+ setConnMsg(p, {
331
+ ok: p === 'youtube' && !n?.authorized ? 'Saved. Next, authorize with Google.' : 'Saved. Test the connection to turn it on.',
332
+ err: '',
333
+ });
334
+ } catch (err) {
335
+ setConnMsg(p, { ok: '', err: errText(err) });
336
+ }
337
+ });
338
+ }
339
+
340
+ async function test(p: Provider) {
341
+ setConnMsg(p, NO_MSG);
342
+ await run(`test-${p}`, async () => {
343
+ try {
344
+ const s = await api<LiveAdminState>({ action: 'test', provider: p });
345
+ adopt(s, { channel: true });
346
+ const n = conn(s, p);
347
+ if (n?.error) setConnMsg(p, { ok: '', err: n.error });
348
+ else setConnMsg(p, {
349
+ ok: `Connection works${n?.channelName ? ` — found ${n.channelName}` : ''}.${n?.enabled ? '' : ' Turn it on when you’re ready.'}`,
350
+ err: '',
351
+ });
352
+ } catch (err) {
353
+ setConnMsg(p, { ok: '', err: errText(err) });
354
+ }
355
+ });
356
+ }
357
+
358
+ async function selectChannel() {
359
+ setConnMsg('youtube', NO_MSG);
360
+ await run('channel-youtube', async () => {
361
+ try {
362
+ adopt(await api<LiveAdminState>({ action: 'selectChannel', provider: 'youtube', channelId: channelPick }), { channel: true });
363
+ setConnMsg('youtube', { ok: 'Channel saved. Test the connection again to turn it on.', err: '' });
364
+ } catch (err) {
365
+ setConnMsg('youtube', { ok: '', err: errText(err) });
366
+ }
367
+ });
368
+ }
369
+
370
+ async function setEnabled(p: Provider, enabled: boolean) {
371
+ const c = conn(state, p);
372
+ setConnMsg(p, NO_MSG);
373
+ if (enabled && !c?.testedAt) return setConnMsg(p, { ok: '', err: 'Test the connection first. It can only be turned on after a successful test.' });
374
+ await run(`enable-${p}`, async () => {
375
+ try {
376
+ const s = await api<LiveAdminState>({ action: 'enable', provider: p, enabled });
377
+ adopt(s);
378
+ setConnMsg(p, {
379
+ ok: enabled
380
+ ? `Turned on. ACMS checks every minute or two.${s.config.mode === 'automatic' ? '' : ' Set live video to Automatic above to show it on your site.'}`
381
+ : 'Turned off. ACMS stopped checking this channel.',
382
+ err: '',
383
+ });
384
+ } catch (err) {
385
+ setConnMsg(p, { ok: '', err: errText(err) });
386
+ }
387
+ });
388
+ }
389
+
390
+ async function authorize() {
391
+ setConnMsg('youtube', NO_MSG);
392
+ if (forms.youtube.dirty) return setConnMsg('youtube', { ok: '', err: 'Save your changes before authorizing.' });
393
+ await run('auth-youtube', async () => {
394
+ try {
395
+ const r = await api<{ authorizationUrl: string }>({ action: 'authorize', provider: 'youtube' });
396
+ if (!r.authorizationUrl || !isHttps(r.authorizationUrl)) throw new Error('The server didn’t return a valid Google sign-in link.');
397
+ location.assign(r.authorizationUrl);
398
+ } catch (err) {
399
+ setConnMsg('youtube', { ok: '', err: errText(err) });
400
+ }
401
+ });
402
+ }
403
+
404
+ async function disconnect(p: Provider) {
405
+ if (!confirm(`Disconnect ${NAME[p]}? ACMS deletes the saved app details and stops checking this channel.`)) return;
406
+ setConnMsg(p, NO_MSG);
407
+ await run(`disconnect-${p}`, async () => {
408
+ try {
409
+ adopt(await api<LiveAdminState>({ action: 'disconnect', provider: p }), { provider: p, channel: p === 'youtube' });
410
+ setConnMsg(p, { ok: `${NAME[p]} disconnected.`, err: '' });
411
+ } catch (err) {
412
+ setConnMsg(p, { ok: '', err: errText(err) });
413
+ }
414
+ });
415
+ }
416
+
417
+ function onTabKey(e: KeyboardEvent<HTMLDivElement>) {
418
+ const i = PROVIDERS.indexOf(tab);
419
+ const next = e.key === 'ArrowRight' ? PROVIDERS[(i + 1) % PROVIDERS.length]
420
+ : e.key === 'ArrowLeft' ? PROVIDERS[(i - 1 + PROVIDERS.length) % PROVIDERS.length]
421
+ : e.key === 'Home' ? PROVIDERS[0] : e.key === 'End' ? PROVIDERS[PROVIDERS.length - 1] : null;
422
+ if (!next) return;
423
+ e.preventDefault();
424
+ setTab(next);
425
+ tabRefs.current[next]?.focus();
426
+ }
427
+
428
+ // ---- render pieces ----
429
+
430
+ const snap = state.snapshot;
431
+ const snapHref = snap?.watchUrl && isHttps(snap.watchUrl) ? snap.watchUrl : '';
432
+
433
+ function rightNow() {
434
+ const c = state.config;
435
+ if (c.mode === 'off') return <p>Live video is <b>off</b>. Nothing live-related shows on your site.</p>;
436
+ if (c.mode === 'manual' && manualActive) {
437
+ return <p>Showing your manual link until <b>{fullTime(c.manual.until)}</b>.</p>;
438
+ }
439
+ if (snap && snap.status === 'live') {
440
+ return <p><Status tone="live" label={`Live on ${NAME[snap.provider] ?? snap.provider}`} /> {snapHref ? <a href={snapHref} target="_blank" rel="noopener noreferrer">{snap.title || 'Watch'}</a> : snap.title}</p>;
441
+ }
442
+ if (snap && snap.status === 'upcoming' && c.showUpcoming) {
443
+ return <p>Next up on {NAME[snap.provider] ?? snap.provider}: {snapHref ? <a href={snapHref} target="_blank" rel="noopener noreferrer">{snap.title || 'Scheduled broadcast'}</a> : snap.title}{snap.startedAt ? <> · <When ts={snap.startedAt} now={now} /></> : null}</p>;
444
+ }
445
+ if (!enabledConns.length) return <p>No channel is turned on yet, so there’s nothing to watch for. Connect one below.</p>;
446
+ return <p>Not live right now. Your site updates within a minute or two of your broadcast starting.</p>;
447
+ }
448
+
449
+ function providerPanel(p: Provider) {
450
+ const c = conn(state, p);
451
+ const f = forms[p];
452
+ const h = health(c, state);
453
+ const m = connMsg[p];
454
+ const yt = p === 'youtube';
455
+ const sameClient = !!c && c.clientId === f.clientId.trim();
456
+ const needsAuth = yt && !!c && !c.authorized;
457
+ const canTest = !!c && !f.dirty && !needsAuth;
458
+ const channelChanged = yt && !!c && !!channelPick && channelPick !== c.channelId;
459
+
460
+ return (
461
+ <div
462
+ role="tabpanel"
463
+ id={id(`panel-${p}`)}
464
+ aria-labelledby={id(`tab-${p}`)}
465
+ hidden={tab !== p}
466
+ className="live-admin-panel"
467
+ >
468
+ <div className="live-admin-health">
469
+ <Status tone={h.tone} label={h.label} />
470
+ {c && (
471
+ <dl className="live-admin-dl">
472
+ <div><dt>Channel</dt><dd>{c.channelName || (yt ? 'Not chosen yet' : c.channelInput || 'Not found yet')}</dd></div>
473
+ <div><dt>Last successful check</dt><dd><When ts={c.checkedAt} now={now} /></dd></div>
474
+ <div><dt>Next check</dt><dd>{c.enabled ? <When ts={c.nextAt} now={now} empty="Soon" /> : 'Turned off'}</dd></div>
475
+ {c.failures > 0 && <div><dt>Failed checks in a row</dt><dd>{c.failures}</dd></div>}
476
+ {c.testedAt > 0 && <div><dt>Last tested</dt><dd><When ts={c.testedAt} now={now} /></dd></div>}
477
+ </dl>
478
+ )}
479
+ {c?.error && <p className="flash error live-admin-flash">{c.error}</p>}
480
+ </div>
481
+
482
+ {yt ? youtubeGuide() : twitchGuide()}
483
+
484
+ <form className="live-admin-form" onSubmit={(e) => void saveConnection(p, e)} aria-labelledby={id(`form-h-${p}`)}>
485
+ <h3 id={id(`form-h-${p}`)}>Your {NAME[p]} app</h3>
486
+ <fieldset disabled={locked || !state.encryptionReady}>
487
+ <div className="field-row">
488
+ <label className="field">
489
+ <span>Client ID</span>
490
+ <input
491
+ type="text" className="mono" value={f.clientId} autoComplete="off" spellCheck={false}
492
+ onChange={(e) => setForm(p, { clientId: e.target.value })}
493
+ />
494
+ </label>
495
+ <label className="field">
496
+ <span>Client Secret</span>
497
+ <input
498
+ type="password" className="mono" value={f.clientSecret} autoComplete="new-password" spellCheck={false}
499
+ data-1p-ignore data-lpignore="true"
500
+ placeholder={c?.hasSecret && sameClient ? 'Saved — type to replace' : ''}
501
+ onChange={(e) => setForm(p, { clientSecret: e.target.value })}
502
+ aria-describedby={id(`secret-hint-${p}`)}
503
+ />
504
+ <small className="hint" id={id(`secret-hint-${p}`)}>
505
+ {c?.hasSecret && !sameClient ? 'New Client ID: enter its secret too.' : 'Stored encrypted on the server. Never shown again or sent to visitors.'}
506
+ </small>
507
+ </label>
508
+ </div>
509
+ {!yt && (
510
+ <label className="field">
511
+ <span>Channel link or username</span>
512
+ <input
513
+ type="text" value={f.channelInput} autoComplete="off" spellCheck={false}
514
+ placeholder="https://twitch.tv/yourname"
515
+ onChange={(e) => setForm(p, { channelInput: e.target.value })}
516
+ />
517
+ </label>
518
+ )}
519
+ {yt && (
520
+ <label className="field">
521
+ <span>Google consent screen status</span>
522
+ <select
523
+ value={f.googleStatus}
524
+ onChange={(e) => setForm(p, { googleStatus: e.target.value as GoogleStatus })}
525
+ aria-describedby={id('gs-hint')}
526
+ >
527
+ <option value="testing">Testing</option>
528
+ <option value="production">In production</option>
529
+ </select>
530
+ <small className="hint" id={id('gs-hint')}>
531
+ Match what Google Cloud shows. In Testing, Google ends the sign-in after 7 days, so you’ll need to authorize again each week.
532
+ In production it lasts, but Google may ask you to verify the app first — requirements vary.
533
+ </small>
534
+ </label>
535
+ )}
536
+ </fieldset>
537
+ <div className="live-admin-actions">
538
+ <button type="submit" className="btn primary" disabled={locked || !f.dirty || !state.encryptionReady}>
539
+ {busy === `save-${p}` ? 'Saving…' : c ? 'Save changes' : 'Save'}
540
+ </button>
541
+ {f.dirty && c && (
542
+ <button type="button" className="btn ghost" disabled={locked} onClick={() => setForms((x) => ({ ...x, [p]: toConnForm(c) }))}>
543
+ Discard changes
544
+ </button>
545
+ )}
546
+ <span className="hint">Saving turns the connection off until you test it again.</span>
547
+ </div>
548
+ </form>
549
+
550
+ {c && (
551
+ <div className="live-admin-steps-row">
552
+ {yt && (
553
+ <div className="live-admin-step">
554
+ <h3>Authorize</h3>
555
+ <p className="hint">
556
+ {c.authorized ? 'Google access is in place. Authorize again if checks start failing.' : 'Sign in with the Google account that owns your channel. ACMS asks for read-only access.'}
557
+ </p>
558
+ <button type="button" className={`btn ${c.authorized ? '' : 'primary'}`} disabled={locked || !c.hasSecret} onClick={() => void authorize()}>
559
+ {busy === 'auth-youtube' ? 'Opening Google…' : c.authorized ? 'Authorize again' : 'Authorize with Google'}
560
+ </button>
561
+ </div>
562
+ )}
563
+
564
+ {yt && c.authorized && c.channels.length > 0 && (
565
+ <div className="live-admin-step">
566
+ <label className="field">
567
+ <span>Channel</span>
568
+ <select value={channelPick} disabled={locked} onChange={(e) => setChannelPick(e.target.value)}>
569
+ {c.channels.map((ch) => <option key={ch.id} value={ch.id}>{ch.name}</option>)}
570
+ </select>
571
+ </label>
572
+ {channelChanged && (
573
+ <button type="button" className="btn" disabled={locked} onClick={() => void selectChannel()}>
574
+ {busy === 'channel-youtube' ? 'Saving…' : 'Use this channel'}
575
+ </button>
576
+ )}
577
+ </div>
578
+ )}
579
+
580
+ <div className="live-admin-step">
581
+ <h3>Test and turn on</h3>
582
+ <p className="hint">
583
+ {f.dirty ? 'Save your changes first.' : needsAuth ? 'Authorize with Google first.' : 'Testing checks your app and channel. It doesn’t turn anything on.'}
584
+ </p>
585
+ <div className="live-admin-actions">
586
+ <button type="button" className="btn" disabled={locked || !canTest} onClick={() => void test(p)}>
587
+ {busy === `test-${p}` ? 'Testing…' : 'Test connection'}
588
+ </button>
589
+ {c.enabled ? (
590
+ <button type="button" className="btn" disabled={locked} onClick={() => void setEnabled(p, false)}>
591
+ {busy === `enable-${p}` ? 'Turning off…' : 'Turn off'}
592
+ </button>
593
+ ) : (
594
+ <button
595
+ type="button" className="btn primary" disabled={locked || !c.testedAt || needsAuth}
596
+ onClick={() => void setEnabled(p, true)} aria-describedby={c.testedAt ? undefined : id(`on-hint-${p}`)}
597
+ >
598
+ {busy === `enable-${p}` ? 'Turning on…' : 'Turn on'}
599
+ </button>
600
+ )}
601
+ </div>
602
+ {!c.testedAt && <p className="hint" id={id(`on-hint-${p}`)}>Available after a successful test.</p>}
603
+ </div>
604
+ </div>
605
+ )}
606
+
607
+ <div className="live-admin-msgs" aria-live="polite">
608
+ {m.ok && <p className="flash live-admin-flash">{m.ok}</p>}
609
+ </div>
610
+ <div className="live-admin-msgs" role="alert">
611
+ {m.err && <p className="flash error live-admin-flash">{m.err}</p>}
612
+ </div>
613
+
614
+ {c && (
615
+ <div className="live-admin-danger">
616
+ <button type="button" className="btn ghost danger" disabled={locked} onClick={() => void disconnect(p)}>
617
+ {busy === `disconnect-${p}` ? 'Disconnecting…' : `Disconnect ${NAME[p]}`}
618
+ </button>
619
+ </div>
620
+ )}
621
+ </div>
622
+ );
623
+ }
624
+
625
+ function copyRow(value: string, label: string, what: string) {
626
+ return (
627
+ <div className="live-admin-copy">
628
+ <input type="text" className="mono" readOnly value={value} aria-label={label} onFocus={(e) => e.target.select()} />
629
+ <button type="button" className="btn sm" onClick={() => void copy(value, what)}>Copy</button>
630
+ </div>
631
+ );
632
+ }
633
+
634
+ function twitchGuide() {
635
+ return (
636
+ <details className="live-admin-guide">
637
+ <summary>How to set up Twitch</summary>
638
+ <ol>
639
+ <li>
640
+ Open the <a href="https://dev.twitch.tv/console/apps" target="_blank" rel="noopener noreferrer">Twitch Developer Console</a> and
641
+ choose <b>Register Your Application</b>. Twitch may ask you to turn on two-factor authentication first.
642
+ </li>
643
+ <li>
644
+ Give it any name. For <b>OAuth Redirect URL</b>, Twitch requires one, so paste this address (ACMS doesn’t actually use it):
645
+ {copyRow(twitchRedirect, 'Twitch redirect URL', 'Redirect URL')}
646
+ </li>
647
+ <li>Choose the category that best describes your site, set <b>Client Type</b> to <b>Confidential</b>, and create the app.</li>
648
+ <li>Open the app with <b>Manage</b>. Copy the <b>Client ID</b>, then choose <b>New Secret</b> and copy the secret.</li>
649
+ <li>Paste both below with your channel link or username, save, then test the connection.</li>
650
+ <li>When the test passes, turn it on.</li>
651
+ </ol>
652
+ <p className="hint">
653
+ ACMS uses an app-only token. You never sign in as the broadcaster here, and ACMS never needs your stream key.{' '}
654
+ <a href="https://dev.twitch.tv/docs/authentication/register-app/" target="_blank" rel="noopener noreferrer">Twitch’s guide ↗</a>
655
+ </p>
656
+ </details>
657
+ );
658
+ }
659
+
660
+ function youtubeGuide() {
661
+ return (
662
+ <details className="live-admin-guide">
663
+ <summary>How to set up YouTube</summary>
664
+ <ol>
665
+ <li>In the Google Cloud console, create a project (or pick one you already have).</li>
666
+ <li>
667
+ Enable the <a href="https://console.cloud.google.com/apis/library/youtube.googleapis.com" target="_blank" rel="noopener noreferrer">YouTube Data API v3</a> for that project.
668
+ </li>
669
+ <li>Set up the <b>OAuth consent screen</b>: choose External, fill in the app name and your email, and add your own Google account as a test user.</li>
670
+ <li>
671
+ Go to <b>Credentials → Create credentials → OAuth client ID</b> and choose <b>Web application</b>.
672
+ Under <b>Authorized redirect URIs</b>, paste this exactly:
673
+ {copyRow(state.callbackUrl, 'YouTube redirect URI', 'Redirect URI')}
674
+ </li>
675
+ <li>Copy the <b>Client ID</b> and <b>Client Secret</b> below, set the consent screen status, and save.</li>
676
+ <li>Choose <b>Authorize with Google</b> and sign in with the account that owns your channel. ACMS only asks for read-only access.</li>
677
+ <li>Confirm your channel, test the connection, then turn it on.</li>
678
+ </ol>
679
+ <p className="hint">
680
+ ACMS checks your broadcasts using Google’s{' '}
681
+ <a href="https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/list" target="_blank" rel="noopener noreferrer">live broadcasts list ↗</a>.
682
+ One check a minute uses about 1,440 quota units a day, more with video checks, upcoming broadcasts or many simultaneous streams. Your daily limit depends on your Google Cloud project.
683
+ </p>
684
+ </details>
685
+ );
686
+ }
687
+
688
+ return (
689
+ <div className="live-admin" aria-busy={locked}>
690
+ <header className="page-head live-admin-head">
691
+ <div>
692
+ <h1>Live video</h1>
693
+ <p className="sub">Connect your own developer app. ACMS finds your live broadcasts and updates your site automatically.</p>
694
+ </div>
695
+ </header>
696
+
697
+ <p className="live-admin-lede">
698
+ Start your broadcast on Twitch or YouTube as usual. ACMS only watches for it — it never starts, stops, or changes a stream.
699
+ </p>
700
+
701
+ <div className="live-admin-msgs" aria-live="polite">{pageMsg.ok && <p className="flash">{pageMsg.ok}</p>}</div>
702
+ <div className="live-admin-msgs" role="alert">{pageMsg.err && <p className="flash error">{pageMsg.err}</p>}</div>
703
+
704
+ {!state.encryptionReady && (
705
+ <div className="notice warn" role="note">
706
+ ACMS can’t store app credentials safely yet. Ask your host to set a strong <code>SESSION_SECRET</code> (or <code>LIVE_ENCRYPTION_KEY</code>), then reload this page.
707
+ </div>
708
+ )}
709
+
710
+ {/* ---- status ---- */}
711
+ <section className="live-admin-card" aria-labelledby={id('now-h')}>
712
+ <div className="live-admin-card-head">
713
+ <h2 id={id('now-h')}>Right now</h2>
714
+ <button type="button" className="btn sm" disabled={locked} onClick={() => void refresh()}>
715
+ {busy === 'refresh' ? 'Checking…' : 'Refresh status'}
716
+ </button>
717
+ </div>
718
+ <div className="live-admin-now">{rightNow()}</div>
719
+ {manualActive && (
720
+ <button type="button" className="btn" disabled={locked} onClick={() => void endManual()}>
721
+ {busy === 'endManual' ? 'Ending…' : 'End manual link now'}
722
+ </button>
723
+ )}
724
+ <ul className="live-admin-conns" aria-label="Connections">
725
+ {PROVIDERS.map((p) => {
726
+ const h = health(conn(state, p), state);
727
+ return (
728
+ <li key={p}>
729
+ <b>{NAME[p]}</b>
730
+ <Status tone={h.tone} label={h.label} />
731
+ </li>
732
+ );
733
+ })}
734
+ </ul>
735
+ <p className="hint live-admin-sched">
736
+ {state.scheduler.lastAt ? <>Background checks last ran <When ts={state.scheduler.lastAt} now={now} />. </> : <>Background checks haven’t run yet. </>}
737
+ Updates land within a minute or two, not instantly.
738
+ </p>
739
+ {!state.scheduler.healthy && (state.config.mode === 'automatic' || enabledConns.length > 0) && (
740
+ <div className="notice warn live-admin-flash" role="note">
741
+ Background checks aren’t running on schedule, so automatic updates are paused. Your host needs to run the ACMS scheduled task every minute.
742
+ </div>
743
+ )}
744
+ <div className="live-admin-msgs" aria-live="polite">{statusMsg.ok && <p className="flash live-admin-flash">{statusMsg.ok}</p>}</div>
745
+ <div className="live-admin-msgs" role="alert">{statusMsg.err && <p className="flash error live-admin-flash">{statusMsg.err}</p>}</div>
746
+ </section>
747
+
748
+ {/* ---- settings ---- */}
749
+ <section className="live-admin-card" aria-labelledby={id('set-h')}>
750
+ <div className="live-admin-card-head">
751
+ <h2 id={id('set-h')}>What your site shows</h2>
752
+ </div>
753
+ <form onSubmit={(e) => void saveConfig(e)}>
754
+ <fieldset className="live-admin-modes" disabled={locked}>
755
+ <legend>Live video</legend>
756
+ {MODES.map((m) => (
757
+ <label key={m.id} className={`live-admin-mode ${cfg.mode === m.id ? 'on' : ''}`}>
758
+ <input type="radio" name={id('mode')} value={m.id} checked={cfg.mode === m.id} onChange={() => setCfgField('mode', m.id)} />
759
+ <span><b>{m.name}</b><small>{m.desc}</small></span>
760
+ </label>
761
+ ))}
762
+ </fieldset>
763
+
764
+ {cfg.mode === 'automatic' && (
765
+ <fieldset className="live-admin-fields" disabled={locked}>
766
+ {!enabledConns.length && (
767
+ <p className="notice warn live-admin-flash" role="note">No channel is turned on yet. Connect and test one below, then turn it on.</p>
768
+ )}
769
+ <div className="field-row">
770
+ <label className="field">
771
+ <span>If both channels are live, show</span>
772
+ <select value={cfg.primary} onChange={(e) => setCfgField('primary', e.target.value as Provider)}>
773
+ <option value="youtube">YouTube</option>
774
+ <option value="twitch">Twitch</option>
775
+ </select>
776
+ </label>
777
+ <label className="field">
778
+ <span>Link when you’re offline (optional)</span>
779
+ <input
780
+ type="url" inputMode="url" value={cfg.fallbackUrl} placeholder="https://"
781
+ onChange={(e) => setCfgField('fallbackUrl', e.target.value)} aria-describedby={id('fb-hint')}
782
+ />
783
+ <small className="hint" id={id('fb-hint')}>For example your channel page. Leave empty to show nothing.</small>
784
+ </label>
785
+ </div>
786
+ <label className="check-row">
787
+ <input type="checkbox" checked={cfg.showUpcoming} onChange={(e) => setCfgField('showUpcoming', e.target.checked)} />
788
+ <span>Show your next scheduled broadcast while offline <span className="hint">(uses more YouTube quota)</span></span>
789
+ </label>
790
+ </fieldset>
791
+ )}
792
+
793
+ {cfg.mode === 'manual' && (
794
+ <fieldset className="live-admin-fields" disabled={locked}>
795
+ <label className="field">
796
+ <span>Live video link</span>
797
+ <input
798
+ type="url" inputMode="url" value={cfg.watchUrl} placeholder="https://" required
799
+ onChange={(e) => setCfgField('watchUrl', e.target.value)} aria-describedby={id('mu-hint')}
800
+ />
801
+ <small className="hint" id={id('mu-hint')}>Any https link works, including a Facebook live video.</small>
802
+ </label>
803
+ <div className="field-row">
804
+ <label className="field">
805
+ <span>Title</span>
806
+ <input type="text" value={cfg.title} maxLength={200} onChange={(e) => setCfgField('title', e.target.value)} />
807
+ </label>
808
+ <label className="field">
809
+ <span>Show it for</span>
810
+ <select value={cfg.hours} onChange={(e) => setCfgField('hours', e.target.value)}>
811
+ {state.config.mode === 'manual' && state.config.manual.until > now && (
812
+ <option value="keep">Until {fullTime(state.config.manual.until)}</option>
813
+ )}
814
+ {HOURS.map((h) => <option key={h} value={h}>{h} hour{h === '1' ? '' : 's'} from now</option>)}
815
+ </select>
816
+ </label>
817
+ </div>
818
+ </fieldset>
819
+ )}
820
+
821
+ {cfg.mode !== 'off' && (
822
+ <fieldset className="live-admin-fields live-admin-look" disabled={locked}>
823
+ <legend>How it looks</legend>
824
+ <div className="live-admin-styles" role="radiogroup" aria-label="Card style">
825
+ {STYLES.map((st) => (
826
+ <label key={st.id} className={`live-admin-mode live-admin-style ${cfg.display.style === st.id ? 'on' : ''}`}>
827
+ <input type="radio" name={id('style')} value={st.id} checked={cfg.display.style === st.id} onChange={() => setDisplay({ style: st.id })} />
828
+ <span>
829
+ <i className={`live-admin-glyph is-${st.id}`} aria-hidden="true"><em /><s /></i>
830
+ <b>{st.name}</b><small>{st.desc}</small>
831
+ </span>
832
+ </label>
833
+ ))}
834
+ </div>
835
+ <small className="hint live-admin-look-hint">Applies to Live video sections set to “card”. Sections set to “player” always use the split layout with a play button.</small>
836
+ <label className="check-row">
837
+ <input type="checkbox" checked={cfg.display.thumbnail} onChange={(e) => setDisplay({ thumbnail: e.target.checked })} />
838
+ <span>Show the stream thumbnail <span className="hint">(loaded through your site, so visitors don’t contact Twitch or YouTube until they press play)</span></span>
839
+ </label>
840
+ <label className="check-row">
841
+ <input type="checkbox" checked={cfg.display.viewers} onChange={(e) => setDisplay({ viewers: e.target.checked })} />
842
+ <span>Show how many people are watching</span>
843
+ </label>
844
+ <p className="live-admin-sub">While you’re live, also show on every page</p>
845
+ {SIGNALS.map((sg) => (
846
+ <label key={sg.id} className="check-row">
847
+ <input type="checkbox" checked={cfg.display[sg.id]} onChange={(e) => setDisplay({ [sg.id]: e.target.checked })} />
848
+ <span>{sg.name} <span className="hint">{sg.desc}</span></span>
849
+ </label>
850
+ ))}
851
+ </fieldset>
852
+ )}
853
+
854
+ <div className="live-admin-actions">
855
+ <button type="submit" className="btn primary" disabled={locked || !cfg.dirty}>
856
+ {busy === 'config' ? 'Saving…' : 'Save'}
857
+ </button>
858
+ {cfg.dirty && (
859
+ <button type="button" className="btn ghost" disabled={locked} onClick={() => setCfg(toConfigForm(state.config, Date.now()))}>
860
+ Discard changes
861
+ </button>
862
+ )}
863
+ </div>
864
+ <div className="live-admin-msgs" aria-live="polite">{cfgMsg.ok && <p className="flash live-admin-flash">{cfgMsg.ok}</p>}</div>
865
+ <div className="live-admin-msgs" role="alert">{cfgMsg.err && <p className="flash error live-admin-flash">{cfgMsg.err}</p>}</div>
866
+ </form>
867
+ <p className="hint live-admin-place">
868
+ Add a Live video section in <a href="/admin/design">Design → Pages</a>, or a Live video block on your <a href="/admin/links">Links page</a>.
869
+ Older widget layouts also have a Live video widget.
870
+ </p>
871
+ </section>
872
+
873
+ {/* ---- connections ---- */}
874
+ <section className="live-admin-card" aria-labelledby={id('con-h')}>
875
+ <div className="live-admin-card-head">
876
+ <h2 id={id('con-h')}>Connections</h2>
877
+ </div>
878
+ <div className="seg live-admin-tabs" role="tablist" aria-label="Providers" onKeyDown={onTabKey}>
879
+ {PROVIDERS.map((p) => {
880
+ const h = health(conn(state, p), state);
881
+ return (
882
+ <button
883
+ key={p}
884
+ ref={(el) => { tabRefs.current[p] = el; }}
885
+ type="button"
886
+ role="tab"
887
+ id={id(`tab-${p}`)}
888
+ aria-selected={tab === p}
889
+ aria-controls={id(`panel-${p}`)}
890
+ tabIndex={tab === p ? 0 : -1}
891
+ onClick={() => setTab(p)}
892
+ >
893
+ <i className={`live-admin-dot is-${h.tone}`} aria-hidden="true" />
894
+ {NAME[p]}
895
+ <span className="sr-only">, {h.label}</span>
896
+ </button>
897
+ );
898
+ })}
899
+ </div>
900
+ {PROVIDERS.map((p) => <div key={p}>{providerPanel(p)}</div>)}
901
+ <p className="hint live-admin-copied" aria-live="polite">{copied}</p>
902
+ </section>
903
+
904
+ <section className="live-admin-card live-admin-roadmap" aria-labelledby={id('fb-h')}>
905
+ <h2 id={id('fb-h')}>Facebook</h2>
906
+ <p>Manual links supported. Automatic Page integration will follow after permissions and access requirements are validated.</p>
907
+ </section>
908
+ </div>
909
+ );
910
+ }