@acms-subir/core 0.3.51 → 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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.52
4
+
5
+ ### Patch Changes
6
+
7
+ - b8ffed8: Saved TV screens: **Admin → Calendar → Screens** (for people who manage rooms and calendars).
8
+
9
+ Each screen has a name, the display settings (view, hours with "Show all hours" or "Fit the events", days, first day, calendars, rooms in column order, tags, theme, text size, what shows, title, language) beside a live 16:9 preview, and a long random key that is its whole address: `/display/s/<key>`. **Copy link** copies it, and **Rotate link** makes a new one; the old link stops working at once. **Last check-in** shows when the screen last fetched its events (a TV that's on checks in every 5 minutes), so staff can tell a TV is off. A change made in the admin reaches the TV within 5 minutes. The same editor builds quick links, can start from a pasted one, and saves any quick link as a screen.
10
+
11
+ **Show private calendars and room bookings** (off by default) lets a screen show the private calendars it names and the confirmed bookings of the rooms it names (or of rooms set to show on the calendar): titles, rooms, hosts and the name on each booking ("Birthday party · Rivera"), never emails, phone numbers, booking notes or RSVP lists. A Google booking keeps "Booked" with its own title under it. Turning it on, or previewing it, also needs **Manage bookings**. Saved screens are never cached (`private, no-store`) and send no referrer. Wrong keys are rate-limited: after 60 in 15 minutes from one address, further wrong keys from it get a 429, while a screen's own key always opens it (bad keys can't lock a church's TVs out).
12
+
13
+ Migration 67 adds the `display_screens` table. Screens go into backups without their keys; after a restore each screen has a new link to put on its TV. New: `/display/s/[key]`, `/admin/calendar/screens`, `/admin/calendar/screens/preview` and `/api/calendar/screens` (staff only). `npx acms docs calendar` covers screens, keys and private details.
14
+
15
+ The display list now keeps each day in one column when it fits, so a column never starts without its day.
16
+
17
+ Moved and cancelled dates on the display use the event pages' own words ("Cambio de horario" in Spanish), so a TV says what the site and its emails say.
18
+
3
19
  ## 0.3.51
4
20
 
5
21
  ### Patch Changes
@@ -434,6 +434,19 @@ var ANNOUNCEMENT_VISIBLE_COLUMNS = [
434
434
  "published_at"
435
435
  ];
436
436
 
437
+ // src/lib/display/store.ts
438
+ var SCREENS_MIGRATION = {
439
+ version: 67,
440
+ statements: [
441
+ `CREATE TABLE display_screens (
442
+ id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', params TEXT NOT NULL DEFAULT '', locale TEXT NOT NULL DEFAULT '',
443
+ private_details INTEGER NOT NULL DEFAULT 0, screen_key TEXT NOT NULL DEFAULT '', last_seen_at INTEGER,
444
+ ord INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
445
+ )`,
446
+ `CREATE UNIQUE INDEX display_screens_key ON display_screens (screen_key) WHERE screen_key <> ''`
447
+ ]
448
+ };
449
+
437
450
  // src/lib/repo/migrations.ts
438
451
  var CONTENT_VERSION_COLUMNS = {
439
452
  settings: [
@@ -1266,7 +1279,9 @@ var MIGRATIONS = [
1266
1279
  EVENT_CHANGES_MIGRATION,
1267
1280
  // single dates of a series moved (CalEvent.movedDates): a column on the
1268
1281
  // row, so the triggers on events (content version, event_changes) see a move
1269
- MOVED_DATES_MIGRATION(contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates"])[2])
1282
+ MOVED_DATES_MIGRATION(contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates"])[2]),
1283
+ // saved TV screens (Admin → Calendar → Screens, lib/display/store.ts)
1284
+ SCREENS_MIGRATION
1270
1285
  ];
1271
1286
  var CURRENT_CONTENT_VERSION_COLUMNS = {
1272
1287
  ...CONTENT_VERSION_COLUMNS,
package/docs/calendar.md CHANGED
@@ -195,9 +195,12 @@ client has `roomFree({ room, start, end })`, and the native package has
195
195
 
196
196
  A full-screen calendar for a TV in a lobby or on a room's door. It shows a week, day, month, list, rooms or "now and next" view, with your own hours (8 AM to 10 PM by default). TVs have no mouse, so everything about a screen lives in its link. The page has nothing to focus or follow (no links, buttons or tab stops), because a remote's presses go to the page.
197
197
 
198
- A display link spells out its settings in the address: `/display/calendar?view=week&hours=8-22`. Nothing is saved. Whatever its parameters, it shows only what `calendar.ics` shows: published events in public calendars, or in an unlisted calendar named in `cal=`. It never shows subscribed calendars, booking blocks, hosts, or rooms that aren't shown on public events.
198
+ There are two kinds of link:
199
199
 
200
- Spanish (or any other site language): `/es/display/calendar?…`.
200
+ - **Quick links** spell out their settings in the address: `/display/calendar?view=week&hours=8-22`. Nothing is saved. Whatever their parameters, they show only what `calendar.ics` shows: published events in public calendars, or in an unlisted calendar named in `cal=`. There are no subscribed calendars, booking blocks, room names (until rooms are shown on public events) or hosts.
201
+ - **Saved screens** live in **Admin → Calendar → Screens**. Each has a name, the same settings and a long random key, which is the whole address: `/display/s/<key>`. A screen can show private details (below).
202
+
203
+ Spanish (or any other site language): `/es/display/calendar?…`, or the screen's Language setting.
201
204
 
202
205
  ### Parameters
203
206
 
@@ -209,12 +212,12 @@ Each has a default; leave out what you don't need. Lists are comma-separated or
209
212
  | `hours` | `8-22` · `8:30-21` · `8-24` · `0-24` (or `all`) · `auto` | The hour grids' visible hours. `8-24` ends at midnight. `auto` fits the events shown, never less than 6 hours. Events outside the hours are cut and marked (▲ ▼), never left out. Default `8-22`. |
210
213
  | `days` | `7` · `5` · `mon-fri` · `mon-sat` · `sun,wed,fri` | A number is that many days from the first day; names pick weekdays. For `list`, a number is how many days the list covers. Default 7. |
211
214
  | `start` | `sun` · `mon` · `today` | The week's first day. `today` gives a rolling week. Default: Calendar settings → Defaults. |
212
- | `cal` | calendar slugs | Only these calendars. Default: every public calendar. Private calendars never show. |
213
- | `tag` / `notag` | tag names | Only events with one of these tags, or none of them. `tag=Class` makes a class board. Internal `#` tags never match. |
214
- | `room` | room slugs | Only events held in these rooms. With `view=rooms` it also sets the columns and their order (otherwise every room of kind "Room"). Only rooms shown on public events count; any other room shows nothing. |
215
+ | `cal` | calendar slugs | Only these calendars. Default: every public calendar. Private calendars need a saved screen with private details. |
216
+ | `tag` / `notag` | tag names | Only events with one of these tags, or none of them. `tag=Class` makes a class board. Internal `#` tags only match on a screen with private details. |
217
+ | `room` | room slugs | Only events held in these rooms. With `view=rooms` it also sets the columns and their order (otherwise every room of kind "Room"). On a quick link, only rooms named on public events count; any other room shows nothing. |
215
218
  | `theme` | `dark` · `light` | Default `dark`. |
216
219
  | `size` | `s` · `m` · `l` · `xl` | Text size for the viewing distance. Default `m`. |
217
- | `show` | `time,room,host,clock,legend,qr` (or `none`) | What each event and the page show. Default `time,room,clock`. `legend` is a colour key; `qr` is a code that opens the calendar on a phone (with the same filters). `host` is for saved screens with private details, which are coming next. |
220
+ | `show` | `time,room,host,clock,legend,qr` (or `none`) | What each event and the page show. Default `time,room,clock`. `legend` is a colour key; `qr` is a code that opens the calendar on a phone (with the same public filters). Hosts only show on a screen with private details. |
218
221
  | `title` | text | Replaces the heading ("This week", "Today"…). |
219
222
 
220
223
  Examples:
@@ -223,19 +226,30 @@ Examples:
223
226
  - `/display/calendar?view=list&days=14&size=l`: the next two weeks, in large text.
224
227
  - `/display/calendar?view=rooms&room=chapel,hall,youth&hours=auto`: today, one column per room.
225
228
 
229
+ The editor in Admin → Calendar → Screens builds these links (**Quick link**), shows a live preview and can save any quick link as a screen.
230
+
226
231
  ### What a board does
227
232
 
228
233
  - The first view is rendered on the server. A small script moves the now line, dims what has ended, and turns the list's pages (the list runs in columns, then pages, instead of scrolling).
229
234
  - Every 5 minutes it fetches the board again from the same address, so a change made in the admin reaches the TV within 5 minutes. It also fetches at midnight, so the board rolls to the next day, week or month. It reloads once a night (between 3:00 and 3:40, site time).
230
- - Offline, it keeps the last board and shows "Updated 7:41 PM". A small service worker (scope `/display/`) keeps a copy of the page, so a reload during an outage shows the last events instead of an error page.
235
+ - Offline, it keeps the last board and shows "Updated 7:41 PM". A small service worker (scope `/display/`) keeps a copy of the page, so a reload during an outage or a server error shows the last events instead of an error page (a copy older than a week is never shown).
231
236
  - Events take their room's colour, else their calendar's. Appointments from Google booking pages (tagged `#google-booking`) show as "Booked". A cancelled date is struck through, and a moved date shows at its new time, marked "Moved" with its old time (and day, when that changed).
232
- - The page may be framed anywhere (`frame-ancestors *`), sends no referrer, is never indexed, and needs no cookie. It may sit in a shared cache for a minute.
237
+ - The page may be framed anywhere (`frame-ancestors *`), sends no referrer, is never indexed, and needs no cookie. Quick links may sit in a shared cache for a minute; saved screens never do.
238
+
239
+ ### Saved screens, keys and private details
240
+
241
+ **Show private calendars and room bookings** (each screen's switch, off by default) lets the screen show the private calendars it names in `cal=` and the confirmed bookings of the rooms it names in `room=` (or of rooms set to show on the calendar). It shows their titles, times, rooms, hosts and the name on each booking ("Birthday party · Rivera"). It never shows emails, phone numbers, booking notes or RSVP lists. Turning it on (or previewing it) needs both **Manage rooms and calendars** and **Manage bookings**. Anyone with the screen's link can see what it shows, so keep the link in the TV's playlist and nowhere public. A booking isn't in any calendar, so a `cal=` filter leaves bookings out.
242
+
243
+ - **Copy link** copies the screen's address. **Rotate link** makes a new key; the old address stops working at once (the TV shows "This screen isn't available" until it gets the new link).
244
+ - **Last check-in** is when the screen last fetched its events. A TV that is on checks in every 5 minutes, so an old check-in means it's off or offline.
245
+ - Wrong keys are rate-limited per network address: after 60 wrong keys in 15 minutes, further wrong keys from that address get "Too many" until the window ends. A screen's own key always opens it.
246
+ - Screens go into backups without their keys. After a restore, each screen has a new link: copy it into each TV again.
233
247
 
234
248
  ### Setting up a TV with AbleSign
235
249
 
236
250
  AbleSign (app.ablesign.tv/screens) plays a playlist on each screen; a website is one item.
237
251
 
238
- 1. In AbleSign, open **Websites → Add website** and paste the display link.
252
+ 1. In AbleSign, open **Websites → Add website** and paste the screen's link (or a quick link).
239
253
  2. Add the website to the screen's playlist with a long duration (AbleSign reloads a website every time the playlist plays it; the page refreshes itself).
240
254
  3. Put a short image before it in the playlist, so the page loads in the background.
241
255
  4. The Fire TV Stick needs Fire OS 6 or later (not Vega OS) and AbleSign 2.2.2 or later.
@@ -253,4 +267,4 @@ A theme's `theme.css` loads after the board's own styles. The simplest way to re
253
267
  .acms-display[data-theme='light'] { --display-bg: #fffaf2; --display-fg: #1d1a16; }
254
268
  ```
255
269
 
256
- Keep blocks flat: the built-in look has no accent stripes or gradients. The display leaves out the site's header, footer, head code and analytics. A site file at `src/pages/display/calendar.astro` replaces that page.
270
+ Keep blocks flat: the built-in look has no accent stripes or gradients. The display leaves out the site's header, footer, head code and analytics. A site file at `src/pages/display/calendar.astro` (or `display/s/[key].astro`) replaces that page.
package/integration.mjs CHANGED
@@ -863,6 +863,11 @@ export default function acms(options = {}) {
863
863
  // TV displays: quick links (everything in the address) and their offline worker
864
864
  ['/display/calendar', 'display/calendar.astro'],
865
865
  ['/display/sw.js', 'display/sw.js.ts'],
866
+ // saved screens: a long random key is the whole address (Admin → Calendar → Screens)
867
+ ['/display/s/[key]', 'display/s/[key].astro'],
868
+ ['/admin/calendar/screens', 'admin/calendar/screens/index.astro'],
869
+ ['/admin/calendar/screens/preview', 'admin/calendar/screens/preview.astro'],
870
+ ['/api/calendar/screens', 'api/calendar/screens.ts'],
866
871
  ['/api/v1/events', 'api/v1/events.ts'],
867
872
  ['/api/v1/calendars', 'api/v1/calendars.ts'],
868
873
  // RSVPs from apps: a date's status, then POST /bookings (room bookings there need bookings on)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.51",
3
+ "version": "0.3.52",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,481 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ DEFAULT_HOURS, DISPLAY_VIEWS, defaultDisplaySettings, displayQuery, readDisplaySettings,
4
+ type DisplaySettings, type DisplayShow, type DisplayView,
5
+ } from '../../lib/display/settings';
6
+ import '../../styles/screens-admin.css';
7
+
8
+ // Admin → Calendar → Screens. Saved screens on the left; the editor puts the
9
+ // settings beside a live 16:9 preview (the board the TV will show, from
10
+ // settings not saved yet). The same editor builds quick links, and any quick
11
+ // link can be saved as a screen. Data: /api/calendar/screens.
12
+
13
+ interface ScreenRow {
14
+ id: string; name: string; params: string; locale: string; privateDetails: boolean;
15
+ key: string; url: string; lastSeenAt: number | null; createdAt: number; updatedAt: number;
16
+ }
17
+ interface State {
18
+ now: number;
19
+ origin: string;
20
+ /** may see booker names (Manage bookings): private details need it */
21
+ canPrivate: boolean;
22
+ weekStart: 0 | 1;
23
+ timezone: string;
24
+ languages: { code: string; label: string; main: boolean }[];
25
+ screens: ScreenRow[];
26
+ calendars: { slug: string; name: string; color: string; visibility: string; kind: string }[];
27
+ rooms: { slug: string; name: string; kind: string; color: string; public: boolean }[];
28
+ tags: string[];
29
+ }
30
+ interface Draft { name: string; settings: DisplaySettings; locale: string; privateDetails: boolean }
31
+ type Mode = { kind: 'screen'; id: string } | { kind: 'new' } | { kind: 'quick' };
32
+
33
+ const ENDPOINT = '/api/calendar/screens';
34
+ const PRIVATE_WARNING = 'Shows their titles and the name on each booking. Never emails, phone numbers or notes. Anyone with this screen’s link can see them.';
35
+ const VIEW_LABEL: Record<DisplayView, string> = { week: 'Week', day: 'Day', rooms: 'Rooms', month: 'Month', list: 'List', next: 'Now & next' };
36
+ const SHOW_LABEL: Record<DisplayShow, string> = { time: 'Times', room: 'Rooms', host: 'Hosts', clock: 'Clock', legend: 'Colour key', qr: 'QR code for phones' };
37
+ const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
38
+ const STALE_AFTER = 12 * 60_000; // a TV fetches every 5 minutes
39
+
40
+ async function api<T>(body?: Record<string, unknown>): Promise<T> {
41
+ const res = await fetch(ENDPOINT, {
42
+ method: body ? 'POST' : 'GET', credentials: 'same-origin',
43
+ headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
44
+ body: body ? JSON.stringify(body) : undefined,
45
+ });
46
+ const data = await res.json().catch(() => null);
47
+ if (!res.ok) throw new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
48
+ return data as T;
49
+ }
50
+ const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
51
+
52
+ const hourLabel = (min: number) => {
53
+ if (min === 1440) return 'Midnight';
54
+ return new Date(Date.UTC(2000, 0, 3, 0, min)).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', timeZone: 'UTC' });
55
+ };
56
+ const HOUR_STEPS = Array.from({ length: 49 }, (_, i) => i * 30);
57
+
58
+ function ago(ms: number, now: number) {
59
+ const s = Math.max(0, Math.round((now - ms) / 1000));
60
+ if (s < 90) return 'just now';
61
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
62
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
63
+ return new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' });
64
+ }
65
+
66
+ function summary(s: DisplaySettings, st: State): string {
67
+ const bits: string[] = [VIEW_LABEL[s.view]];
68
+ if (['week', 'day', 'rooms'].includes(s.view)) {
69
+ bits.push(s.hours === 'auto' ? 'hours fit the events' : s.hours.from === 0 && s.hours.to === 1440 ? 'all hours' : `${hourLabel(s.hours.from)}–${hourLabel(s.hours.to)}`);
70
+ }
71
+ const names = (slugs: string[], list: { slug: string; name: string }[]) => slugs.map((x) => list.find((i) => i.slug === x)?.name ?? x).join(', ');
72
+ if (s.filter.cal.length) bits.push(names(s.filter.cal, st.calendars));
73
+ if (s.filter.room.length) bits.push(names(s.filter.room, st.rooms));
74
+ if (s.filter.tag.length) bits.push(`tagged ${s.filter.tag.join(', ')}`);
75
+ if (s.filter.notag.length) bits.push(`not ${s.filter.notag.join(', ')}`);
76
+ return bits.join(' · ');
77
+ }
78
+
79
+ const draftOf = (row: ScreenRow): Draft => ({ name: row.name, settings: readDisplaySettings(new URLSearchParams(row.params)), locale: row.locale, privateDetails: row.privateDetails });
80
+ const sameDraft = (a: Draft, b: Draft) => a.name === b.name && a.locale === b.locale && a.privateDetails === b.privateDetails && displayQuery(a.settings) === displayQuery(b.settings);
81
+ const blankDraft = (): Draft => ({ name: '', settings: defaultDisplaySettings(), locale: '', privateDetails: false });
82
+
83
+ export default function ScreensManager() {
84
+ const [state, setState] = useState<State | null>(null);
85
+ const [loadError, setLoadError] = useState('');
86
+ const [mode, setMode] = useState<Mode | null>(null);
87
+ const [draft, setDraft] = useState<Draft>(blankDraft);
88
+ const [busy, setBusy] = useState('');
89
+ const [msg, setMsg] = useState<{ ok?: string; err?: string }>({});
90
+
91
+ useEffect(() => { api<State>().then(setState, (e) => setLoadError(errText(e))); }, []);
92
+ // the check-ins move while the page is open
93
+ useEffect(() => {
94
+ const t = setInterval(() => api<State>().then((s) => setState((old) => (old ? { ...old, now: s.now, screens: old.screens.map((r) => ({ ...r, lastSeenAt: s.screens.find((x) => x.id === r.id)?.lastSeenAt ?? r.lastSeenAt })) } : s)), () => {}), 60_000);
95
+ return () => clearInterval(t);
96
+ }, []);
97
+
98
+ const current = state && mode?.kind === 'screen' ? state.screens.find((s) => s.id === mode.id) ?? null : null;
99
+ const dirty = mode?.kind === 'new' || (current ? !sameDraft(draft, draftOf(current)) : false);
100
+
101
+ const open = (m: Mode, d?: Draft) => {
102
+ if (dirty && !confirm('Leave this screen without saving your changes?')) return;
103
+ setMsg({});
104
+ setMode(m);
105
+ setDraft(d ?? (m.kind === 'screen' && state ? draftOf(state.screens.find((s) => s.id === m.id)!) : blankDraft()));
106
+ };
107
+
108
+ const run = async (body: Record<string, unknown>, label: string, after?: (r: { screen?: ScreenRow; state: State }) => void) => {
109
+ setBusy(label); setMsg({});
110
+ try {
111
+ const r = await api<{ screen?: ScreenRow; state: State }>(body);
112
+ setState(r.state);
113
+ after?.(r);
114
+ } catch (e) { setMsg({ err: errText(e) }); } finally { setBusy(''); }
115
+ };
116
+
117
+ const save = () => {
118
+ const body = { name: draft.name, params: displayQuery(draft.settings), locale: draft.locale, privateDetails: draft.privateDetails };
119
+ if (mode?.kind === 'screen') run({ action: 'save', id: mode.id, ...body }, 'save', (r) => { if (r.screen) { setDraft(draftOf({ ...r.screen, url: '' } as ScreenRow)); setMsg({ ok: 'Saved. The TV shows it within 5 minutes.' }); } });
120
+ else run({ action: 'create', ...body }, 'save', (r) => { if (r.screen) { setMode({ kind: 'screen', id: r.screen.id }); setDraft(draftOf({ ...r.screen, url: '' } as ScreenRow)); setMsg({ ok: 'Screen saved. Copy its link into AbleSign.' }); } });
121
+ };
122
+ const rotate = () => {
123
+ if (mode?.kind !== 'screen' || !confirm('Make a new link for this screen? The old one stops working at once, so update the TV in AbleSign with the new link.')) return;
124
+ run({ action: 'rotate', id: mode.id }, 'rotate', () => setMsg({ ok: 'New link made. The old one no longer works.' }));
125
+ };
126
+ const remove = () => {
127
+ if (mode?.kind !== 'screen' || !confirm(`Delete “${current?.name}”? Its link stops working at once.`)) return;
128
+ run({ action: 'delete', id: mode.id }, 'delete', () => { setMode(null); setMsg({ ok: 'Screen deleted.' }); });
129
+ };
130
+
131
+ if (loadError) return <p className="flash error">{loadError}</p>;
132
+ if (!state) return <div className="card card-pad sc-skel" aria-busy="true">Loading screens…</div>;
133
+
134
+ return (
135
+ <div className="sc">
136
+ <section className="card sc-list" aria-label="Saved screens">
137
+ <div className="sc-list-head">
138
+ <b>Screens</b>
139
+ <div className="sc-list-actions">
140
+ <button type="button" className="btn sm primary" onClick={() => open({ kind: 'new' })}>New screen</button>
141
+ <button type="button" className="btn sm" onClick={() => open({ kind: 'quick' })}>Quick link</button>
142
+ </div>
143
+ </div>
144
+ {state.screens.length === 0 && (
145
+ <div className="empty-state sc-empty">
146
+ <b>No screens yet</b>
147
+ <p>A screen is a calendar for a TV: pick what it shows, then put its link in the TV’s playlist.</p>
148
+ </div>
149
+ )}
150
+ <ul className="sc-rows">
151
+ {state.screens.map((row) => {
152
+ const s = readDisplaySettings(new URLSearchParams(row.params));
153
+ const seen = row.lastSeenAt;
154
+ const stale = seen !== null && state.now - seen > STALE_AFTER;
155
+ const active = mode?.kind === 'screen' && mode.id === row.id;
156
+ return (
157
+ <li key={row.id}>
158
+ <button type="button" className={`sc-row${active ? ' on' : ''}`} aria-current={active || undefined} onClick={() => open({ kind: 'screen', id: row.id })}>
159
+ <span className="sc-row-name">{row.name}{row.privateDetails && <span className="pill sc-private">Private details</span>}</span>
160
+ <span className="sc-row-sum">{summary(s, state)}</span>
161
+ <span className={`sc-seen${seen === null ? '' : stale ? ' stale' : ' ok'}`}>
162
+ {seen === null ? 'Not opened on a TV yet' : stale ? `Last check-in ${ago(seen, state.now)}. Is the TV on?` : `Checked in ${ago(seen, state.now)}`}
163
+ </span>
164
+ </button>
165
+ </li>
166
+ );
167
+ })}
168
+ </ul>
169
+ </section>
170
+
171
+ {mode ? (
172
+ <Editor
173
+ key={mode.kind === 'screen' ? mode.id : mode.kind}
174
+ state={state} mode={mode} current={current} draft={draft} setDraft={setDraft} dirty={dirty} busy={busy} msg={msg}
175
+ onSave={save} onRotate={rotate} onDelete={remove}
176
+ onSaveQuick={() => open({ kind: 'new' }, { ...draft, name: '' })}
177
+ onCopied={(what) => setMsg({ ok: `${what} copied.` })}
178
+ />
179
+ ) : (
180
+ <section className="card card-pad sc-intro">
181
+ <h2>TV screens</h2>
182
+ <p>Each screen is a full-screen calendar for a TV: week, day, rooms, month, list or now &amp; next, with your own hours. Everything about it lives in its link, so the TV needs no mouse or keyboard, and a change you make here reaches the TV within 5 minutes.</p>
183
+ <ul className="sc-steps">
184
+ <li><b>Saved screens</b> have a long secret link. They can also show private calendars and room bookings.</li>
185
+ <li><b>Quick links</b> spell out their settings in the address and only show public events.</li>
186
+ </ul>
187
+ <p className="hint">On a Fire TV Stick with AbleSign: Websites → Add website, paste the link, add it to the screen’s playlist with a long duration, and put a short image before it so the page loads in the background. The stick needs Fire OS 6 or later (not Vega OS) and AbleSign 2.2.2 or later.</p>
188
+ </section>
189
+ )}
190
+ </div>
191
+ );
192
+ }
193
+
194
+ interface EditorProps {
195
+ state: State;
196
+ mode: Mode;
197
+ current: ScreenRow | null;
198
+ draft: Draft;
199
+ setDraft: (d: Draft) => void;
200
+ dirty: boolean;
201
+ busy: string;
202
+ msg: { ok?: string; err?: string };
203
+ onSave: () => void;
204
+ onRotate: () => void;
205
+ onDelete: () => void;
206
+ onSaveQuick: () => void;
207
+ onCopied: (what: string) => void;
208
+ }
209
+
210
+ function Editor({ state, mode, current, draft, setDraft, dirty, busy, msg, onSave, onRotate, onDelete, onSaveQuick, onCopied }: EditorProps) {
211
+ const s = draft.settings;
212
+ const quick = mode.kind === 'quick';
213
+ const priv = !quick && draft.privateDetails;
214
+ const set = (patch: Partial<DisplaySettings>) => setDraft({ ...draft, settings: { ...s, ...patch } });
215
+ const setFilter = (k: keyof DisplaySettings['filter'], values: string[]) => set({ filter: { ...s.filter, [k]: values } });
216
+ const toggle = (list: string[], v: string) => (list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
217
+ const grid = s.view === 'week' || s.view === 'day' || s.view === 'rooms';
218
+ const main = state.languages.find((l) => l.main)?.code ?? 'en';
219
+ const lang = draft.locale || main;
220
+
221
+ const query = displayQuery(s);
222
+ const quickUrl = `${state.origin}${lang !== main ? `/${lang}` : ''}/display/calendar${query ? `?${query}` : ''}`;
223
+ const previewSrc = `/admin/calendar/screens/preview?${[query, lang !== main ? `lang=${lang}` : '', priv ? 'private=1' : ''].filter(Boolean).join('&')}`;
224
+
225
+ const [paste, setPaste] = useState('');
226
+ const fromLink = () => {
227
+ try {
228
+ const u = new URL(paste.trim(), state.origin);
229
+ const es = state.languages.find((l) => !l.main && u.pathname.startsWith(`/${l.code}/`));
230
+ setDraft({ ...draft, settings: readDisplaySettings(u.searchParams), locale: es?.code ?? '' });
231
+ setPaste('');
232
+ } catch { /* not a link: leave it for the person to fix */ }
233
+ };
234
+
235
+ const copy = async (text: string, what: string) => {
236
+ try { await navigator.clipboard.writeText(text); onCopied(what); } catch { prompt('Copy this link:', text); }
237
+ };
238
+
239
+ const daysValue = 'count' in s.days ? String(s.days.count) : 'custom';
240
+ const weekdays = 'weekdays' in s.days ? s.days.weekdays : [];
241
+ const hoursMode = s.hours === 'auto' ? 'auto' : s.hours.from === 0 && s.hours.to === 1440 ? 'all' : 'range';
242
+ const range = s.hours === 'auto' ? DEFAULT_HOURS : s.hours;
243
+ const visibleCals = state.calendars.filter((c) => priv || c.visibility !== 'private');
244
+ const privateCals = state.calendars.filter((c) => c.visibility === 'private');
245
+ const tags = state.tags.filter((t) => priv || !t.startsWith('#'));
246
+
247
+ return (
248
+ <section className="card sc-editor" aria-label={quick ? 'Quick link' : 'Screen'}>
249
+ <div className="sc-editor-grid">
250
+ <div className="sc-options">
251
+ {msg.err && <p className="flash error" role="alert">{msg.err}</p>}
252
+ {msg.ok && <p className="flash" role="status">{msg.ok}</p>}
253
+ {quick ? (
254
+ <>
255
+ <h2 className="sc-h">Quick link</h2>
256
+ <p className="hint">The settings are in the link itself. Nothing is saved, and it only shows public events.</p>
257
+ <label className="field"><span>Start from a link (optional)</span>
258
+ <div className="sc-inline"><input type="url" value={paste} placeholder={`${state.origin}/display/calendar?view=week`} onChange={(e) => setPaste(e.target.value)} />
259
+ <button type="button" className="btn sm" disabled={!paste.trim()} onClick={fromLink}>Use</button></div>
260
+ </label>
261
+ </>
262
+ ) : (
263
+ <label className="field"><span>Name</span>
264
+ <input type="text" value={draft.name} maxLength={120} placeholder="Lobby TV" onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
265
+ </label>
266
+ )}
267
+
268
+ <fieldset className="sc-fs"><legend>View</legend>
269
+ <div className="seg sc-seg">
270
+ {DISPLAY_VIEWS.map((v) => <button key={v} type="button" aria-pressed={s.view === v} onClick={() => set({ view: v })}>{VIEW_LABEL[v]}</button>)}
271
+ </div>
272
+ </fieldset>
273
+
274
+ {grid && (
275
+ <fieldset className="sc-fs"><legend>Hours</legend>
276
+ <label className="check-row"><input type="checkbox" checked={hoursMode === 'all'} onChange={(e) => set({ hours: e.target.checked ? { from: 0, to: 1440 } : { ...DEFAULT_HOURS } })} /> Show all hours</label>
277
+ <label className="check-row"><input type="checkbox" checked={hoursMode === 'auto'} onChange={(e) => set({ hours: e.target.checked ? 'auto' : { ...DEFAULT_HOURS } })} /> Fit the events shown (at least 6 hours)</label>
278
+ {hoursMode === 'range' && (
279
+ <div className="field-row">
280
+ <label className="field"><span>From</span>
281
+ <select value={range.from} onChange={(e) => { const from = +e.target.value; set({ hours: { from, to: Math.max(range.to, from + 60) } }); }}>
282
+ {HOUR_STEPS.filter((m) => m <= 1380).map((m) => <option key={m} value={m}>{hourLabel(m)}</option>)}
283
+ </select>
284
+ </label>
285
+ <label className="field"><span>To</span>
286
+ <select value={range.to} onChange={(e) => set({ hours: { from: range.from, to: +e.target.value } })}>
287
+ {HOUR_STEPS.filter((m) => m >= range.from + 60).map((m) => <option key={m} value={m}>{hourLabel(m)}</option>)}
288
+ </select>
289
+ </label>
290
+ </div>
291
+ )}
292
+ <p className="hint">Events outside these hours are cut and marked, never left out.</p>
293
+ </fieldset>
294
+ )}
295
+
296
+ {(s.view === 'week' || s.view === 'list' || s.view === 'month') && (
297
+ <fieldset className="sc-fs"><legend>{s.view === 'list' ? 'Days the list covers' : 'Days'}</legend>
298
+ <div className="field-row">
299
+ <label className="field"><span>{s.view === 'list' ? 'How many' : 'Which days'}</span>
300
+ <select value={daysValue} onChange={(e) => set({ days: e.target.value === 'custom' ? { weekdays: [1, 2, 3, 4, 5] } : { count: +e.target.value } })}>
301
+ {(s.view === 'list' ? [1, 3, 7, 14, 31] : [7, 5, 3]).map((n) => <option key={n} value={n}>{n === 7 && s.view !== 'list' ? 'All 7 days' : `${n} day${n > 1 ? 's' : ''}`}</option>)}
302
+ <option value="custom">Some weekdays…</option>
303
+ </select>
304
+ </label>
305
+ {s.view !== 'list' && (
306
+ <label className="field"><span>First day</span>
307
+ <select value={s.start} onChange={(e) => set({ start: e.target.value as DisplaySettings['start'] })}>
308
+ <option value="">Site setting ({state.weekStart ? 'Monday' : 'Sunday'})</option>
309
+ <option value="sun">Sunday</option>
310
+ <option value="mon">Monday</option>
311
+ {s.view === 'week' && <option value="today">Today (a rolling week)</option>}
312
+ </select>
313
+ </label>
314
+ )}
315
+ </div>
316
+ {daysValue === 'custom' && (
317
+ <div className="sc-chips" role="group" aria-label="Weekdays">
318
+ {WEEKDAYS.map((d, i) => (
319
+ <label key={d} className="sc-chip"><input type="checkbox" checked={weekdays.includes(i)}
320
+ onChange={() => { const next = weekdays.includes(i) ? weekdays.filter((x) => x !== i) : [...weekdays, i].sort(); set({ days: next.length ? { weekdays: next } : { count: 7 } }); }} /> {d}</label>
321
+ ))}
322
+ </div>
323
+ )}
324
+ </fieldset>
325
+ )}
326
+
327
+ <fieldset className="sc-fs"><legend>Calendars</legend>
328
+ <p className="hint">None ticked: every public calendar.</p>
329
+ <div className="sc-checks">
330
+ {visibleCals.map((c) => (
331
+ <label key={c.slug} className="check-row"><input type="checkbox" checked={s.filter.cal.includes(c.slug)} onChange={() => setFilter('cal', toggle(s.filter.cal, c.slug))} />
332
+ <span className="sc-swatch" style={{ background: c.color }} /> {c.name}{c.visibility !== 'public' && <span className="hint"> · {c.visibility}</span>}</label>
333
+ ))}
334
+ </div>
335
+ {!priv && privateCals.length > 0 && <p className="hint">{quick ? 'Private calendars need a saved screen with private details on.' : 'Turn on private details below to pick private calendars.'}</p>}
336
+ </fieldset>
337
+
338
+ {state.rooms.length > 0 && (
339
+ <fieldset className="sc-fs"><legend>Rooms</legend>
340
+ <p className="hint">{s.view === 'rooms' ? 'One column per room, in the order you tick them. None ticked: every room.' : 'Only events in these rooms. None ticked: every room.'}</p>
341
+ <div className="sc-checks">
342
+ {state.rooms.map((r) => {
343
+ const usable = priv || r.public;
344
+ const at = s.filter.room.indexOf(r.slug);
345
+ return (
346
+ <label key={r.slug} className={`check-row${usable ? '' : ' sc-off'}`}>
347
+ <input type="checkbox" checked={at >= 0} disabled={!usable && at < 0} onChange={() => setFilter('room', toggle(s.filter.room, r.slug))} />
348
+ {r.color && <span className="sc-swatch" style={{ background: r.color }} />} {r.name}
349
+ {at >= 0 && s.view === 'rooms' && <span className="sc-order">{at + 1}</span>}
350
+ {!usable && <span className="hint"> · not named on public events</span>}
351
+ </label>
352
+ );
353
+ })}
354
+ </div>
355
+ </fieldset>
356
+ )}
357
+
358
+ {tags.length > 0 && (
359
+ <fieldset className="sc-fs"><legend>Tags</legend>
360
+ <div className="field-row">
361
+ <TagPicker label="Only events tagged" tags={tags} value={s.filter.tag} onChange={(v) => setFilter('tag', v)} />
362
+ <TagPicker label="Leave out events tagged" tags={tags} value={s.filter.notag} onChange={(v) => setFilter('notag', v)} />
363
+ </div>
364
+ </fieldset>
365
+ )}
366
+
367
+ <fieldset className="sc-fs"><legend>Look</legend>
368
+ <div className="sc-look">
369
+ <div className="seg sc-seg" role="group" aria-label="Theme">
370
+ {(['dark', 'light'] as const).map((t) => <button key={t} type="button" aria-pressed={s.theme === t} onClick={() => set({ theme: t })}>{t === 'dark' ? 'Dark' : 'Light'}</button>)}
371
+ </div>
372
+ <div className="seg sc-seg" role="group" aria-label="Text size">
373
+ {(['s', 'm', 'l', 'xl'] as const).map((z) => <button key={z} type="button" aria-pressed={s.size === z} onClick={() => set({ size: z })}>{z.toUpperCase()}</button>)}
374
+ </div>
375
+ </div>
376
+ <div className="sc-chips" role="group" aria-label="Show">
377
+ {(Object.keys(SHOW_LABEL) as DisplayShow[]).map((k) => (
378
+ <label key={k} className="sc-chip"><input type="checkbox" checked={s.show.includes(k)} onChange={() => set({ show: s.show.includes(k) ? s.show.filter((x) => x !== k) : [...s.show, k] })} /> {SHOW_LABEL[k]}</label>
379
+ ))}
380
+ </div>
381
+ {s.show.includes('host') && !priv && <p className="hint">Hosts only show on a saved screen with private details on.</p>}
382
+ <div className="field-row">
383
+ <label className="field"><span>Title (optional)</span>
384
+ <input type="text" value={s.title} maxLength={80} placeholder={s.view === 'month' ? 'The month' : s.view === 'week' ? 'This week' : 'Today'} onChange={(e) => set({ title: e.target.value })} />
385
+ </label>
386
+ {state.languages.length > 1 && (
387
+ <label className="field"><span>Language</span>
388
+ <select value={lang} onChange={(e) => setDraft({ ...draft, locale: e.target.value === main ? '' : e.target.value })}>
389
+ {state.languages.map((l) => <option key={l.code} value={l.code}>{l.label}</option>)}
390
+ </select>
391
+ </label>
392
+ )}
393
+ </div>
394
+ </fieldset>
395
+
396
+ {!quick && (
397
+ <fieldset className="sc-fs sc-private"><legend>Private details</legend>
398
+ <label className="check-row"><input type="checkbox" role="switch" checked={draft.privateDetails} disabled={!state.canPrivate && !draft.privateDetails}
399
+ onChange={(e) => setDraft({ ...draft, privateDetails: e.target.checked })} /> <b>Show private calendars and room bookings</b></label>
400
+ <p className="hint">{PRIVATE_WARNING}</p>
401
+ {!state.canPrivate && <p className="hint">Turning this on needs the “Manage bookings” permission (Team → Roles).</p>}
402
+ </fieldset>
403
+ )}
404
+ </div>
405
+
406
+ <div className="sc-side">
407
+ <Preview src={previewSrc} />
408
+ {quick ? (
409
+ <div className="sc-link">
410
+ <label className="field"><span>Quick link</span><input type="text" readOnly value={quickUrl} onFocus={(e) => e.currentTarget.select()} /></label>
411
+ <div className="form-actions">
412
+ <button type="button" className="btn" onClick={() => copy(quickUrl, 'Link')}>Copy link</button>
413
+ <button type="button" className="btn primary" onClick={onSaveQuick}>Save as screen</button>
414
+ </div>
415
+ </div>
416
+ ) : (
417
+ <div className="sc-link">
418
+ {current?.url ? (
419
+ <>
420
+ <label className="field"><span>Screen link</span><input type="text" readOnly value={current.url} onFocus={(e) => e.currentTarget.select()} /></label>
421
+ <div className="form-actions">
422
+ <button type="button" className="btn" onClick={() => copy(current.url, 'Link')}>Copy link</button>
423
+ <button type="button" className="btn" disabled={busy === 'rotate'} onClick={onRotate}>Rotate link</button>
424
+ </div>
425
+ <p className="hint">The link is the screen’s only key: keep it in the TV’s playlist and nowhere public. Rotate it if it gets out; the old one stops at once.</p>
426
+ </>
427
+ ) : mode.kind === 'screen' ? (
428
+ <><p className="hint">This screen has no link yet (restored from a backup). Make one, then put it on the TV.</p>
429
+ <button type="button" className="btn" disabled={busy === 'rotate'} onClick={onRotate}>Make a link</button></>
430
+ ) : <p className="hint">Save the screen to get its link.</p>}
431
+ </div>
432
+ )}
433
+ {!quick && (
434
+ <div className="form-actions sc-save">
435
+ <button type="button" className="btn primary" disabled={!dirty || !draft.name.trim() || busy === 'save'} onClick={onSave}>{busy === 'save' ? 'Saving…' : mode.kind === 'new' ? 'Save screen' : 'Save changes'}</button>
436
+ {mode.kind === 'screen' && <button type="button" className="btn danger" disabled={busy === 'delete'} onClick={onDelete}>Delete</button>}
437
+ </div>
438
+ )}
439
+ </div>
440
+ </div>
441
+ </section>
442
+ );
443
+ }
444
+
445
+ function TagPicker({ label, tags, value, onChange }: { label: string; tags: string[]; value: string[]; onChange: (v: string[]) => void }) {
446
+ return (
447
+ <div className="field sc-tags">
448
+ <span className="sc-tags-label">{label}</span>
449
+ <div className="sc-checks sc-tag-list">
450
+ {tags.map((t) => (
451
+ <label key={t} className="check-row"><input type="checkbox" checked={value.some((v) => v.toLowerCase() === t.toLowerCase())}
452
+ onChange={(e) => onChange(e.target.checked ? [...value, t] : value.filter((v) => v.toLowerCase() !== t.toLowerCase()))} /> {t}</label>
453
+ ))}
454
+ </div>
455
+ </div>
456
+ );
457
+ }
458
+
459
+ // the board at the TV's 960×540, scaled to the column (debounced: the frame
460
+ // reloads once the settings stop changing)
461
+ function Preview({ src }: { src: string }) {
462
+ const box = useRef<HTMLDivElement>(null);
463
+ const [scale, setScale] = useState(0.5);
464
+ const [shown, setShown] = useState(src);
465
+ useEffect(() => { const t = setTimeout(() => setShown(src), 350); return () => clearTimeout(t); }, [src]);
466
+ useEffect(() => {
467
+ const el = box.current;
468
+ if (!el) return;
469
+ const fit = () => setScale(el.clientWidth / 960);
470
+ fit();
471
+ const ro = new ResizeObserver(fit);
472
+ ro.observe(el);
473
+ return () => ro.disconnect();
474
+ }, []);
475
+ const style = useMemo(() => ({ transform: `scale(${scale})` }), [scale]);
476
+ return (
477
+ <div className="sc-preview" ref={box} aria-label="Preview">
478
+ <iframe title="Screen preview" src={shown} width={960} height={540} tabIndex={-1} style={style} />
479
+ </div>
480
+ );
481
+ }