@acms-subir/core 0.3.50 → 0.3.52
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 +24 -0
- package/cli/migrations.generated.mjs +16 -1
- package/docs/calendar.md +24 -10
- package/docs/theme-authoring.md +1 -1
- package/integration.mjs +5 -0
- package/package.json +1 -1
- package/section-library/upcoming-events/UpcomingEvents.astro +3 -1
- package/src/components/display/ScreensManager.tsx +481 -0
- package/src/layouts/Admin.astro +2 -0
- package/src/lib/backup/content.ts +10 -1
- package/src/lib/calendar/api.ts +11 -3
- package/src/lib/calendar/cards.ts +3 -3
- package/src/lib/display/board.ts +4 -3
- package/src/lib/display/screens.ts +111 -0
- package/src/lib/display/store.ts +113 -0
- package/src/lib/display/words.ts +16 -6
- package/src/lib/mobile/calendar.ts +3 -1
- package/src/lib/repo/migrations.ts +3 -0
- package/src/lib/repo/sql.ts +5 -0
- package/src/lib/repo/types.ts +5 -0
- package/src/lib/section-items.ts +24 -0
- package/src/lib/sections.ts +4 -15
- package/src/pages/admin/calendar/screens/index.astro +24 -0
- package/src/pages/admin/calendar/screens/preview.astro +36 -0
- package/src/pages/api/calendar/screens.ts +30 -0
- package/src/pages/display/s/[key].astro +48 -0
- package/src/styles/display.css +2 -1
- package/src/styles/screens-admin.css +53 -0
- package/src/theme-sdk/calendar.ts +3 -1
- package/src/theme-sdk/index.ts +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Saved screens as the TV and the admin see them: opening one by its key
|
|
2
|
+
// (rate-limited for wrong keys), and the admin state and actions behind
|
|
3
|
+
// Admin → Calendar → Screens (/api/calendar/screens).
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { ContentRepository } from '../repo/types';
|
|
6
|
+
import { ApiError } from '../api';
|
|
7
|
+
import { sha256Hex } from '../api-keys';
|
|
8
|
+
import { I18N, localeName } from '../i18n';
|
|
9
|
+
import { siteLang } from '../dates';
|
|
10
|
+
import { roomColor } from '../schema';
|
|
11
|
+
import { safeColor } from '../calendar/events';
|
|
12
|
+
import { canonicalDisplayQuery } from './settings';
|
|
13
|
+
import type { Screen, ScreenInput } from './store';
|
|
14
|
+
|
|
15
|
+
// Wrong keys, per client address: past this many in the window, further
|
|
16
|
+
// wrong keys from that address are refused without a lookup. A right key
|
|
17
|
+
// always opens its screen, so nobody can lock a church's TVs out by sending
|
|
18
|
+
// bad keys "from" its address (proxies can claim any address). Keys are
|
|
19
|
+
// 256-bit, so guessing gets nowhere either way.
|
|
20
|
+
export const MISS_LIMIT = 60;
|
|
21
|
+
export const MISS_WINDOW = 15 * 60_000;
|
|
22
|
+
const missKey = async (address: string) => `display:miss:${await sha256Hex(address || 'unknown')}`;
|
|
23
|
+
|
|
24
|
+
export type OpenedScreen = { status: 'ok'; screen: Screen } | { status: 'missing' } | { status: 'limited' };
|
|
25
|
+
|
|
26
|
+
/** The screen at /display/s/<key>, counting a wrong key against the caller's address. */
|
|
27
|
+
export async function openScreen(repo: ContentRepository, key: string, address: string): Promise<OpenedScreen> {
|
|
28
|
+
const screen = await repo.screens.byKey(key);
|
|
29
|
+
if (screen) return { status: 'ok', screen };
|
|
30
|
+
const limitKey = await missKey(address);
|
|
31
|
+
if (await repo.email.rateLimited(limitKey, MISS_LIMIT)) return { status: 'limited' };
|
|
32
|
+
await repo.email.rateHit(limitKey, MISS_WINDOW);
|
|
33
|
+
return { status: 'missing' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const screenPath = (s: Pick<Screen, 'key'>) => `/display/s/${s.key}`;
|
|
37
|
+
|
|
38
|
+
// ---- admin -----------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
const siteLanguages = (mainLang: string) => {
|
|
41
|
+
const codes = I18N.enabled ? I18N.locales : [mainLang];
|
|
42
|
+
return codes.map((code) => ({ code, label: localeName(code, true), main: I18N.enabled ? code === I18N.defaultLocale : true }));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What the Screens editor needs: the screens, and what a screen can pick
|
|
47
|
+
* from. `bookings`: the person may see booker names (Manage bookings), which
|
|
48
|
+
* private details show.
|
|
49
|
+
*/
|
|
50
|
+
export async function screensState(repo: ContentRepository, origin: string, opts: { bookings: boolean }) {
|
|
51
|
+
const [screens, site, calendars, resources, events] = await Promise.all([
|
|
52
|
+
repo.screens.list(), repo.getSettings(), repo.listCalendars(), repo.listResources(), repo.listPublishedEvents(),
|
|
53
|
+
]);
|
|
54
|
+
const tags = [...new Set(events.flatMap((e) => e.tags.map((t) => t.trim()).filter(Boolean)))].sort((a, b) => a.localeCompare(b));
|
|
55
|
+
return {
|
|
56
|
+
now: Date.now(),
|
|
57
|
+
origin,
|
|
58
|
+
canPrivate: opts.bookings,
|
|
59
|
+
weekStart: site.calendar.weekStart,
|
|
60
|
+
timezone: site.timezone || 'UTC',
|
|
61
|
+
languages: siteLanguages(siteLang(site)),
|
|
62
|
+
screens: screens.map((s) => ({ ...s, url: s.key ? origin + screenPath(s) : '' })),
|
|
63
|
+
calendars: calendars.map((c) => ({ slug: c.slug, name: c.name, color: safeColor(c.color), visibility: c.visibility, kind: c.kind })),
|
|
64
|
+
// public: named on public events, so quick links can show and filter by it
|
|
65
|
+
rooms: resources.map((r) => ({ slug: r.slug, name: r.name, kind: r.kind, color: roomColor(r), public: r.showOnEvents })),
|
|
66
|
+
tags,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ScreenFields = {
|
|
71
|
+
name: z.string().trim().min(1, 'Give the screen a name.').max(120),
|
|
72
|
+
params: z.string().max(4000),
|
|
73
|
+
locale: z.string().max(20).default(''),
|
|
74
|
+
privateDetails: z.boolean().default(false),
|
|
75
|
+
};
|
|
76
|
+
export const ScreenAction = z.discriminatedUnion('action', [
|
|
77
|
+
z.object({ action: z.literal('create'), ...ScreenFields }),
|
|
78
|
+
z.object({ action: z.literal('save'), id: z.string().min(1), ...ScreenFields }),
|
|
79
|
+
z.object({ action: z.literal('rotate'), id: z.string().min(1) }),
|
|
80
|
+
z.object({ action: z.literal('delete'), id: z.string().min(1) }),
|
|
81
|
+
z.object({ action: z.literal('reorder'), ids: z.array(z.string()).max(500) }),
|
|
82
|
+
]);
|
|
83
|
+
export type ScreenAction = z.infer<typeof ScreenAction>;
|
|
84
|
+
|
|
85
|
+
function screenInput(a: { name: string; params: string; locale: string; privateDetails: boolean }): ScreenInput {
|
|
86
|
+
// only a configured extra language; the main one is stored as ''
|
|
87
|
+
const locale = I18N.enabled && a.locale !== I18N.defaultLocale && I18N.locales.includes(a.locale) ? a.locale : '';
|
|
88
|
+
return { name: a.name, params: canonicalDisplayQuery(a.params.replace(/^\?/, '')), locale, privateDetails: a.privateDetails };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function screenAction(repo: ContentRepository, a: ScreenAction, opts: { bookings: boolean }): Promise<{ screen?: Screen; ok?: true }> {
|
|
92
|
+
// private details show booker names: only for people who may see bookings
|
|
93
|
+
if ((a.action === 'create' || a.action === 'save') && a.privateDetails && !opts.bookings) {
|
|
94
|
+
throw new ApiError(403, 'Showing private calendars and room bookings needs the “Manage bookings” permission.');
|
|
95
|
+
}
|
|
96
|
+
const found = (s: Screen | null) => {
|
|
97
|
+
if (!s) throw new ApiError(404, 'That screen was removed. Reload the page.');
|
|
98
|
+
return { screen: s };
|
|
99
|
+
};
|
|
100
|
+
switch (a.action) {
|
|
101
|
+
case 'create': return { screen: await repo.screens.create(screenInput(a)) };
|
|
102
|
+
case 'save': return found(await repo.screens.update(a.id, screenInput(a)));
|
|
103
|
+
case 'rotate': return found(await repo.screens.rotate(a.id));
|
|
104
|
+
case 'delete':
|
|
105
|
+
if (!(await repo.screens.delete(a.id))) throw new ApiError(404, 'That screen was removed. Reload the page.');
|
|
106
|
+
return { ok: true };
|
|
107
|
+
case 'reorder':
|
|
108
|
+
await repo.screens.reorder(a.ids);
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Saved TV screens (Admin → Calendar → Screens). Each one is a name, the
|
|
2
|
+
// display settings as their canonical query (lib/display/settings.ts, the
|
|
3
|
+
// same text a quick link carries), a language, the private-details switch and
|
|
4
|
+
// a long random key: the screen's whole address is /display/s/<key>. The key
|
|
5
|
+
// is kept as is, like a private calendar's feed token, so staff can copy the
|
|
6
|
+
// link again; rotating it stops the old one at once. last_seen_at is the last
|
|
7
|
+
// time the screen fetched its events. Shared by the D1 and libSQL adapters.
|
|
8
|
+
import type { SqlDriver, SqlStatement } from '../repo/sql';
|
|
9
|
+
|
|
10
|
+
export interface Screen {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
/** the display settings, as their canonical query ("view=rooms&hours=8-21") */
|
|
14
|
+
params: string;
|
|
15
|
+
/** the display's language ('' = the site's main language) */
|
|
16
|
+
locale: string;
|
|
17
|
+
/** shows private calendars it names and room bookings (titles, rooms, hosts, the name on a booking) */
|
|
18
|
+
privateDetails: boolean;
|
|
19
|
+
/** the secret in the screen's address; '' = no link until it's rotated */
|
|
20
|
+
key: string;
|
|
21
|
+
/** when the screen last fetched its events (ms; null = never) */
|
|
22
|
+
lastSeenAt: number | null;
|
|
23
|
+
order: number;
|
|
24
|
+
createdAt: number;
|
|
25
|
+
updatedAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ScreenInput = Pick<Screen, 'name' | 'params' | 'locale' | 'privateDetails'>;
|
|
29
|
+
|
|
30
|
+
// v67: Admin → Calendar → Screens. Not public (each screen is reached by its
|
|
31
|
+
// own key, never cached), so no content-version trigger.
|
|
32
|
+
export const SCREENS_MIGRATION = {
|
|
33
|
+
version: 67,
|
|
34
|
+
statements: [
|
|
35
|
+
`CREATE TABLE display_screens (
|
|
36
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', params TEXT NOT NULL DEFAULT '', locale TEXT NOT NULL DEFAULT '',
|
|
37
|
+
private_details INTEGER NOT NULL DEFAULT 0, screen_key TEXT NOT NULL DEFAULT '', last_seen_at INTEGER,
|
|
38
|
+
ord INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
39
|
+
)`,
|
|
40
|
+
`CREATE UNIQUE INDEX display_screens_key ON display_screens (screen_key) WHERE screen_key <> ''`,
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** 32 random bytes, base64url (43 characters) */
|
|
45
|
+
export function screenKey(): string {
|
|
46
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
47
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
48
|
+
}
|
|
49
|
+
export const isScreenKey = (s: string) => /^[A-Za-z0-9_-]{43}$/.test(s);
|
|
50
|
+
|
|
51
|
+
type Row = Record<string, unknown>;
|
|
52
|
+
const screen = (r: Row): Screen => ({
|
|
53
|
+
id: String(r.id), name: String(r.name ?? ''), params: String(r.params ?? ''), locale: String(r.locale ?? ''),
|
|
54
|
+
privateDetails: Number(r.private_details) === 1, key: String(r.screen_key ?? ''),
|
|
55
|
+
lastSeenAt: r.last_seen_at == null ? null : Number(r.last_seen_at),
|
|
56
|
+
order: Number(r.ord ?? 0), createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** The INSERT for one screen (create and backup restore). */
|
|
60
|
+
export function screenInsert(s: Screen): SqlStatement {
|
|
61
|
+
return {
|
|
62
|
+
sql: `INSERT INTO display_screens (id, name, params, locale, private_details, screen_key, last_seen_at, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
63
|
+
args: [s.id, s.name, s.params, s.locale, s.privateDetails ? 1 : 0, s.key, s.lastSeenAt, s.order, s.createdAt, s.updatedAt],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// a screen fetching every few minutes writes its check-in at most once a minute
|
|
68
|
+
const CHECK_IN_EVERY = 60_000;
|
|
69
|
+
|
|
70
|
+
export class ScreenStore {
|
|
71
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
72
|
+
private async all(sql: string, args: unknown[] = []) { return (await this.driver()).all(sql, args); }
|
|
73
|
+
|
|
74
|
+
async list(): Promise<Screen[]> {
|
|
75
|
+
return (await this.all('SELECT * FROM display_screens ORDER BY ord, name COLLATE NOCASE, created_at')).map(screen);
|
|
76
|
+
}
|
|
77
|
+
async get(id: string): Promise<Screen | null> {
|
|
78
|
+
const r = await this.all('SELECT * FROM display_screens WHERE id = ?', [id]);
|
|
79
|
+
return r[0] ? screen(r[0]) : null;
|
|
80
|
+
}
|
|
81
|
+
/** the screen with this key (null for a wrong, rotated or empty key) */
|
|
82
|
+
async byKey(key: string): Promise<Screen | null> {
|
|
83
|
+
if (!isScreenKey(key)) return null;
|
|
84
|
+
const r = await this.all('SELECT * FROM display_screens WHERE screen_key = ?', [key]);
|
|
85
|
+
return r[0] ? screen(r[0]) : null;
|
|
86
|
+
}
|
|
87
|
+
async create(input: ScreenInput, now = Date.now()): Promise<Screen> {
|
|
88
|
+
const next = await this.all('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM display_screens');
|
|
89
|
+
const s: Screen = { id: crypto.randomUUID(), ...input, key: screenKey(), lastSeenAt: null, order: Number(next[0]?.n ?? 0), createdAt: now, updatedAt: now };
|
|
90
|
+
const insert = screenInsert(s);
|
|
91
|
+
return screen((await this.all(`${insert.sql} RETURNING *`, insert.args))[0]);
|
|
92
|
+
}
|
|
93
|
+
async update(id: string, input: ScreenInput, now = Date.now()): Promise<Screen | null> {
|
|
94
|
+
const r = await this.all('UPDATE display_screens SET name = ?, params = ?, locale = ?, private_details = ?, updated_at = ? WHERE id = ? RETURNING *',
|
|
95
|
+
[input.name, input.params, input.locale, input.privateDetails ? 1 : 0, now, id]);
|
|
96
|
+
return r[0] ? screen(r[0]) : null;
|
|
97
|
+
}
|
|
98
|
+
/** a new key: the old address stops working at once */
|
|
99
|
+
async rotate(id: string, now = Date.now()): Promise<Screen | null> {
|
|
100
|
+
const r = await this.all('UPDATE display_screens SET screen_key = ?, updated_at = ? WHERE id = ? RETURNING *', [screenKey(), now, id]);
|
|
101
|
+
return r[0] ? screen(r[0]) : null;
|
|
102
|
+
}
|
|
103
|
+
async delete(id: string): Promise<boolean> {
|
|
104
|
+
return (await this.all('DELETE FROM display_screens WHERE id = ? RETURNING id', [id])).length > 0;
|
|
105
|
+
}
|
|
106
|
+
async reorder(ids: string[]): Promise<void> {
|
|
107
|
+
await (await this.driver()).batch(ids.map((id, i) => ({ sql: 'UPDATE display_screens SET ord = ? WHERE id = ?', args: [i, id] })));
|
|
108
|
+
}
|
|
109
|
+
/** the screen fetched its events (written at most once a minute) */
|
|
110
|
+
async checkIn(id: string, now = Date.now()): Promise<void> {
|
|
111
|
+
await (await this.driver()).run('UPDATE display_screens SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at <= ?)', [now, id, now - CHECK_IN_EVERY]);
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/lib/display/words.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Words of the TV display (/display/…). Dates, weekdays and times come from
|
|
2
2
|
// Intl in the display's language; these are the rest. Plain strings with
|
|
3
|
-
// {slots} filled by `fill`. Other languages fall back to English.
|
|
3
|
+
// {slots} filled by `fill`. Other languages fall back to English. "Moved"
|
|
4
|
+
// and "Cancelled" are the event pages' own words (event-words.ts), so a TV
|
|
5
|
+
// says what the site and its emails say.
|
|
6
|
+
import { eventStrings } from '../calendar/event-words';
|
|
4
7
|
export interface DisplayStrings {
|
|
5
8
|
thisWeek: string;
|
|
6
9
|
/** a rolling week (start=today) or list: "Next 7 days" */
|
|
@@ -16,11 +19,13 @@ export interface DisplayStrings {
|
|
|
16
19
|
nothingNext: string;
|
|
17
20
|
noEvents: string;
|
|
18
21
|
more: string;
|
|
22
|
+
/** from eventStrings: the event pages' word */
|
|
19
23
|
moved: string;
|
|
20
24
|
/** a moved date's old time */
|
|
21
25
|
was: string;
|
|
22
26
|
/** appointments from Google booking pages (#google-booking) */
|
|
23
27
|
booked: string;
|
|
28
|
+
/** from eventStrings: the event pages' word */
|
|
24
29
|
cancelled: string;
|
|
25
30
|
scan: string;
|
|
26
31
|
tomorrow: string;
|
|
@@ -28,25 +33,30 @@ export interface DisplayStrings {
|
|
|
28
33
|
notFoundNote: string;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
const STRINGS: Record<string, DisplayStrings
|
|
36
|
+
const STRINGS: Record<string, Omit<DisplayStrings, 'moved' | 'cancelled'>> = {
|
|
32
37
|
en: {
|
|
33
38
|
thisWeek: 'This week', nextDays: 'Next {n} days', today: 'Today', upcoming: 'Coming up', allDay: 'All day',
|
|
34
39
|
updated: 'Updated {time}', now: 'Now', next: 'Next', nothingNow: 'Nothing right now', nothingNext: 'Nothing else today',
|
|
35
|
-
noEvents: 'No events', more: '+{n} more',
|
|
40
|
+
noEvents: 'No events', more: '+{n} more', was: 'was {time}', booked: 'Booked',
|
|
36
41
|
scan: 'Scan for the calendar', tomorrow: 'Tomorrow',
|
|
37
42
|
notFound: 'This screen isn’t available', notFoundNote: 'Its link may have changed. Copy the new one from Admin → Calendar → Screens.',
|
|
38
43
|
},
|
|
39
44
|
es: {
|
|
40
45
|
thisWeek: 'Esta semana', nextDays: 'Próximos {n} días', today: 'Hoy', upcoming: 'Próximamente', allDay: 'Todo el día',
|
|
41
46
|
updated: 'Actualizado {time}', now: 'Ahora', next: 'Después', nothingNow: 'Nada en este momento', nothingNext: 'Nada más hoy',
|
|
42
|
-
noEvents: 'Sin eventos', more: '+{n} más',
|
|
47
|
+
noEvents: 'Sin eventos', more: '+{n} más', was: 'antes {time}', booked: 'Reservado',
|
|
43
48
|
scan: 'Escanea para ver el calendario', tomorrow: 'Mañana',
|
|
44
49
|
notFound: 'Esta pantalla no está disponible', notFoundNote: 'Puede que su enlace haya cambiado. Copia el nuevo en Admin → Calendario → Pantallas.',
|
|
45
50
|
},
|
|
46
51
|
};
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
/** "Booked" in every language: the Google sync titles its appointments with it */
|
|
54
|
+
export const isBookedWord = (title: string) => Object.values(STRINGS).some((s) => s.booked.toLowerCase() === title.trim().toLowerCase());
|
|
55
|
+
|
|
56
|
+
export const displayStrings = (lang: string | null | undefined): DisplayStrings => {
|
|
57
|
+
const ew = eventStrings(lang);
|
|
58
|
+
return { ...(STRINGS[(lang || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en), moved: ew.moved, cancelled: ew.cancelled };
|
|
59
|
+
};
|
|
50
60
|
|
|
51
61
|
export const fill = (s: string, slots: Record<string, string | number>) =>
|
|
52
62
|
s.replace(/\{(\w+)\}/g, (m, k: string) => (k in slots ? String(slots[k]) : m));
|
|
@@ -11,6 +11,7 @@ import { sha256Hex } from '../api-keys';
|
|
|
11
11
|
import type { PublicContext } from './route';
|
|
12
12
|
import { addDaysYmd, describeLink, linkLengths, localMidnight, slotsForDay, ymd } from '../booking/availability';
|
|
13
13
|
import { siteOrigin } from '../seo';
|
|
14
|
+
import { siteLang } from '../dates';
|
|
14
15
|
import { safeUrl } from './content';
|
|
15
16
|
import { pageQuery, isoDay, isoDayOrInstant, queryParams } from './http';
|
|
16
17
|
import { localizeDoc } from '../translations';
|
|
@@ -116,7 +117,8 @@ export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>) {
|
|
|
116
117
|
}
|
|
117
118
|
// e is already localized; publicCalendars limits which calendars are named
|
|
118
119
|
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null, resources?: Map<string, Resource>) {
|
|
119
|
-
|
|
120
|
+
// the repeat line in the request's language, else the site's main one
|
|
121
|
+
const event = eventToJson(e, origin, publicCalendars, settings.timezone, { resources, locale: locale || siteLang(settings) });
|
|
120
122
|
event.url = localizedUrl(event.url, origin, locale);
|
|
121
123
|
// Only configured public event fields; no undeclared imported metadata, no private fields.
|
|
122
124
|
event.fields = publicEventFields(e.fields);
|
|
@@ -10,6 +10,7 @@ import { PLACES_MIGRATION } from '../maps/migration';
|
|
|
10
10
|
import { ROLES_MIGRATION } from '../permissions/migration';
|
|
11
11
|
import { SYNC_MIGRATION } from '../sync/migration';
|
|
12
12
|
import { ANNOUNCEMENT_TABLE, ANNOUNCEMENT_VISIBLE_COLUMNS } from '../announcements/migration';
|
|
13
|
+
import { SCREENS_MIGRATION } from '../display/store';
|
|
13
14
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
14
15
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
15
16
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -727,6 +728,8 @@ export const MIGRATIONS: Migration[] = [
|
|
|
727
728
|
// single dates of a series moved (CalEvent.movedDates): a column on the
|
|
728
729
|
// row, so the triggers on events (content version, event_changes) see a move
|
|
729
730
|
MOVED_DATES_MIGRATION(contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates'])[2]),
|
|
731
|
+
// saved TV screens (Admin → Calendar → Screens, lib/display/store.ts)
|
|
732
|
+
SCREENS_MIGRATION,
|
|
730
733
|
];
|
|
731
734
|
|
|
732
735
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { GuestStore } from '../mobile/guest-store';
|
|
|
6
6
|
import { LiveStore } from '../live/store';
|
|
7
7
|
import { PushStore } from '../push/store';
|
|
8
8
|
import { AnnouncementStore, announcementInsert } from '../announcements/store';
|
|
9
|
+
import { ScreenStore, screenInsert, screenKey } from '../display/store';
|
|
9
10
|
import { EditorStore } from '../editor/store';
|
|
10
11
|
import { PermissionStore } from '../permissions/store';
|
|
11
12
|
import { BackupStore } from '../backup/store';
|
|
@@ -296,6 +297,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
296
297
|
readonly editor: EditorStore;
|
|
297
298
|
readonly backups: BackupStore;
|
|
298
299
|
readonly permissions: PermissionStore;
|
|
300
|
+
readonly screens: ScreenStore;
|
|
299
301
|
|
|
300
302
|
constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
301
303
|
this.ready = this.init(driver);
|
|
@@ -310,6 +312,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
310
312
|
this.auth = new AuthStore(async () => { await this.ready; return this.driver; });
|
|
311
313
|
this.email = new EmailStore(async () => { await this.ready; return this.driver; });
|
|
312
314
|
this.permissions = new PermissionStore(async () => { await this.ready; return this.driver; });
|
|
315
|
+
this.screens = new ScreenStore(async () => { await this.ready; return this.driver; });
|
|
313
316
|
}
|
|
314
317
|
|
|
315
318
|
private async init(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
@@ -1938,6 +1941,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
1938
1941
|
// announcements (a backup made before them leaves the current ones). Restored
|
|
1939
1942
|
// rows never push again here: the push ledger keeps announcement:<id>
|
|
1940
1943
|
...(content.announcements ? [{ sql: 'DELETE FROM announcements' }, ...content.announcements.map(announcementInsert)] : []),
|
|
1944
|
+
// screens come back with new keys (backups never hold them): each TV needs its new link
|
|
1945
|
+
...(content.screens ? [{ sql: 'DELETE FROM display_screens' }, ...content.screens.map((s) => screenInsert({ ...s, key: screenKey(), lastSeenAt: null }))] : []),
|
|
1941
1946
|
...(content.forms ?? []).map(formInsert),
|
|
1942
1947
|
...(content.formSubmissions ?? []).map(submissionInsert),
|
|
1943
1948
|
...(content.resources ?? []).map((r) => this.resourceInsert(r)),
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { AnnouncementStore } from '../announcements/store';
|
|
|
13
13
|
import type { Announcement } from '../announcements/types';
|
|
14
14
|
import type { EditorStore } from '../editor/store';
|
|
15
15
|
import type { PermissionStore } from '../permissions/store';
|
|
16
|
+
import type { Screen, ScreenStore } from '../display/store';
|
|
16
17
|
export interface ImportRun { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Record<string, unknown>; createdAt: number; updatedAt: number }
|
|
17
18
|
export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'redirect' | 'nav';
|
|
18
19
|
export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
|
|
@@ -83,6 +84,7 @@ export interface ContentRepository {
|
|
|
83
84
|
readonly announcements: AnnouncementStore;
|
|
84
85
|
readonly editor: EditorStore;
|
|
85
86
|
readonly permissions: PermissionStore;
|
|
87
|
+
readonly screens: ScreenStore;
|
|
86
88
|
// Bounded public catalog; fetch limit + 1 IDs without loading every body.
|
|
87
89
|
listPublicContent(options: { page: number; limit: number; kind?: 'post' | 'page'; collection?: string; locale?: string; collections: string[]; defaultLocale: string }): Promise<{ id: string; kind: 'post' | 'page' }[]>;
|
|
88
90
|
exportBookingState(): Promise<{ bookings: Booking[]; bookingRequests: BookingRequest[] }>;
|
|
@@ -367,6 +369,9 @@ export interface ContentRepository {
|
|
|
367
369
|
formSubmissions?: FormSubmission[];
|
|
368
370
|
// added with announcements; a backup without them leaves the current ones
|
|
369
371
|
announcements?: Announcement[];
|
|
372
|
+
// saved TV screens, without their keys (each gets a new one); a backup
|
|
373
|
+
// without them leaves the current ones
|
|
374
|
+
screens?: Screen[];
|
|
370
375
|
}, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
|
|
371
376
|
|
|
372
377
|
// imports (lib/import/apply.ts drives these). writeImported is the
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The item shapes section data carries (lib/sections.ts fills them): what a
|
|
2
|
+
// theme's section components and the apps' native sections receive.
|
|
3
|
+
// No imports, so other packages can typecheck against them
|
|
4
|
+
// (packages/native keeps its own copy; test/section-items.test.ts there
|
|
5
|
+
// keeps the two in step).
|
|
6
|
+
|
|
7
|
+
export interface LinkValue { label: string; href: string; external: boolean }
|
|
8
|
+
/** a room this date holds, named only when set to show on public events; color null = none */
|
|
9
|
+
export interface EventItemRoom { slug: string; name: string; color: string | null }
|
|
10
|
+
export interface EventItem {
|
|
11
|
+
title: string; url: string; summary: string; image: string; imageAlt: string; location: string;
|
|
12
|
+
start: string; end: string; allDay: boolean; cancelled: boolean;
|
|
13
|
+
/** this date was moved here: from "Thu, Oct 1 · 6:00 PM" (where it was), with staff's public note; null when not moved */
|
|
14
|
+
moved: { from: string; note: string } | null;
|
|
15
|
+
/** the public rooms this date holds (a moved date's own, when it moved into others) */
|
|
16
|
+
rooms: EventItemRoom[];
|
|
17
|
+
/** "Sat, Oct 3 · 11:00 AM" */ when: string; /** "Saturday, October 3, 2026" */ date: string;
|
|
18
|
+
/** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
|
|
19
|
+
cta: { label: string; href: string } | null;
|
|
20
|
+
}
|
|
21
|
+
export interface PostAuthor { name: string; url: string; avatar: string }
|
|
22
|
+
// excerpt: the post's own, else the first ~160 characters of its text
|
|
23
|
+
export interface PostItem { title: string; url: string; excerpt: string; image: string; imageAlt: string; date: string; publishedAt: number | null; tags: string[]; authors: PostAuthor[] }
|
|
24
|
+
export interface BookingItem { title: string; url: string; description: string; resource: string; minutes: number }
|
package/src/lib/sections.ts
CHANGED
|
@@ -28,20 +28,8 @@ type Instance = z.infer<typeof SectionInstanceSchema>;
|
|
|
28
28
|
type Definition = z.infer<typeof SectionDefinition>;
|
|
29
29
|
type FieldDef = Definition['fields'][string];
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
export
|
|
33
|
-
title: string; url: string; summary: string; image: string; imageAlt: string; location: string;
|
|
34
|
-
start: string; end: string; allDay: boolean; cancelled: boolean;
|
|
35
|
-
/** this date was moved here: from "Thu, Oct 1 · 6:00 PM" (where it was), with staff's public note; null when not moved */
|
|
36
|
-
moved: { from: string; note: string } | null;
|
|
37
|
-
/** "Sat, Oct 3 · 11:00 AM" */ when: string; /** "Saturday, October 3, 2026" */ date: string;
|
|
38
|
-
/** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
|
|
39
|
-
cta: { label: string; href: string } | null;
|
|
40
|
-
}
|
|
41
|
-
export interface PostAuthor { name: string; url: string; avatar: string }
|
|
42
|
-
// excerpt: the post's own, else the first ~160 characters of its text
|
|
43
|
-
export interface PostItem { title: string; url: string; excerpt: string; image: string; imageAlt: string; date: string; publishedAt: number | null; tags: string[]; authors: PostAuthor[] }
|
|
44
|
-
export interface BookingItem { title: string; url: string; description: string; resource: string; minutes: number }
|
|
31
|
+
import type { BookingItem, EventItem, LinkValue, PostAuthor, PostItem } from './section-items';
|
|
32
|
+
export type { BookingItem, EventItem, EventItemRoom, LinkValue, PostAuthor, PostItem } from './section-items';
|
|
45
33
|
|
|
46
34
|
/** apply a theme migration when the stored instance is older than the definition */
|
|
47
35
|
export function migrateSection(theme: Pick<Theme, 'sections' | 'migrations'>, s: Instance): Instance {
|
|
@@ -148,7 +136,8 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
|
|
|
148
136
|
return {
|
|
149
137
|
title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
|
|
150
138
|
location: locationLine(o.moved?.location ?? e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
151
|
-
allDay: o.allDay, cancelled: o.cancelled, moved: was && o.moved ? { from: o.allDay ? was.date : was.range, note: o.moved.note } : null,
|
|
139
|
+
allDay: o.allDay, cancelled: o.cancelled, moved: was && o.moved ? { from: o.allDay ? was.date : was.range, note: o.moved.note } : null,
|
|
140
|
+
rooms: (o.rooms ?? []).map((r) => ({ slug: r.slug, name: r.name, color: r.color || null })), when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
|
|
152
141
|
cta: e.cta.label && ctaHref && !o.cancelled ? { label: e.cta.label, href: ctaHref } : null,
|
|
153
142
|
};
|
|
154
143
|
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../../layouts/Admin.astro';
|
|
3
|
+
import ScreensManager from '../../../../components/display/ScreensManager';
|
|
4
|
+
import { getRepo } from '../../../../lib/repo';
|
|
5
|
+
import { CALENDAR } from '../../../../lib/calendar/config';
|
|
6
|
+
import { forbiddenUnless } from '../../../../lib/permissions/access';
|
|
7
|
+
|
|
8
|
+
// Admin → Calendar → Screens: saved TV screens and quick links
|
|
9
|
+
// (lib/display). A screen's link can show private calendars, so it takes the
|
|
10
|
+
// same permission as calendars and rooms.
|
|
11
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
12
|
+
const repo = await getRepo(Astro.locals);
|
|
13
|
+
const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.manage');
|
|
14
|
+
if (denied) return denied;
|
|
15
|
+
---
|
|
16
|
+
<Admin title="Screens" section="calendar:screens" wide>
|
|
17
|
+
<div class="page-head">
|
|
18
|
+
<div>
|
|
19
|
+
<h1>Screens</h1>
|
|
20
|
+
<p class="sub">Full-screen calendars for TVs. A change here reaches the TV within 5 minutes.</p>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
<ScreensManager client:only="react" />
|
|
24
|
+
</Admin>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
import DisplayPage from '../../../../components/display/DisplayPage.astro';
|
|
3
|
+
import { getRepo } from '../../../../lib/repo';
|
|
4
|
+
import { CALENDAR } from '../../../../lib/calendar/config';
|
|
5
|
+
import { I18N, localePath } from '../../../../lib/i18n';
|
|
6
|
+
import { siteOrigin } from '../../../../lib/seo';
|
|
7
|
+
import { siteLang } from '../../../../lib/dates';
|
|
8
|
+
import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
|
|
9
|
+
import { readDisplaySettings } from '../../../../lib/display/settings';
|
|
10
|
+
import { loadDisplayBoard } from '../../../../lib/display/board';
|
|
11
|
+
|
|
12
|
+
// The Screens editor's live preview (a 16:9 frame beside the settings): the
|
|
13
|
+
// board as the TV will show it, from settings not saved yet. ?lang= picks the
|
|
14
|
+
// language, ?private=1 shows what a screen with private details shows (for
|
|
15
|
+
// people who may see bookings). It never refreshes, keeps no offline copy
|
|
16
|
+
// and counts as no screen's check-in.
|
|
17
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
18
|
+
const repo = await getRepo(Astro.locals);
|
|
19
|
+
const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.manage');
|
|
20
|
+
if (denied) return denied;
|
|
21
|
+
const site = await repo.getSettings();
|
|
22
|
+
const q = Astro.url.searchParams;
|
|
23
|
+
const canPrivate = Boolean((await accessFor(Astro.request, Astro.cookies, repo))?.can('schedule.bookings'));
|
|
24
|
+
const settings = readDisplaySettings(q);
|
|
25
|
+
const wanted = q.get('lang') ?? '';
|
|
26
|
+
const lang = I18N.enabled && I18N.locales.includes(wanted) ? wanted : siteLang(site);
|
|
27
|
+
const board = await loadDisplayBoard(repo, site, {
|
|
28
|
+
settings, privateDetails: q.get('private') === '1' && canPrivate, lang,
|
|
29
|
+
phoneLink: siteOrigin(site, Astro.url) + localePath(CALENDAR.base, lang),
|
|
30
|
+
});
|
|
31
|
+
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
32
|
+
Astro.response.headers.set('Content-Security-Policy', "frame-ancestors 'self'");
|
|
33
|
+
Astro.response.headers.set('Referrer-Policy', 'no-referrer');
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<DisplayPage site={site} board={board} settings={settings} title="Screen preview" lang={lang} live={false} />
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../../lib/repo';
|
|
3
|
+
import { ApiError, apiError, json, readJson } from '../../../lib/api';
|
|
4
|
+
import { requirePermission } from '../../../lib/permissions/access';
|
|
5
|
+
import { CALENDAR } from '../../../lib/calendar/config';
|
|
6
|
+
import { siteOrigin } from '../../../lib/seo';
|
|
7
|
+
import { ScreenAction, screenAction, screensState } from '../../../lib/display/screens';
|
|
8
|
+
|
|
9
|
+
// Admin → Calendar → Screens: saved TV screens, for people who manage rooms
|
|
10
|
+
// and calendars (a screen's link can show private calendars).
|
|
11
|
+
export const GET: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
12
|
+
try {
|
|
13
|
+
if (!CALENDAR.enabled) throw new ApiError(404, 'The calendar is off on this site.');
|
|
14
|
+
const repo = await getRepo(locals);
|
|
15
|
+
const access = await requirePermission(request, cookies, repo, 'schedule.manage');
|
|
16
|
+
return json(await screensState(repo, siteOrigin(await repo.getSettings(), url), { bookings: access.can('schedule.bookings') }));
|
|
17
|
+
} catch (e) { return apiError(e); }
|
|
18
|
+
};
|
|
19
|
+
export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
20
|
+
try {
|
|
21
|
+
if (!CALENDAR.enabled) throw new ApiError(404, 'The calendar is off on this site.');
|
|
22
|
+
const repo = await getRepo(locals);
|
|
23
|
+
const access = await requirePermission(request, cookies, repo, 'schedule.manage');
|
|
24
|
+
// a screen's key unlocks private calendars: only from this site's admin
|
|
25
|
+
if (request.headers.get('origin') !== url.origin) throw new ApiError(403, 'Change this from this site’s admin.');
|
|
26
|
+
const bookings = access.can('schedule.bookings');
|
|
27
|
+
const result = await screenAction(repo, ScreenAction.parse(await readJson(request, 20_000)), { bookings });
|
|
28
|
+
return json({ ...result, state: await screensState(repo, siteOrigin(await repo.getSettings(), url), { bookings }) });
|
|
29
|
+
} catch (e) { return apiError(e); }
|
|
30
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
import DisplayPage from '../../../components/display/DisplayPage.astro';
|
|
3
|
+
import { getRepo } from '../../../lib/repo';
|
|
4
|
+
import { CALENDAR } from '../../../lib/calendar/config';
|
|
5
|
+
import { I18N, localePath, requestLocale } from '../../../lib/i18n';
|
|
6
|
+
import { siteOrigin } from '../../../lib/seo';
|
|
7
|
+
import { siteLang } from '../../../lib/dates';
|
|
8
|
+
import { defaultDisplaySettings, readDisplaySettings } from '../../../lib/display/settings';
|
|
9
|
+
import { loadDisplayBoard } from '../../../lib/display/board';
|
|
10
|
+
import { displayHeaders } from '../../../lib/display/http';
|
|
11
|
+
import { MISS_WINDOW, openScreen } from '../../../lib/display/screens';
|
|
12
|
+
|
|
13
|
+
// A saved screen (Admin → Calendar → Screens): its long random key is the
|
|
14
|
+
// whole address. With "Show private calendars and room bookings" on, it shows
|
|
15
|
+
// the private calendars it names and its rooms' bookings, with titles, rooms,
|
|
16
|
+
// hosts and the name on a booking; never emails, phone numbers or notes.
|
|
17
|
+
// The board's 5-minute refresh fetches this same address, so a rotated key
|
|
18
|
+
// stops the TV at once and every fetch counts as the screen's check-in.
|
|
19
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
20
|
+
const repo = await getRepo(Astro.locals);
|
|
21
|
+
let address = 'unknown';
|
|
22
|
+
try { address = Astro.clientAddress || address; } catch { /* adapters without a client address share a bucket */ }
|
|
23
|
+
const opened = await openScreen(repo, Astro.params.key ?? '', address);
|
|
24
|
+
if (opened.status === 'limited') {
|
|
25
|
+
return new Response('Too many wrong screen links from this network. Try again in a few minutes.', {
|
|
26
|
+
status: 429,
|
|
27
|
+
headers: { 'Retry-After': String(MISS_WINDOW / 1000), 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer', 'Content-Type': 'text/plain; charset=utf-8' },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const site = await repo.getSettings();
|
|
31
|
+
// the address's language prefix (/es/display/s/…) picks the worker's scope
|
|
32
|
+
const pathLang = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
|
|
33
|
+
const prefix = I18N.enabled && pathLang && pathLang !== I18N.defaultLocale ? `/${pathLang}` : '';
|
|
34
|
+
const screen = opened.status === 'ok' ? opened.screen : null;
|
|
35
|
+
const settings = screen ? readDisplaySettings(new URLSearchParams(screen.params)) : defaultDisplaySettings();
|
|
36
|
+
const lang = screen?.locale || (prefix ? pathLang : '') || siteLang(site);
|
|
37
|
+
const board = screen
|
|
38
|
+
? await loadDisplayBoard(repo, site, {
|
|
39
|
+
settings, privateDetails: screen.privateDetails, lang,
|
|
40
|
+
phoneLink: siteOrigin(site, Astro.url) + localePath(CALENDAR.base, lang),
|
|
41
|
+
})
|
|
42
|
+
: null;
|
|
43
|
+
if (screen) await repo.screens.checkIn(screen.id);
|
|
44
|
+
else Astro.response.status = 404;
|
|
45
|
+
displayHeaders(Astro.response.headers, { shared: false });
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
<DisplayPage site={site} board={board} settings={settings} title={screen?.name || site.siteTitle} lang={lang} live sw={`${prefix}/display/sw.js`} />
|
package/src/styles/display.css
CHANGED
|
@@ -161,7 +161,8 @@ body { background: #000; }
|
|
|
161
161
|
/* list: columns, then pages (the script turns them) */
|
|
162
162
|
.display-list { height: 100%; overflow: hidden; }
|
|
163
163
|
.display-list-cols { height: 100%; column-count: var(--dsp-list-cols, 3); column-gap: calc(3.5 * var(--dsp-u)); column-fill: auto; }
|
|
164
|
-
|
|
164
|
+
/* a day stays in one column when it fits, so a column never starts without its day */
|
|
165
|
+
.display-day { margin: 0 0 calc(2.2 * var(--dsp-u)); break-inside: avoid-column; }
|
|
165
166
|
.display-day-title { margin: 0 0 calc(0.6 * var(--dsp-u)); font-size: calc(2.3 * var(--dsp-u)); font-weight: 700; color: var(--dsp-muted); break-after: avoid; }
|
|
166
167
|
.display-day.is-today .display-day-title { color: var(--dsp-fg); }
|
|
167
168
|
.display-row {
|