@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,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 & 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
|
+
}
|
package/src/layouts/Admin.astro
CHANGED
|
@@ -128,6 +128,8 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
128
128
|
...(CALENDAR.enabled && can('schedule.bookings')
|
|
129
129
|
? [{ key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 }]
|
|
130
130
|
: []),
|
|
131
|
+
// TV screens: saved display links (lib/display), with private details behind their key
|
|
132
|
+
...(can('schedule.manage') ? [{ key: 'calendar:screens', label: 'Screens', href: '/admin/calendar/screens', icon: icons.monitor, count: null }] : []),
|
|
131
133
|
],
|
|
132
134
|
}]
|
|
133
135
|
: []),
|
|
@@ -9,6 +9,13 @@ import type { ContentRepository } from '../repo/types';
|
|
|
9
9
|
const legacyApproval = (l: unknown) =>
|
|
10
10
|
l && typeof l === 'object' && !('approval' in l) && (l as { autoConfirm?: boolean }).autoConfirm === false ? { ...l, approval: 'manual' } : l;
|
|
11
11
|
|
|
12
|
+
// a saved TV screen (lib/display/store.ts). Its key never goes into a
|
|
13
|
+
// backup: a restored screen gets a new one
|
|
14
|
+
export const ScreenRecord = z.object({
|
|
15
|
+
id: z.string().min(1), name: z.string().max(200).default(''), params: z.string().max(4000).default(''), locale: z.string().max(20).default(''),
|
|
16
|
+
privateDetails: z.boolean().default(false), key: z.string().default(''), lastSeenAt: z.number().nullable().default(null),
|
|
17
|
+
order: z.number().default(0), createdAt: z.number(), updatedAt: z.number(),
|
|
18
|
+
});
|
|
12
19
|
export const BookingRequest = z.object({ keyHash: z.string().regex(/^[a-f0-9]{64}$/), requestHash: z.string().regex(/^[a-f0-9]{64}$/), bookingId: z.string().min(1), createdAt: z.number().int().nonnegative() });
|
|
13
20
|
export type BookingRequest = z.infer<typeof BookingRequest>;
|
|
14
21
|
export const BackupContent = z.object({
|
|
@@ -21,6 +28,7 @@ export const BackupContent = z.object({
|
|
|
21
28
|
forms: z.array(SavedForm).optional(), formSubmissions: z.array(FormSubmission).optional(),
|
|
22
29
|
// optional (no version bump): older cores restore such a backup and skip them
|
|
23
30
|
announcements: z.array(AnnouncementRecord).optional(),
|
|
31
|
+
screens: z.array(ScreenRecord).optional(),
|
|
24
32
|
}).superRefine((data, ctx) => {
|
|
25
33
|
if (data.version >= 6 && (!data.forms || !data.formSubmissions)) ctx.addIssue({ code: 'custom', message: 'Version 6 requires forms and their submissions.' });
|
|
26
34
|
if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
|
|
@@ -39,12 +47,13 @@ export async function exportContent(repo: ContentRepository, now = Date.now()) {
|
|
|
39
47
|
repo.listUsers(), repo.listRedirects(), repo.listCalendars(), repo.listEvents(), repo.listResources(), repo.listBookingLinks(), repo.exportBookingState(),
|
|
40
48
|
repo.listPlaces(), repo.forms.list(), repo.forms.submissions(undefined, -1),
|
|
41
49
|
]);
|
|
42
|
-
const announcements = await repo.announcements.list(-1);
|
|
50
|
+
const [announcements, screens] = await Promise.all([repo.announcements.list(-1), repo.screens.list()]);
|
|
43
51
|
return {
|
|
44
52
|
format: 'acms-backup' as const, version: 6, exportedAt: new Date(now).toISOString(),
|
|
45
53
|
settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false }, monitoring: { ...settings.monitoring, pushUrl: '' } },
|
|
46
54
|
pages, posts, media, links, tags, navItems, users, redirects,
|
|
47
55
|
calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
|
|
48
56
|
resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions, announcements,
|
|
57
|
+
screens: screens.map(s => ({ ...s, key: '', lastSeenAt: null })),
|
|
49
58
|
};
|
|
50
59
|
}
|
package/src/lib/calendar/api.ts
CHANGED
|
@@ -59,8 +59,16 @@ export function roomsToJson(ids: string[], resources: Map<string, Resource> | un
|
|
|
59
59
|
|
|
60
60
|
const abs = (origin: string, path: string) => (/^https?:\/\//.test(path) ? path : origin + path);
|
|
61
61
|
|
|
62
|
-
//
|
|
63
|
-
|
|
62
|
+
// a repeat's "until" date in that language ("12 jun 2027")
|
|
63
|
+
const untilDate = (locale: string) => (ymd: string) => {
|
|
64
|
+
const [y, m, d] = ymd.split('-').map(Number);
|
|
65
|
+
try { return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); }
|
|
66
|
+
catch { return ymd; }
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// resources: every room by id (rooms stay [] without it); locale: the
|
|
70
|
+
// language of the repeat line ("Cada semana el sábado"; English without it)
|
|
71
|
+
export function eventToJson(e: CalEvent, origin: string, calendars: Map<string, Calendar>, siteTz = '', opts: { includePrivate?: boolean; resources?: Map<string, Resource>; locale?: string } = {}): EventJson {
|
|
64
72
|
return {
|
|
65
73
|
id: e.id,
|
|
66
74
|
slug: e.slug,
|
|
@@ -76,7 +84,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
|
|
|
76
84
|
allDay: e.allDay,
|
|
77
85
|
timezone: e.timezone || siteTz || 'UTC',
|
|
78
86
|
rrule: e.rrule,
|
|
79
|
-
repeats: describeRepeat(e.rrule, e.start),
|
|
87
|
+
repeats: describeRepeat(e.rrule, e.start, opts.locale ? untilDate(opts.locale) : undefined, opts.locale),
|
|
80
88
|
exdates: e.exdates,
|
|
81
89
|
cancelledDates: e.cancelledDates ?? [],
|
|
82
90
|
movedDates: movedDatesToJson(e, opts.includePrivate),
|
|
@@ -8,7 +8,7 @@ import { eventStrings } from './event-words';
|
|
|
8
8
|
import { eventImage } from './images';
|
|
9
9
|
import { eventUrl } from './config';
|
|
10
10
|
import { eventIsPublic, featuredOccurrence, formatOccurrence, locationLine, type Occurrence } from './events';
|
|
11
|
-
import { collectViewEvents } from './data';
|
|
11
|
+
import { collectViewEvents, type ViewEvent } from './data';
|
|
12
12
|
import type { ResolvedEventFilter } from './filter';
|
|
13
13
|
|
|
14
14
|
export interface EventCardAttrs {
|
|
@@ -80,11 +80,11 @@ export async function upcomingEvents(
|
|
|
80
80
|
displayTz: tz, now, windowStartMs: now, windowEndMs: now + (opts.horizonDays ?? 400) * 86_400_000,
|
|
81
81
|
}, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false, filter: opts.filter, locale: opts.locale });
|
|
82
82
|
const seen = new Set<string>();
|
|
83
|
-
const out: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved'>)[] = [];
|
|
83
|
+
const out: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved'> & Pick<ViewEvent, 'rooms'>)[] = [];
|
|
84
84
|
for (const v of events) {
|
|
85
85
|
if (!v.event || v.end <= now || seen.has(v.event.id) || !tagged(v.event.tags)) continue;
|
|
86
86
|
seen.add(v.event.id);
|
|
87
|
-
out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: Boolean(v.cancelled), ...(v.moved ? { moved: v.moved } : {}) });
|
|
87
|
+
out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: Boolean(v.cancelled), ...(v.moved ? { moved: v.moved } : {}), rooms: v.rooms ?? [] });
|
|
88
88
|
if (out.length >= opts.limit) break;
|
|
89
89
|
}
|
|
90
90
|
return out;
|
package/src/lib/display/board.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { addDays, dayOfWeek, monthWeeks, startOfWeek, type Iso } from '../calend
|
|
|
15
15
|
import { siteLang, siteLocale } from '../dates';
|
|
16
16
|
import { DEFAULT_HOURS, MIN_AUTO_HOURS, type DisplaySettings, type HourRange } from './settings';
|
|
17
17
|
import { renderBoard, type DisplayItem, type DisplayModel, type DisplayRoom } from './render';
|
|
18
|
-
import { displayStrings } from './words';
|
|
18
|
+
import { displayStrings, isBookedWord } from './words';
|
|
19
19
|
import { qrSvg } from './http';
|
|
20
20
|
|
|
21
21
|
export const GOOGLE_BOOKING_TAG = '#google-booking';
|
|
@@ -200,8 +200,9 @@ function displayItem(v: ViewEvent, i: number, c: ItemContext): DisplayItem {
|
|
|
200
200
|
key: `${ev?.id ?? v.ref ?? 'x'}:${v.start}:${i}`,
|
|
201
201
|
kind: ev ? 'event' : v.rooms ? 'booking' : 'feed',
|
|
202
202
|
title: booked ? c.words.booked : v.title,
|
|
203
|
-
// under "Booked", a private screen also names the appointment
|
|
204
|
-
|
|
203
|
+
// under "Booked", a private screen also names the appointment (unless
|
|
204
|
+
// its title is just "Booked", as the Google sync makes them)
|
|
205
|
+
detail: booked && c.privateDetails && !isBookedWord(v.title) ? v.title : '',
|
|
205
206
|
start: v.start, end: v.end, allDay: v.allDay,
|
|
206
207
|
startDay, endDay,
|
|
207
208
|
startMin: v.allDay ? 0 : s.min,
|