@acms-subir/core 0.3.46 → 0.3.47
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 +12 -0
- package/package.json +1 -1
- package/src/lib/calendar/cards.ts +9 -6
- package/src/lib/calendar/data.ts +25 -9
- package/src/lib/calendar/event-words.ts +124 -0
- package/src/lib/calendar/feeds.ts +11 -3
- package/src/lib/calendar/filter.ts +140 -0
- package/src/lib/calendar/render.ts +20 -16
- package/src/lib/mobile/calendar.ts +27 -12
- package/src/lib/mobile/http.ts +5 -3
- package/src/pages/api/calendar/events/index.ts +18 -12
- package/src/pages/calendar/[slug].ics.ts +8 -2
- package/src/pages/calendar/feed.ics.ts +9 -2
- package/src/pages/calendar/index.astro +104 -48
- package/src/pages/embed/calendar/[slug].astro +29 -9
- package/src/styles/site.css +10 -0
|
@@ -11,7 +11,7 @@ import { siteLocale } from '../dates';
|
|
|
11
11
|
import { getCalendarEvents, tzParts, zonedToUtc } from './ics';
|
|
12
12
|
import { calendarPageUrl } from './src';
|
|
13
13
|
import { collectViewEvents, type ViewEvent } from './data';
|
|
14
|
-
import { eventStrings } from './event-words';
|
|
14
|
+
import { eventStrings, type EventStrings } from './event-words';
|
|
15
15
|
|
|
16
16
|
// Card sources are ICS feed URLs or native site calendars:
|
|
17
17
|
// 'acms:all' every public calendar (+ uncategorized events)
|
|
@@ -65,6 +65,7 @@ function compactTime(s: string): string {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayEvent[] {
|
|
68
|
+
const allDay = eventStrings(locale).allDay;
|
|
68
69
|
const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
69
70
|
const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
|
|
70
71
|
return events.map((e) => {
|
|
@@ -73,7 +74,7 @@ function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayE
|
|
|
73
74
|
let tm = '';
|
|
74
75
|
let rg: string;
|
|
75
76
|
if (e.allDay) {
|
|
76
|
-
rg = e.days > 1 ? `${when} – ${dayFmt.format(e.end - DAY)}` : `${when} ·
|
|
77
|
+
rg = e.days > 1 ? `${when} – ${dayFmt.format(e.end - DAY)}` : `${when} · ${allDay}`;
|
|
77
78
|
} else {
|
|
78
79
|
tm = compactTime(timeFmt.format(e.start));
|
|
79
80
|
rg = `${when} · ${timeFmt.format(e.start)} – ${timeFmt.format(e.end)}`;
|
|
@@ -132,11 +133,11 @@ function weekHtml(weekStart: number, todayN: number, byDay: Map<number, number[]
|
|
|
132
133
|
return `<div class="pc-cal-week">${h}</div>`;
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
function upcomingHtml(events: DisplayEvent[], order: number[], limit: number): string {
|
|
136
|
-
if (!order.length) return `<p class="pc-cal-empty"
|
|
136
|
+
function upcomingHtml(events: DisplayEvent[], order: number[], limit: number, w: EventStrings): string {
|
|
137
|
+
if (!order.length) return `<p class="pc-cal-empty">${esc(w.calendar.noUpcoming)}</p>`;
|
|
137
138
|
const items = order.slice(0, limit).map((i) => {
|
|
138
139
|
const e = events[i];
|
|
139
|
-
return `<li><button type="button" class="pc-cal-row${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) ||
|
|
140
|
+
return `<li><button type="button" class="pc-cal-row${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) || esc(w.allDay)}</span></button></li>`;
|
|
140
141
|
});
|
|
141
142
|
return `<ul class="pc-cal-agenda">${items.join('')}</ul>`;
|
|
142
143
|
}
|
|
@@ -189,7 +190,7 @@ if(!hit||!root.contains(hit)){if(!t.closest('.pc-cal-pop'))closePop();return}
|
|
|
189
190
|
var e=P.events[Number(hit.getAttribute('data-ev'))];if(!e)return;
|
|
190
191
|
closePop();
|
|
191
192
|
pop=document.createElement('div');pop.className='pc-cal-pop';
|
|
192
|
-
pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="
|
|
193
|
+
pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="'+esc(P.close)+'">×</button>'+(e.x?'<span class="pc-cal-pop-flag">'+esc(P.cancelled)+'</span>':'')+'<strong class="pc-cal-pop-title">'+esc(e.t)+'</strong><div class="pc-cal-pop-when">'+esc(e.rg)+'</div>'+(e.l?'<div class="pc-cal-pop-loc">'+esc(e.l)+'</div>':'')+(e.ds?'<p class="pc-cal-pop-desc">'+esc(e.ds)+'</p>':'')+(e.u?'<a class="pc-cal-pop-link" href="'+esc(e.u)+'">'+esc(P.details)+' →</a>':'');
|
|
193
194
|
frame.appendChild(pop);
|
|
194
195
|
var fr=frame.getBoundingClientRect(),cr=hit.getBoundingClientRect();
|
|
195
196
|
pop.style.left=Math.max(8,Math.min(cr.left-fr.left,fr.width-pop.offsetWidth-8))+'px';
|
|
@@ -234,7 +235,9 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
|
|
|
234
235
|
(opts.title ? `<h3 class="pc-cal-heading">${esc(opts.title)}</h3>` : '') +
|
|
235
236
|
(opts.subtitle ? `<p class="pc-cal-sub">${esc(opts.subtitle)}</p>` : '');
|
|
236
237
|
const tz = settings.timezone || 'UTC';
|
|
238
|
+
// a page in another language passes its settings with that locale
|
|
237
239
|
const locale = siteLocale(settings);
|
|
240
|
+
const w = eventStrings(locale);
|
|
238
241
|
const today = tzParts(now, tz);
|
|
239
242
|
const [minY, minM] = addMonths(today.y, today.m, -6);
|
|
240
243
|
const [maxY, maxM] = addMonths(today.y, today.m, 6);
|
|
@@ -263,10 +266,10 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
|
|
|
263
266
|
titleText = `${monthsShort[wa.m - 1]} ${wa.d} – ${wa.m === wb.m ? '' : `${monthsShort[wb.m - 1]} `}${wb.d}, ${wb.y}`;
|
|
264
267
|
bodyHtml = weekHtml(weekStart, todayN, byDay, events, dows);
|
|
265
268
|
} else if (opts.view === 'upcoming') {
|
|
266
|
-
titleText =
|
|
269
|
+
titleText = w.calendar.upcoming;
|
|
267
270
|
const startOfToday = zonedToUtc(today.y, today.m, today.d, 0, 0, 0, tz);
|
|
268
271
|
const order = raw.map((e, i) => ({ e, i })).filter(({ e }) => e.end > startOfToday).map(({ i }) => i);
|
|
269
|
-
bodyHtml = upcomingHtml(events, order, Math.min(50, Math.max(1, opts.limit || 6)));
|
|
272
|
+
bodyHtml = upcomingHtml(events, order, Math.min(50, Math.max(1, opts.limit || 6)), w);
|
|
270
273
|
if (!order.length && opts.emptyText) bodyHtml = `<p class="pc-cal-empty">${esc(opts.emptyText)}</p>`;
|
|
271
274
|
} else {
|
|
272
275
|
titleText = `${months[fm - 1]} ${fy}`;
|
|
@@ -276,9 +279,9 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
|
|
|
276
279
|
const navButtons =
|
|
277
280
|
opts.view === 'upcoming'
|
|
278
281
|
? ''
|
|
279
|
-
: `<button type="button" class="pc-cal-btn" data-nav="today"
|
|
280
|
-
`<button type="button" class="pc-cal-btn" data-nav="prev" aria-label="
|
|
281
|
-
`<button type="button" class="pc-cal-btn" data-nav="next" aria-label="
|
|
282
|
+
: `<button type="button" class="pc-cal-btn" data-nav="today">${esc(w.calendar.today)}</button>` +
|
|
283
|
+
`<button type="button" class="pc-cal-btn" data-nav="prev" aria-label="${esc(w.calendar.previous)}">‹</button>` +
|
|
284
|
+
`<button type="button" class="pc-cal-btn" data-nav="next" aria-label="${esc(w.calendar.next)}">›</button>`;
|
|
282
285
|
|
|
283
286
|
const footer = opts.footer
|
|
284
287
|
? `<div class="pc-cal-foot"><a class="pc-cal-open" href="${esc(opts.footer.href)}" target="_blank" rel="noopener">${esc(opts.footer.label)}</a></div>`
|
|
@@ -291,8 +294,8 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
|
|
|
291
294
|
min: [minY, minM],
|
|
292
295
|
max: [maxY, maxM],
|
|
293
296
|
months, monthsShort, dows, events, ws,
|
|
294
|
-
// the pop-up's
|
|
295
|
-
details:
|
|
297
|
+
// the pop-up's words in the dates' language, like event cards
|
|
298
|
+
details: w.calendar.details, cancelled: w.cancelled, close: w.calendar.close,
|
|
296
299
|
};
|
|
297
300
|
const json = JSON.stringify(payload).replace(/</g, '\\u003c');
|
|
298
301
|
|
|
@@ -319,11 +322,12 @@ export async function renderCalendarBlock(
|
|
|
319
322
|
const heading =
|
|
320
323
|
(a.title ? `<h3 class="pc-cal-heading">${esc(a.title)}</h3>` : '') +
|
|
321
324
|
(a.subtitle ? `<p class="pc-cal-sub">${esc(a.subtitle)}</p>` : '');
|
|
325
|
+
const w = eventStrings(siteLocale(settings)).calendar;
|
|
322
326
|
|
|
323
327
|
const feeds = a.sources.filter((s) => s.startsWith('http'));
|
|
324
328
|
const natives = repo ? a.sources.filter(isNativeSource) : [];
|
|
325
329
|
if (!feeds.length && !natives.length) {
|
|
326
|
-
return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty"
|
|
330
|
+
return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">${esc(w.noSource)}</p></div></section>`;
|
|
327
331
|
}
|
|
328
332
|
|
|
329
333
|
const win = calendarWindow(settings, now);
|
|
@@ -346,7 +350,7 @@ export async function renderCalendarBlock(
|
|
|
346
350
|
const raw: ViewEvent[] = [...feedEvents, ...native].sort((x, y) => x.start - y.start || x.title.localeCompare(y.title));
|
|
347
351
|
|
|
348
352
|
if (!raw.length && feeds.length && errors.length === feeds.length && !natives.length) {
|
|
349
|
-
return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty"
|
|
353
|
+
return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">${esc(w.unavailable)}</p></div></section>`;
|
|
350
354
|
}
|
|
351
355
|
|
|
352
356
|
const pageUrl = a.showOpenLink ? feeds.map(calendarPageUrl).find(Boolean) ?? '' : '';
|
|
@@ -355,7 +359,7 @@ export async function renderCalendarBlock(
|
|
|
355
359
|
title: a.title,
|
|
356
360
|
subtitle: a.subtitle,
|
|
357
361
|
limit: a.limit,
|
|
358
|
-
footer: pageUrl ? { href: pageUrl, label:
|
|
362
|
+
footer: pageUrl ? { href: pageUrl, label: w.openGoogle } : undefined,
|
|
359
363
|
// dots only mean something with 2+ calendars
|
|
360
364
|
multi: feeds.length + nativeCalendars > 1,
|
|
361
365
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
-
import {
|
|
3
|
+
import { type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl, publicEventFields } from '../calendar/config';
|
|
6
6
|
import { calendarToJson, eventToJson, roomsToJson } from '../calendar/api';
|
|
@@ -17,6 +17,7 @@ import { localizeDoc } from '../translations';
|
|
|
17
17
|
import { localeQuery, mobileLocale, localizedUrl } from './locale';
|
|
18
18
|
import { confirmsInstantly, linkApproval } from '../booking/approval';
|
|
19
19
|
import { changeTopics } from '../push/service';
|
|
20
|
+
import { occurrenceMatches, readEventFilter, resolveEventFilter, tagsMatch } from '../calendar/filter';
|
|
20
21
|
|
|
21
22
|
export function requireCalendar(bookings = false) {
|
|
22
23
|
if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
|
|
@@ -27,14 +28,24 @@ export async function listCalendars(repo: ContentRepository, url: URL) {
|
|
|
27
28
|
const origin = siteOrigin(await repo.getSettings(), url);
|
|
28
29
|
return { calendars: (await repo.listCalendars()).filter(c => c.visibility === 'public').map(c => calendarToJson(c, origin)) };
|
|
29
30
|
}
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// The website calendar's filters (calendar/filter.ts). Each takes one or more
|
|
32
|
+
// values, comma-separated or repeated. Tags are a public tag's name or slug
|
|
33
|
+
// (internal "#" tags never match, as they are never shown).
|
|
34
|
+
const FILTER_LISTS = ['calendar', 'cal', 'tag', 'excludeTag', 'notag', 'room'] as const;
|
|
35
|
+
const filterList = (what: string) => z.string().min(1).max(2100)
|
|
36
|
+
.refine((v) => { const parts = v.split(','); return parts.length <= 20 && parts.every((p) => p.trim().length <= 100); }, 'Use up to 20 values of up to 100 characters each.')
|
|
37
|
+
.optional().describe(`${what} One or more, comma-separated (a,b) or repeated.`);
|
|
38
|
+
export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(),
|
|
39
|
+
calendar: filterList('Only events in these calendars (slugs).'), cal: filterList('Alias of calendar.'),
|
|
40
|
+
tag: filterList('Only events with one of these tags (a name in any case, or a slug).'), excludeTag: filterList('Leave out events with any of these tags.'), notag: filterList('Alias of excludeTag.'),
|
|
41
|
+
room: filterList('Only events held in one of these rooms (slugs of rooms shown on public events).'),
|
|
42
|
+
q: z.string().max(100).optional() }).strict();
|
|
33
43
|
export async function listEvents(repo: ContentRepository, url: URL) {
|
|
34
44
|
requireCalendar();
|
|
35
|
-
const q = eventsQuery.parse(queryParams(url));
|
|
45
|
+
const q = eventsQuery.parse(queryParams(url, FILTER_LISTS));
|
|
36
46
|
const locale = mobileLocale(q.locale);
|
|
37
47
|
const term = q.q?.trim() ?? '';
|
|
48
|
+
const filter = readEventFilter(url.searchParams);
|
|
38
49
|
const [settings, calendars, events, resources, indexed] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(), roomMap(repo),
|
|
39
50
|
// the search index's matches (body text, word prefixes), so "more" from /search continues here
|
|
40
51
|
term ? repo.searchPublished(term, { limit: 200, collections: [] }).then((r) => new Set(r.events.map(({ doc }) => doc.id))) : null]);
|
|
@@ -47,14 +58,18 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
47
58
|
const to = q.to ? at(q.to) : fromDay.length === 10 ? localMidnight(addDaysYmd(fromDay, 30), tz) : from + 30 * 86400000;
|
|
48
59
|
if (to <= from || to - from > 90 * 86400000 + 3600000) throw new ApiError(400, 'Use an event window of 1 to 90 days.');
|
|
49
60
|
const all = new Map(calendars.map(c => [c.id, c]));
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
61
|
+
// private calendars and rooms not shown on public events answer like missing ones
|
|
62
|
+
const f = resolveEventFilter(filter, { calendars, resources: [...resources.values()] });
|
|
63
|
+
if (f.calendarIds && f.calendarIds.length < filter.cal.length) throw new ApiError(404, 'Calendar not found.');
|
|
64
|
+
if (f.roomIds && f.roomIds.length < filter.room.length) throw new ApiError(404, 'Room not found.');
|
|
65
|
+
const picked = f.calendarIds;
|
|
66
|
+
const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || picked?.includes(c.id)).map(c => [c.id, c]));
|
|
67
|
+
const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (picked ? e.calendarIds.some(id => picked.includes(id)) && eventIsPublic(e, all) : eventIsListed(e, all))
|
|
68
|
+
&& tagsMatch(f, e.tags)
|
|
56
69
|
&& (!term || indexed!.has(e.id) || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(term.toLowerCase())));
|
|
57
|
-
const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to })
|
|
70
|
+
const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to })
|
|
71
|
+
// rooms are per date (a moved date can change them)
|
|
72
|
+
.filter(o => !f.roomIds || occurrenceMatches(f, o));
|
|
58
73
|
occurrences.sort((a, b) => a.start - b.start || (a.event.id < b.event.id ? -1 : a.event.id > b.event.id ? 1 : 0));
|
|
59
74
|
const start = (q.page - 1) * q.limit;
|
|
60
75
|
const origin = siteOrigin(settings, url);
|
package/src/lib/mobile/http.ts
CHANGED
|
@@ -43,11 +43,13 @@ export async function limitRequest(repo: ContentRepository, address: string, wri
|
|
|
43
43
|
const key = `mobile:${write ? 'write' : 'read'}:${await sha256Hex(address)}`;
|
|
44
44
|
if (!await repo.email.rateLimit(key, write ? 20 : 300, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
// lists: parameters that may repeat (tag=a&tag=b); their values are joined
|
|
47
|
+
// with commas, the same as tag=a,b
|
|
48
|
+
export function queryParams(url: URL, lists: readonly string[] = []) {
|
|
47
49
|
const result: Record<string, string> = {};
|
|
48
50
|
for (const [key, value] of url.searchParams) {
|
|
49
|
-
if (key in result) throw new ApiError(400, `Duplicate query parameter: ${key}.`);
|
|
50
|
-
result[key] = value;
|
|
51
|
+
if (key in result && !lists.includes(key)) throw new ApiError(400, `Duplicate query parameter: ${key}.`);
|
|
52
|
+
result[key] = key in result ? `${result[key]},${value}` : value;
|
|
51
53
|
}
|
|
52
54
|
return result;
|
|
53
55
|
}
|
|
@@ -14,16 +14,20 @@ import { readJson } from '../../../../lib/api';
|
|
|
14
14
|
import { runAgentTool } from '../../../../lib/agent';
|
|
15
15
|
import { waitUntilFrom } from '../../../../lib/webhooks';
|
|
16
16
|
import { fail } from '../../../../lib/calendar/http';
|
|
17
|
-
import {
|
|
17
|
+
import { occurrenceMatches, readEventFilter, resolveEventFilter, tagsMatch } from '../../../../lib/calendar/filter';
|
|
18
18
|
|
|
19
19
|
// Public read API for other sites, apps and lobby screens.
|
|
20
20
|
// GET /api/calendar/events?from=2026-10-01&to=2026-10-31&calendar=youth&q=night
|
|
21
21
|
// GET /api/calendar/events?tag=class (or excludeTag=class; a tag's name or slug)
|
|
22
|
+
// GET /api/calendar/events?calendar=youth,events¬ag=Class&room=chapel
|
|
23
|
+
// (the /calendar page's filters: each takes several values, comma-separated
|
|
24
|
+
// or repeated; cal and notag are aliases of calendar and excludeTag)
|
|
22
25
|
// → { timezone, from, to, occurrences: [{ id, start, end, allDay, cancelled, event }] }
|
|
23
26
|
// GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
|
|
24
27
|
// Anonymous callers see public calendars (and an unlisted one when asked by
|
|
25
28
|
// slug). A key with events:read also sees private calendars and private
|
|
26
|
-
// custom fields,
|
|
29
|
+
// custom fields, its tag filters also match internal "#" tags, and its room
|
|
30
|
+
// filter matches every room (anonymous callers: rooms shown on public events).
|
|
27
31
|
const CORS = {
|
|
28
32
|
'Access-Control-Allow-Origin': '*',
|
|
29
33
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
@@ -43,28 +47,28 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
|
43
47
|
const principal = await authenticate(request, repo);
|
|
44
48
|
if (request.headers.get('authorization') && !principal) throw new ApiError(401, 'Invalid API key.');
|
|
45
49
|
const full = principal ? hasScope(principal, 'events:read') : false;
|
|
50
|
+
const filter = readEventFilter(url.searchParams);
|
|
46
51
|
const [settings, calendars, published, rooms] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(), repo.listResources()]);
|
|
47
52
|
const tz = settings.timezone || 'UTC';
|
|
48
53
|
const map = new Map(calendars.map((c) => [c.id, c]));
|
|
49
54
|
const resources = new Map(rooms.map((r) => [r.id, r]));
|
|
50
55
|
const origin = siteOrigin(settings, url);
|
|
51
56
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
if (
|
|
57
|
+
const f = resolveEventFilter(filter, { calendars, resources: rooms, allowPrivate: full });
|
|
58
|
+
if (f.calendarIds && f.calendarIds.length < filter.cal.length) throw new ApiError(404, 'Unknown calendar.');
|
|
59
|
+
if (f.roomIds && f.roomIds.length < filter.room.length) throw new ApiError(404, 'Unknown room.');
|
|
60
|
+
const picked = f.calendarIds;
|
|
55
61
|
const q = (url.searchParams.get('q') ?? '').trim().toLowerCase();
|
|
56
62
|
let events = published.filter((e) => {
|
|
57
|
-
if (
|
|
63
|
+
if (picked) return e.calendarIds.some((id) => picked.includes(id)) && (full || eventIsPublic(e, map));
|
|
58
64
|
return full ? true : eventIsListed(e, map);
|
|
59
65
|
});
|
|
60
66
|
if (q) events = events.filter((e) => `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q));
|
|
61
|
-
|
|
62
|
-
if (tag || excludeTag) {
|
|
63
|
-
const tagged = tagFilter(tag, excludeTag);
|
|
64
|
-
events = events.filter((e) => tagged(full ? e.tags : publicTags(e.tags)));
|
|
65
|
-
}
|
|
67
|
+
events = events.filter((e) => tagsMatch(f, e.tags, { internal: full }));
|
|
66
68
|
|
|
67
69
|
if (url.searchParams.get('expand') === '0') {
|
|
70
|
+
// rules, not dates: a room filter matches the series' own rooms
|
|
71
|
+
if (f.roomIds) events = events.filter((e) => occurrenceMatches(f, { event: e }));
|
|
68
72
|
return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full, resources })) }));
|
|
69
73
|
}
|
|
70
74
|
const day = (param: string, fallback: number) => {
|
|
@@ -76,7 +80,9 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
|
76
80
|
const to = Math.min(day('to', from + 90 * 86_400_000), from + 400 * 86_400_000);
|
|
77
81
|
if (to <= from) throw new ApiError(400, '"to" must be after "from".');
|
|
78
82
|
const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get('limit')) || 200));
|
|
79
|
-
const occ = expandEvents(events, settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: to })
|
|
83
|
+
const occ = expandEvents(events, settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: to })
|
|
84
|
+
.filter((o) => !f.roomIds || occurrenceMatches(f, o))
|
|
85
|
+
.slice(0, limit);
|
|
80
86
|
const cache = new Map<string, ReturnType<typeof eventToJson>>();
|
|
81
87
|
const toJson = (id: string) => {
|
|
82
88
|
if (!cache.has(id)) cache.set(id, eventToJson(events.find((e) => e.id === id)!, origin, map, settings.timezone, { includePrivate: full, resources }));
|
|
@@ -4,10 +4,13 @@ import { getRepo } from '../../lib/repo';
|
|
|
4
4
|
import { siteOrigin } from '../../lib/seo';
|
|
5
5
|
import { CALENDAR } from '../../lib/calendar/config';
|
|
6
6
|
import { feedFor, icsResponse } from '../../lib/calendar/feeds';
|
|
7
|
+
import { readEventFilter, resolveEventFilter } from '../../lib/calendar/filter';
|
|
7
8
|
|
|
8
9
|
// One calendar's subscribe feed. Public and unlisted calendars are open;
|
|
9
10
|
// private ones need the secret ?t= token from Calendars → Copy link (staff
|
|
10
11
|
// subscriptions). A wrong token looks exactly like a missing calendar.
|
|
12
|
+
// ?tag=, ?notag= and ?room= narrow it like the /calendar page (a `cal` here
|
|
13
|
+
// is ignored: the address names the calendar).
|
|
11
14
|
const sameToken = (a: string, b: string) =>
|
|
12
15
|
a.length === b.length && a.length > 0 && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
13
16
|
|
|
@@ -18,8 +21,11 @@ export const GET: APIRoute = async ({ locals, url, params }) => {
|
|
|
18
21
|
if (!calendar || calendar.kind !== 'native') return new Response(null, { status: 404 });
|
|
19
22
|
const isPrivate = calendar.visibility === 'private';
|
|
20
23
|
if (isPrivate && !sameToken(url.searchParams.get('t') ?? '', calendar.feedToken)) return new Response(null, { status: 404 });
|
|
21
|
-
const
|
|
22
|
-
const
|
|
24
|
+
const filter = { ...readEventFilter(url.searchParams), cal: [] };
|
|
25
|
+
const [settings, resources] = await Promise.all([repo.getSettings(), filter.room.length ? repo.listResources() : []]);
|
|
26
|
+
const resolved = resolveEventFilter(filter, { calendars: [], resources, allowPrivate: isPrivate });
|
|
27
|
+
if (resolved.unknown) return new Response(null, { status: 404 });
|
|
28
|
+
const { body } = await feedFor(repo, settings, siteOrigin(settings, url), { calendar, allowPrivate: isPrivate, filter: resolved });
|
|
23
29
|
// private feeds must never sit in shared caches
|
|
24
30
|
const res = icsResponse(body, `${calendar.slug}.ics`, isPrivate ? 0 : 300);
|
|
25
31
|
if (isPrivate) res.headers.set('Cache-Control', 'private, no-store');
|
|
@@ -3,13 +3,20 @@ import { getRepo } from '../../lib/repo';
|
|
|
3
3
|
import { siteOrigin } from '../../lib/seo';
|
|
4
4
|
import { CALENDAR } from '../../lib/calendar/config';
|
|
5
5
|
import { feedFor, icsResponse } from '../../lib/calendar/feeds';
|
|
6
|
+
import { readEventFilter, resolveEventFilter } from '../../lib/calendar/filter';
|
|
6
7
|
|
|
7
8
|
// Every public calendar in one subscribe feed (/calendar.ics). Feed
|
|
8
9
|
// calendars are not re-published — subscribers can add those at the source.
|
|
10
|
+
// The /calendar page's filters narrow it: /calendar.ics?tag=Class,
|
|
11
|
+
// ?cal=youth,events¬ag=Class, ?room=chapel. A calendar or room that isn't
|
|
12
|
+
// public answers 404, like one that doesn't exist.
|
|
9
13
|
export const GET: APIRoute = async ({ locals, url }) => {
|
|
10
14
|
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
11
15
|
const repo = await getRepo(locals);
|
|
12
|
-
const
|
|
13
|
-
const
|
|
16
|
+
const filter = readEventFilter(url.searchParams);
|
|
17
|
+
const [settings, calendars, resources] = await Promise.all([repo.getSettings(), repo.listCalendars(), filter.room.length ? repo.listResources() : []]);
|
|
18
|
+
const resolved = resolveEventFilter(filter, { calendars, resources });
|
|
19
|
+
if (resolved.unknown) return new Response(null, { status: 404 });
|
|
20
|
+
const { body } = await feedFor(repo, settings, siteOrigin(settings, url), { filter: resolved });
|
|
14
21
|
return icsResponse(body, 'calendar.ics');
|
|
15
22
|
};
|
|
@@ -3,50 +3,69 @@ import Site from '../../layouts/Site.astro';
|
|
|
3
3
|
import { getRepo } from '../../lib/repo';
|
|
4
4
|
import { effectiveSettings } from '../../lib/designPreview';
|
|
5
5
|
import { siteOrigin } from '../../lib/seo';
|
|
6
|
-
import { siteLocale } from '../../lib/dates';
|
|
7
|
-
import {
|
|
6
|
+
import { siteLang, siteLocale } from '../../lib/dates';
|
|
7
|
+
import { I18N, localePath, requestLocale } from '../../lib/i18n';
|
|
8
|
+
import { isExtraLocale } from '../../lib/translations';
|
|
9
|
+
import { fill } from '../../lib/live/words';
|
|
10
|
+
import { eventStrings } from '../../lib/calendar/event-words';
|
|
11
|
+
import { CALENDAR, calendarFeedUrl, eventUrl } from '../../lib/calendar/config';
|
|
8
12
|
import { calendarWindow, renderCalendarView } from '../../lib/calendar/render';
|
|
9
13
|
import { collectViewEvents, type ViewEvent } from '../../lib/calendar/data';
|
|
10
14
|
import { eventCardHtml } from '../../lib/calendar/cards';
|
|
11
15
|
import { safeColor } from '../../lib/calendar/events';
|
|
12
16
|
import { tzParts } from '../../lib/calendar/ics';
|
|
17
|
+
import { eventFilterQuery, hasEventFilter, readEventFilter, resolveEventFilter, type EventFilter } from '../../lib/calendar/filter';
|
|
13
18
|
|
|
14
19
|
// The main calendar page: filter chips per public calendar, Month / Week /
|
|
15
20
|
// List views (the same renderer as the calendar card), image cards for
|
|
16
21
|
// what's coming up, and subscribe links for every calendar app. On phones the
|
|
17
22
|
// month grid gives way to a two-week day strip over the agenda.
|
|
23
|
+
// The address carries the filters (filter.ts): ?cal=youth,events, ?tag=Class,
|
|
24
|
+
// ?notag=Class, ?room=chapel. Active ones show as removable chips, and the
|
|
25
|
+
// subscribe links carry them. /es/calendar speaks Spanish (the same languages
|
|
26
|
+
// as event pages) and keeps its links under /es.
|
|
18
27
|
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
19
28
|
|
|
20
29
|
const repo = await getRepo(Astro.locals);
|
|
21
|
-
const
|
|
30
|
+
const reqLocale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
|
|
31
|
+
const baseSettings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
|
|
32
|
+
// dates and the renderer's words follow the page's language, like event pages
|
|
33
|
+
const settings = isExtraLocale(reqLocale) ? { ...baseSettings, locale: reqLocale } : baseSettings;
|
|
34
|
+
const lang = reqLocale || siteLang(settings);
|
|
35
|
+
const w = eventStrings(lang);
|
|
36
|
+
const cw = w.calendar;
|
|
37
|
+
const here = (path: string) => localePath(path, reqLocale);
|
|
22
38
|
const now = Date.now();
|
|
23
39
|
const tz = settings.timezone || 'UTC';
|
|
24
40
|
const locale = siteLocale(settings);
|
|
25
41
|
const origin = siteOrigin(settings, Astro.url);
|
|
26
42
|
|
|
27
|
-
const
|
|
43
|
+
const filter = readEventFilter(Astro.url.searchParams);
|
|
44
|
+
const filtered = hasEventFilter(filter);
|
|
45
|
+
const [all, resources] = await Promise.all([repo.listCalendars(), filter.room.length ? repo.listResources() : []]);
|
|
46
|
+
const resolved = resolveEventFilter(filter, { calendars: all, resources });
|
|
28
47
|
const listed = all.filter((c) => c.visibility === 'public');
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
48
|
+
// exactly one calendar picked: its name, description and own feed
|
|
49
|
+
const active = filter.cal.length === 1 && resolved.calendars.length === 1 ? resolved.calendars[0] : null;
|
|
50
|
+
const picked = new Set(resolved.calendarIds ?? []);
|
|
32
51
|
const viewParam = Astro.url.searchParams.get('view');
|
|
33
52
|
const view = (['month', 'week', 'list'] as const).find((v) => v === viewParam) ?? CALENDAR.defaultView;
|
|
34
|
-
const explicitView =
|
|
35
|
-
const
|
|
53
|
+
const explicitView = viewParam === view;
|
|
54
|
+
const monthRaw = Astro.url.searchParams.get('m') ?? '';
|
|
55
|
+
const monthParam = /^(\d{4})-(\d{2})$/.exec(monthRaw);
|
|
36
56
|
|
|
37
57
|
const win = calendarWindow(settings, now);
|
|
38
|
-
const { events, calendars, errors } = await collectViewEvents(repo, settings, win, {
|
|
39
|
-
audience: 'public',
|
|
40
|
-
...(active ? { calendarIds: [active.id] } : {}),
|
|
41
|
-
});
|
|
58
|
+
const { events, calendars, errors } = await collectViewEvents(repo, settings, win, { audience: 'public', filter: resolved, locale: lang });
|
|
42
59
|
|
|
43
|
-
const
|
|
60
|
+
const gridHtml = renderCalendarView(events, settings, now, {
|
|
44
61
|
view: view === 'list' ? 'upcoming' : view,
|
|
45
62
|
limit: 50,
|
|
46
|
-
multi:
|
|
47
|
-
emptyText:
|
|
63
|
+
multi: calendars.length > 1,
|
|
64
|
+
emptyText: filtered ? cw.noMatch : cw.empty,
|
|
48
65
|
focus: monthParam ? { y: Number(monthParam[1]), m: Number(monthParam[2]) } : undefined,
|
|
49
66
|
});
|
|
67
|
+
// an empty month or week grid says why (the list says it itself)
|
|
68
|
+
const calendarHtml = (filtered && !events.length && view !== 'list' ? '<p class="pc-cal-empty">' + cw.noMatch + '</p>' : '') + gridHtml;
|
|
50
69
|
|
|
51
70
|
// coming up: the next occurrence of each distinct native event
|
|
52
71
|
const seen = new Set<string>();
|
|
@@ -79,19 +98,44 @@ const stripDays = Array.from({ length: 14 }, (_, i) => {
|
|
|
79
98
|
return { k, dow: fmt({ weekday: 'narrow' }).format(ms), d: tzParts(ms, tz).d, has: agenda.has(k), today: i === 0 };
|
|
80
99
|
});
|
|
81
100
|
|
|
82
|
-
|
|
101
|
+
// links keep the filters, the view, the month and the page's language
|
|
102
|
+
const pageHref = (f: EventFilter, opts: { view?: string | null; m?: string | null } = {}) => {
|
|
103
|
+
const v = opts.view === undefined ? (explicitView ? view : null) : opts.view;
|
|
104
|
+
const m = opts.m === undefined ? (monthParam ? monthRaw : null) : opts.m;
|
|
105
|
+
const q = [eventFilterQuery(f), v ? `view=${v}` : '', m ? `m=${m}` : ''].filter(Boolean).join('&');
|
|
106
|
+
return here(CALENDAR.base) + (q ? `?${q}` : '');
|
|
107
|
+
};
|
|
108
|
+
const viewHref = (v: string) => pageHref(filter, { view: v, m: null });
|
|
109
|
+
const without = (key: keyof EventFilter, value: string) => pageHref({ ...filter, [key]: filter[key].filter((x) => x !== value) });
|
|
110
|
+
// calendar chips switch a calendar on or off; "All" drops the calendar filter
|
|
111
|
+
const toggleCal = (slug: string) => {
|
|
112
|
+
const on = filter.cal.some((s) => s.toLowerCase() === slug);
|
|
113
|
+
return pageHref({ ...filter, cal: on ? filter.cal.filter((s) => s.toLowerCase() !== slug) : [...filter.cal, slug] });
|
|
114
|
+
};
|
|
115
|
+
const offered = [...listed, ...resolved.calendars.filter((c) => c.visibility !== 'public')];
|
|
116
|
+
const roomName = (slug: string) => resolved.rooms.find((r) => r.slug === slug.toLowerCase())?.name ?? slug;
|
|
117
|
+
// the removable chips: tags, left-out tags, rooms, and calendars that didn't resolve
|
|
118
|
+
const chips = [
|
|
119
|
+
...filter.tag.map((t) => ({ label: t, href: without('tag', t) })),
|
|
120
|
+
...filter.notag.map((t) => ({ label: fill(cw.without, { tag: t }), href: without('notag', t) })),
|
|
121
|
+
...filter.room.map((r) => ({ label: fill(cw.room, { room: roomName(r) }), href: without('room', r) })),
|
|
122
|
+
...filter.cal.filter((s) => !resolved.calendars.some((c) => c.slug === s.toLowerCase())).map((s) => ({ label: s, href: without('cal', s) })),
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
// subscribe: one calendar alone keeps its own feed; anything else is the
|
|
126
|
+
// site feed with the same filters
|
|
127
|
+
const ownFeed = active && !filter.tag.length && !filter.notag.length && !filter.room.length;
|
|
128
|
+
const feedQuery = eventFilterQuery(filter);
|
|
129
|
+
const feed = origin + (ownFeed ? calendarFeedUrl(active.slug) : calendarFeedUrl() + (feedQuery ? `?${feedQuery}` : ''));
|
|
83
130
|
const webcal = feed.replace(/^https?:\/\//, 'webcal://');
|
|
84
131
|
const googleAdd = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(webcal)}`;
|
|
85
132
|
const outlookAdd = `https://outlook.live.com/calendar/0/addfromweb?url=${encodeURIComponent(feed)}&name=${encodeURIComponent(active?.name ?? settings.siteTitle)}`;
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
p.delete('m');
|
|
90
|
-
return `${CALENDAR.base}?${p}`;
|
|
91
|
-
};
|
|
92
|
-
const title = active ? active.name : 'Calendar';
|
|
93
|
-
const lede = active?.description || `What’s happening at ${settings.siteTitle}. Subscribe to keep it in your own calendar.`;
|
|
133
|
+
const subscribeNote = ownFeed ? fill(cw.subscribeNamed, { name: active.name }) : filtered ? cw.subscribeFiltered : cw.subscribeAll;
|
|
134
|
+
const title = active ? active.name : cw.title;
|
|
135
|
+
const lede = active?.description || fill(cw.lede, { site: settings.siteTitle });
|
|
94
136
|
const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.type === 'timeZoneName')?.value ?? tz;
|
|
137
|
+
const agendaEmpty = filtered && !events.length ? cw.noMatch : cw.emptySoon;
|
|
138
|
+
const cardHref = (v: ViewEvent) => here(eventUrl(v.event!.slug));
|
|
95
139
|
---
|
|
96
140
|
|
|
97
141
|
<Site title={title} description={lede}>
|
|
@@ -102,40 +146,51 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
|
|
|
102
146
|
</header>
|
|
103
147
|
|
|
104
148
|
<div class="pc-calpage-bar">
|
|
105
|
-
{(
|
|
106
|
-
<nav class="pc-calpage-chips" aria-label=
|
|
107
|
-
<a class={!
|
|
108
|
-
{
|
|
109
|
-
<a class={
|
|
149
|
+
{(offered.length > 1 || picked.size > 0) && (
|
|
150
|
+
<nav class="pc-calpage-chips" aria-label={cw.calendars}>
|
|
151
|
+
<a class={!filter.cal.length ? 'is-on' : ''} href={pageHref({ ...filter, cal: [] })} aria-current={!filter.cal.length ? 'page' : undefined}>{cw.all}</a>
|
|
152
|
+
{offered.map((c) => (
|
|
153
|
+
<a class={picked.has(c.id) ? 'is-on' : ''} href={toggleCal(c.slug)} aria-current={picked.has(c.id) ? 'true' : undefined}>
|
|
110
154
|
<i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}
|
|
111
155
|
</a>
|
|
112
156
|
))}
|
|
113
|
-
{active && active.visibility !== 'public' && <a class="is-on" href={calendarPageUrl(active.slug)}><i style={`background:${safeColor(active.color)}`} aria-hidden="true" />{active.name}</a>}
|
|
114
157
|
</nav>
|
|
115
158
|
)}
|
|
116
159
|
<div class="pc-calpage-tools">
|
|
117
|
-
<nav class="pc-calpage-views" aria-label=
|
|
118
|
-
<a class={view === 'month' ? 'is-on' : ''} href={viewHref('month')}>
|
|
119
|
-
<a class={view === 'week' ? 'is-on' : ''} href={viewHref('week')}>
|
|
120
|
-
<a class={view === 'list' ? 'is-on' : ''} href={viewHref('list')}>
|
|
160
|
+
<nav class="pc-calpage-views" aria-label={cw.views}>
|
|
161
|
+
<a class={view === 'month' ? 'is-on' : ''} href={viewHref('month')}>{cw.month}</a>
|
|
162
|
+
<a class={view === 'week' ? 'is-on' : ''} href={viewHref('week')}>{cw.week}</a>
|
|
163
|
+
<a class={view === 'list' ? 'is-on' : ''} href={viewHref('list')}>{cw.list}</a>
|
|
121
164
|
</nav>
|
|
122
165
|
<details class="pc-calpage-sub">
|
|
123
166
|
<summary>
|
|
124
167
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg>
|
|
125
|
-
|
|
168
|
+
{cw.subscribe}
|
|
126
169
|
</summary>
|
|
127
170
|
<div class="pc-calpage-sub-menu">
|
|
128
|
-
<p>
|
|
171
|
+
<p>{subscribeNote}</p>
|
|
129
172
|
<a href={googleAdd} target="_blank" rel="noopener">Google Calendar</a>
|
|
130
173
|
<a href={webcal}>Apple Calendar</a>
|
|
131
174
|
<a href={outlookAdd} target="_blank" rel="noopener">Outlook</a>
|
|
132
|
-
<button type="button" data-copy={feed}>
|
|
175
|
+
<button type="button" data-copy={feed} data-copied={w.linkCopied}>{cw.copyLink}</button>
|
|
133
176
|
</div>
|
|
134
177
|
</details>
|
|
135
178
|
</div>
|
|
136
179
|
</div>
|
|
137
180
|
|
|
138
|
-
|
|
181
|
+
{chips.length > 0 && (
|
|
182
|
+
<nav class="pc-calpage-filters" aria-label={cw.filters}>
|
|
183
|
+
{chips.map((c) => (
|
|
184
|
+
<a class="pc-calpage-filter" href={c.href} aria-label={fill(cw.remove, { filter: c.label })}>
|
|
185
|
+
<span>{c.label}</span>
|
|
186
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17" /></svg>
|
|
187
|
+
</a>
|
|
188
|
+
))}
|
|
189
|
+
{chips.length > 1 && <a class="pc-calpage-clear" href={pageHref({ cal: [], tag: [], notag: [], room: [] })}>{cw.clear}</a>}
|
|
190
|
+
</nav>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
<div class="pc-calpage-strip" aria-label={cw.nextTwoWeeks}>
|
|
139
194
|
{stripDays.map((d) => (
|
|
140
195
|
<a class={`${d.has ? 'has' : ''}${d.today ? ' today' : ''}`} href={d.has ? `#d-${d.k}` : undefined} aria-label={d.k}>
|
|
141
196
|
<small>{d.dow}</small><b>{d.d}</b><i aria-hidden="true" />
|
|
@@ -145,13 +200,13 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
|
|
|
145
200
|
|
|
146
201
|
<div class="pc-calpage-view" set:html={calendarHtml} />
|
|
147
202
|
|
|
148
|
-
<section class="pc-calpage-agenda" aria-label=
|
|
149
|
-
{agenda.size === 0 ? <p class="pc-cal-empty">
|
|
203
|
+
<section class="pc-calpage-agenda" aria-label={cw.agenda}>
|
|
204
|
+
{agenda.size === 0 ? <p class="pc-cal-empty">{agendaEmpty}</p> : [...agenda.entries()].map(([k, list]) => (
|
|
150
205
|
<div class="pc-agenda-day" id={`d-${k}`}>
|
|
151
206
|
<h3>{dayLabel.format(list[0].start < now ? now : list[0].start)}</h3>
|
|
152
207
|
{list.map((v) => (
|
|
153
208
|
<a class={`pc-agenda-row${v.cancelled ? ' is-cancelled' : ''}`} href={v.url ?? undefined}>
|
|
154
|
-
<span class="t">{v.allDay ?
|
|
209
|
+
<span class="t">{v.allDay ? w.allDay : timeLabel.format(v.start)}</span>
|
|
155
210
|
<span class="ti">
|
|
156
211
|
{v.color && <i style={`background:${v.color}`} aria-hidden="true" />}
|
|
157
212
|
<b>{v.title}</b>
|
|
@@ -165,14 +220,14 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
|
|
|
165
220
|
|
|
166
221
|
{view !== 'list' && comingUp.length > 0 && (
|
|
167
222
|
<section class="pc-calpage-up">
|
|
168
|
-
<h2>
|
|
169
|
-
<div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: v.cancelled }, settings, { showImage: true })).join('')} />
|
|
223
|
+
<h2>{cw.comingUp}</h2>
|
|
224
|
+
<div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: v.cancelled }, settings, { showImage: true, href: cardHref(v) })).join('')} />
|
|
170
225
|
</section>
|
|
171
226
|
)}
|
|
172
227
|
|
|
173
228
|
<footer class="pc-calpage-foot">
|
|
174
|
-
<span>
|
|
175
|
-
{errors.length > 0 && <span>
|
|
229
|
+
<span>{fill(cw.timesIn, { zone: zoneName })}</span>
|
|
230
|
+
{errors.length > 0 && <span>{cw.feedErrors}</span>}
|
|
176
231
|
</footer>
|
|
177
232
|
</div>
|
|
178
233
|
<script>
|
|
@@ -180,8 +235,9 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
|
|
|
180
235
|
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
181
236
|
if (b) {
|
|
182
237
|
await navigator.clipboard.writeText(b.dataset.copy ?? '');
|
|
183
|
-
b.textContent
|
|
184
|
-
|
|
238
|
+
const label = (b.dataset.label ??= b.textContent ?? '');
|
|
239
|
+
b.textContent = b.dataset.copied ?? label;
|
|
240
|
+
setTimeout(() => (b.textContent = label), 1400);
|
|
185
241
|
}
|
|
186
242
|
document.querySelectorAll<HTMLDetailsElement>('details.pc-calpage-sub[open]').forEach((d) => {
|
|
187
243
|
if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;
|