@acms-subir/core 0.3.51 → 0.3.53
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 +33 -0
- package/cli/migrations.generated.mjs +16 -1
- package/docs/calendar.md +24 -10
- package/docs/mobile.md +1 -1
- package/integration.mjs +5 -0
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
- package/package.json +1 -1
- package/src/components/ThemeSections.astro +2 -2
- package/src/components/display/ScreensManager.tsx +481 -0
- package/src/components/editor/PostEditor.tsx +2 -0
- package/src/components/editor/event/EventSheet.tsx +6 -2
- package/src/components/editor/event/RegistrationCard.tsx +3 -3
- package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
- package/src/components/forms/Canvas.tsx +31 -19
- package/src/components/forms/FormBuildPage.tsx +12 -6
- package/src/components/forms/FormBuilder.tsx +60 -11
- package/src/components/forms/Inspector.tsx +116 -8
- package/src/components/forms/TypeIcon.tsx +1 -0
- package/src/components/forms/meta.ts +71 -9
- package/src/layouts/Admin.astro +2 -0
- package/src/lib/backup/content.ts +10 -1
- package/src/lib/calendar/api.ts +3 -1
- 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/dynamicBlocks.ts +4 -2
- package/src/lib/forms/fields.ts +14 -9
- package/src/lib/forms/localize.ts +68 -0
- package/src/lib/forms/render.ts +8 -2
- package/src/lib/forms/schema.ts +31 -2
- package/src/lib/forms/service.ts +9 -1
- package/src/lib/forms/validate.ts +16 -4
- package/src/lib/forms/words.ts +32 -0
- package/src/lib/mobile/forms.ts +45 -13
- package/src/lib/mobile/openapi.ts +3 -3
- package/src/lib/mobile/rsvp.ts +4 -2
- package/src/lib/mobile/schemas.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/pages/[...slug].astro +1 -1
- package/src/pages/admin/calendar/screens/index.astro +24 -0
- package/src/pages/admin/calendar/screens/preview.astro +36 -0
- package/src/pages/admin/forms/[id].astro +6 -0
- package/src/pages/api/calendar/screens.ts +30 -0
- package/src/pages/blog/[slug].astro +1 -1
- package/src/pages/display/s/[key].astro +48 -0
- package/src/pages/events/[slug].astro +9 -3
- package/src/pages/forms/[id].astro +22 -4
- package/src/pages/index.astro +1 -1
- package/src/styles/display.css +2 -1
- package/src/styles/forms.css +8 -0
- package/src/styles/screens-admin.css +53 -0
|
@@ -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
|
+
}
|
|
@@ -1162,6 +1162,8 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
1162
1162
|
eventId={docId}
|
|
1163
1163
|
title={meta.title}
|
|
1164
1164
|
status={meta.status}
|
|
1165
|
+
languages={languages}
|
|
1166
|
+
mainLanguage={mainLanguage}
|
|
1165
1167
|
tags={<TagInput tags={meta.tags} suggestions={tagSuggestions} onChange={(tags) => setMeta({ tags })} />}
|
|
1166
1168
|
/>
|
|
1167
1169
|
</div>
|
|
@@ -13,6 +13,7 @@ import { RegistrationCard } from './RegistrationCard';
|
|
|
13
13
|
import { WhenCard } from './WhenCard';
|
|
14
14
|
import { SingleDates } from './SingleDates';
|
|
15
15
|
import { registrationMode } from './registration';
|
|
16
|
+
import type { SiteLanguage } from '../../forms/FormBuilder';
|
|
16
17
|
|
|
17
18
|
// Every event setting, on the page above the event's own content, as one
|
|
18
19
|
// settings column: When · Where · CTA · Organize · Status, with a
|
|
@@ -31,6 +32,9 @@ interface Props {
|
|
|
31
32
|
title: string;
|
|
32
33
|
status: string;
|
|
33
34
|
tags?: ReactNode;
|
|
35
|
+
/** the site's other languages and its main one, for the registration form's words in each */
|
|
36
|
+
languages?: SiteLanguage[];
|
|
37
|
+
mainLanguage?: SiteLanguage;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
const SECTIONS = [
|
|
@@ -43,7 +47,7 @@ const SECTIONS = [
|
|
|
43
47
|
|
|
44
48
|
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
45
49
|
|
|
46
|
-
export function EventSheet({ meta, onChange, calendars, fields, siteTimezone, maps, eventId, title, status, tags }: Props) {
|
|
50
|
+
export function EventSheet({ meta, onChange, calendars, fields, siteTimezone, maps, eventId, title, status, tags, languages, mainLanguage }: Props) {
|
|
47
51
|
const [places, setPlaces] = useState<Place[]>([]);
|
|
48
52
|
const [current, setCurrent] = useState<string>(SECTIONS[0].id);
|
|
49
53
|
const root = useRef<HTMLDivElement>(null);
|
|
@@ -129,7 +133,7 @@ export function EventSheet({ meta, onChange, calendars, fields, siteTimezone, ma
|
|
|
129
133
|
onLocation={(name) => onChange({ location: { ...meta.location, name, placeId: '' } })}
|
|
130
134
|
/>
|
|
131
135
|
</Section>
|
|
132
|
-
<RegistrationCard meta={meta} onChange={onChange} eventId={eventId} title={title} />
|
|
136
|
+
<RegistrationCard meta={meta} onChange={onChange} eventId={eventId} title={title} languages={languages} mainLanguage={mainLanguage} />
|
|
133
137
|
<OrganizeCard meta={meta} onChange={onChange} calendars={calendars} fields={fields} tags={tags} />
|
|
134
138
|
<StatusSection meta={meta} onChange={onChange} />
|
|
135
139
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
|
-
import { starter } from '../../forms/FormBuilder';
|
|
2
|
+
import { starter, type SiteLanguage } from '../../forms/FormBuilder';
|
|
3
3
|
import type { EventCta } from '../../../lib/schema';
|
|
4
4
|
import type { EventMeta } from '../EventPanel';
|
|
5
5
|
import { Section } from './Section';
|
|
@@ -13,7 +13,7 @@ const MODES: { id: RegistrationMode; label: string; sub: string }[] = [
|
|
|
13
13
|
{ id: 'rsvp', label: 'Built-in form', sub: 'Collect RSVPs here' },
|
|
14
14
|
];
|
|
15
15
|
|
|
16
|
-
export function RegistrationCard({ meta, onChange, eventId, title = '' }: { eventId?: string; title?: string; meta: EventMeta; onChange: (patch: Partial<EventMeta>) => void }) {
|
|
16
|
+
export function RegistrationCard({ meta, onChange, eventId, title = '', languages, mainLanguage }: { eventId?: string; title?: string; meta: EventMeta; onChange: (patch: Partial<EventMeta>) => void; languages?: SiteLanguage[]; mainLanguage?: SiteLanguage }) {
|
|
17
17
|
// "Link button" with nothing typed yet is still link mode, so keep the
|
|
18
18
|
// choice locally and only fall back to the derived one on first render
|
|
19
19
|
const [mode, setMode] = useState<RegistrationMode>(() => registrationMode(meta));
|
|
@@ -94,7 +94,7 @@ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { even
|
|
|
94
94
|
<button type="button" className="pe-btn sm" aria-haspopup="dialog" onClick={() => setEditing(true)}>Edit form</button>
|
|
95
95
|
</div>
|
|
96
96
|
</div>
|
|
97
|
-
{editing && <RegistrationFormEditor form={form} maxSeats={meta.rsvp.maxSeats} eventTitle={title} onClose={() => setEditing(false)} onChange={(f) => onChange({ rsvp: { ...meta.rsvp, form: f } })} />}
|
|
97
|
+
{editing && <RegistrationFormEditor form={form} maxSeats={meta.rsvp.maxSeats} eventTitle={title} languages={languages} mainLanguage={mainLanguage} onClose={() => setEditing(false)} onChange={(f) => onChange({ rsvp: { ...meta.rsvp, form: f } })} />}
|
|
98
98
|
</div>
|
|
99
99
|
)}
|
|
100
100
|
</Section>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { actions } from 'astro:actions';
|
|
3
|
-
import FormBuilder, { starter } from '../../forms/FormBuilder';
|
|
3
|
+
import FormBuilder, { starter, type SiteLanguage } from '../../forms/FormBuilder';
|
|
4
4
|
import type { SystemField } from '../../forms/meta';
|
|
5
5
|
import type { FormDefinition, SavedForm } from '../../../lib/forms/schema';
|
|
6
6
|
import '../../../styles/forms.css';
|
|
@@ -10,12 +10,15 @@ type Choice = { id: string; name: string; note: string; definition: () => FormDe
|
|
|
10
10
|
// The event's own registration questions in the full forms builder, on top
|
|
11
11
|
// of the event editor. Edits go straight into the event's meta (its own copy);
|
|
12
12
|
// the event's Update/autosave persists them, so "Done" only closes.
|
|
13
|
-
export function RegistrationFormEditor({ form, onChange, onClose, maxSeats, eventTitle }: {
|
|
13
|
+
export function RegistrationFormEditor({ form, onChange, onClose, maxSeats, eventTitle, languages, mainLanguage }: {
|
|
14
14
|
form: FormDefinition;
|
|
15
15
|
onChange: (form: FormDefinition) => void;
|
|
16
16
|
onClose: () => void;
|
|
17
17
|
maxSeats: number;
|
|
18
18
|
eventTitle: string;
|
|
19
|
+
/** the site's other languages: the builder's switcher writes the questions' words in each */
|
|
20
|
+
languages?: SiteLanguage[];
|
|
21
|
+
mainLanguage?: SiteLanguage;
|
|
19
22
|
}) {
|
|
20
23
|
const ref = useRef<HTMLDialogElement>(null);
|
|
21
24
|
const [templates, setTemplates] = useState<SavedForm[]>([]);
|
|
@@ -135,7 +138,7 @@ export function RegistrationFormEditor({ form, onChange, onClose, maxSeats, even
|
|
|
135
138
|
</div>
|
|
136
139
|
)}
|
|
137
140
|
<div className="pe-evr-body">
|
|
138
|
-
<FormBuilder value={form} onChange={onChange} mode="event" systemFields={system} />
|
|
141
|
+
<FormBuilder value={form} onChange={onChange} mode="event" systemFields={system} languages={languages} mainLanguage={mainLanguage} />
|
|
139
142
|
</div>
|
|
140
143
|
</dialog>
|
|
141
144
|
);
|