@acms-subir/core 0.3.46 → 0.3.48
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 +47 -0
- package/cli/migrations.generated.mjs +42 -23
- package/docs/calendar.md +80 -0
- package/docs/server.md +28 -3
- package/docs/theme-authoring.md +1 -1
- package/package.json +1 -1
- package/src/actions/index.ts +76 -11
- package/src/components/EventPage.astro +18 -12
- package/src/components/calendar/CalendarBoard.tsx +43 -19
- package/src/components/editor/event/EventSheet.tsx +2 -0
- package/src/components/editor/event/SingleDates.tsx +123 -0
- package/src/lib/booking/holds.ts +7 -7
- package/src/lib/calendar/api.ts +11 -1
- package/src/lib/calendar/board.ts +7 -3
- package/src/lib/calendar/cards.ts +14 -11
- package/src/lib/calendar/change-migration.ts +40 -15
- package/src/lib/calendar/data.ts +34 -12
- package/src/lib/calendar/event-words.ts +133 -0
- package/src/lib/calendar/events.ts +94 -9
- package/src/lib/calendar/feeds.ts +11 -3
- package/src/lib/calendar/filter.ts +140 -0
- package/src/lib/calendar/ics-write.ts +25 -2
- package/src/lib/calendar/moves.ts +291 -0
- package/src/lib/calendar/render.ts +26 -18
- package/src/lib/calendar/schedule.ts +73 -61
- package/src/lib/email/booking-words.ts +7 -1
- package/src/lib/email/service.ts +6 -1
- package/src/lib/email/templates.ts +6 -2
- package/src/lib/mobile/calendar.ts +32 -14
- package/src/lib/mobile/http.ts +5 -3
- package/src/lib/mobile/schemas.ts +7 -3
- package/src/lib/push/service.ts +44 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +28 -10
- package/src/lib/repo/types.ts +6 -3
- package/src/lib/schema.ts +21 -0
- package/src/lib/sections.ts +5 -2
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/api/calendar/events/index.ts +24 -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/pages/members/_Layout.astro +1 -0
- package/src/pages/members/events/index.astro +1 -1
- package/src/server/index.ts +33 -0
- package/src/styles/editor.css +19 -0
- package/src/styles/schedule.css +2 -0
- package/src/styles/site.css +16 -0
- package/src/theme-sdk/calendar.ts +3 -2
package/src/lib/repo/sql.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { LiveConfig } from '../live/types';
|
|
|
13
13
|
import { ApiError } from '../api';
|
|
14
14
|
import { EmailStore, bookingJobs, rsvpStaffJobs } from '../email/store';
|
|
15
15
|
import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
|
|
16
|
-
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
16
|
+
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, MovedDate, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
17
17
|
import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
|
|
18
18
|
import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
|
|
19
19
|
import { cleanRrule } from '../calendar/rrule';
|
|
@@ -148,6 +148,15 @@ function parseRequest(raw: unknown): EventRequest | null {
|
|
|
148
148
|
if (raw === null || raw === undefined || raw === '') return null;
|
|
149
149
|
try { const r = EventRequest.safeParse(JSON.parse(String(raw))); return r.success ? r.data : null; } catch { return null; }
|
|
150
150
|
}
|
|
151
|
+
// a corrupt moved-dates map, or one bad entry, reads as "not moved" rather than breaking the event
|
|
152
|
+
function parseMovedDates(raw: unknown): CalEvent['movedDates'] {
|
|
153
|
+
let value: unknown;
|
|
154
|
+
try { value = JSON.parse(String(raw ?? '{}')); } catch { return {}; }
|
|
155
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
156
|
+
const out: CalEvent['movedDates'] = {};
|
|
157
|
+
for (const [key, entry] of Object.entries(value)) { const m = MovedDate.safeParse(entry); if (m.success) out[key] = m.data; }
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
151
160
|
function rowToEvent(r: Row): CalEvent {
|
|
152
161
|
return CalEvent.parse({
|
|
153
162
|
id: r.id, slug: r.slug, title: r.title, bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
|
|
@@ -155,7 +164,7 @@ function rowToEvent(r: Row): CalEvent {
|
|
|
155
164
|
seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
|
|
156
165
|
featured: Boolean(r.featured), tags: json(r.tags, []), template: r.template ?? '',
|
|
157
166
|
start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
|
|
158
|
-
rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []),
|
|
167
|
+
rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []), movedDates: parseMovedDates(r.moved_dates),
|
|
159
168
|
location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
|
|
160
169
|
fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
|
|
161
170
|
hostIds: json(r.host_ids, []), request: parseRequest(r.request),
|
|
@@ -848,8 +857,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
848
857
|
await this.driver.run(
|
|
849
858
|
`INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
850
859
|
starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
|
|
851
|
-
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations)
|
|
852
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
860
|
+
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations, moved_dates)
|
|
861
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
853
862
|
[
|
|
854
863
|
id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
|
|
855
864
|
data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
|
|
@@ -859,6 +868,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
859
868
|
data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
|
|
860
869
|
JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}), JSON.stringify(data.cancelledDates ?? []),
|
|
861
870
|
JSON.stringify(data.hostIds ?? []), data.request ? JSON.stringify(data.request) : null, JSON.stringify(data.translations ?? {}),
|
|
871
|
+
JSON.stringify(data.movedDates ?? {}),
|
|
862
872
|
]
|
|
863
873
|
);
|
|
864
874
|
await this.syncEventIndex(id);
|
|
@@ -883,7 +893,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
883
893
|
if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
|
|
884
894
|
}
|
|
885
895
|
const jsonCols: Record<string, string> = {
|
|
886
|
-
tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
|
|
896
|
+
tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', movedDates: 'moved_dates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
|
|
887
897
|
};
|
|
888
898
|
for (const [k, col] of Object.entries(jsonCols)) {
|
|
889
899
|
const v = (data as Record<string, unknown>)[k];
|
|
@@ -955,6 +965,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
955
965
|
await this.ready;
|
|
956
966
|
await this.driver.run('UPDATE events SET cancelled_dates = ?, updated_at = ? WHERE id = ?', [JSON.stringify(dates), Date.now(), id]);
|
|
957
967
|
}
|
|
968
|
+
async setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts: { quiet?: boolean } = {}) {
|
|
969
|
+
await this.ready;
|
|
970
|
+
const write = cancelledDates
|
|
971
|
+
? { sql: 'UPDATE events SET moved_dates = ?, cancelled_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), JSON.stringify(cancelledDates), Date.now(), id] }
|
|
972
|
+
: { sql: 'UPDATE events SET moved_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), Date.now(), id] };
|
|
973
|
+
// quiet: the change it records sends its webhook but no member push (the flag shares this transaction)
|
|
974
|
+
await this.driver.batch(opts.quiet ? [{ sql: 'UPDATE push_control SET quiet=1 WHERE id=1' }, write, { sql: 'UPDATE push_control SET quiet=0 WHERE id=1' }] : [write]);
|
|
975
|
+
}
|
|
958
976
|
async cancelOccurrenceRsvps(id: string, start: number) {
|
|
959
977
|
await this.ready;
|
|
960
978
|
// the mutation id limits the "cancelled" emails to exactly these RSVPs (as deleteEvent does)
|
|
@@ -966,12 +984,12 @@ export class SqlRepository implements ContentRepository {
|
|
|
966
984
|
]);
|
|
967
985
|
return Number((await this.first("SELECT COUNT(*) AS n FROM bookings WHERE event_id=? AND kind='rsvp' AND email_mutation=?", [id, mutation]))?.n ?? 0);
|
|
968
986
|
}
|
|
969
|
-
async moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }) {
|
|
987
|
+
async moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }, message = '') {
|
|
970
988
|
await this.ready;
|
|
971
989
|
const target = to.eventId || id;
|
|
972
990
|
// one mutation id marks every moved row; only the seated ones are emailed
|
|
973
991
|
const mutation = crypto.randomUUID();
|
|
974
|
-
const notifications = bookingJobs(target,'rescheduled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp' AND b.status IN ('pending','confirmed')")}));
|
|
992
|
+
const notifications = bookingJobs(target,'rescheduled',undefined,false,mutation,message).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp' AND b.status IN ('pending','confirmed')")}));
|
|
975
993
|
await this.driver.batch([
|
|
976
994
|
{sql:`UPDATE bookings SET event_id=?,start_at=?,end_at=?,hold_start=?,hold_end=?,notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=?
|
|
977
995
|
WHERE event_id=? AND kind='rsvp' AND start_at=? AND status IN ${RSVP_ACTIVE}`,args:[target,to.start,to.end,to.start,to.end,mutation,Date.now(),id,start]},
|
|
@@ -1906,15 +1924,15 @@ export class SqlRepository implements ContentRepository {
|
|
|
1906
1924
|
...(content.events ?? []).map((e) => ({
|
|
1907
1925
|
sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
1908
1926
|
starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
|
|
1909
|
-
draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request)
|
|
1910
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1927
|
+
draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, moved_dates)
|
|
1928
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1911
1929
|
args: [
|
|
1912
1930
|
e.id, e.slug, e.title, e.bodyJson, e.bodyHtml, e.excerpt, e.cover, e.coverAlt, e.seoTitle, e.seoDesc, e.status, e.featured ? 1 : 0,
|
|
1913
1931
|
JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
|
|
1914
1932
|
e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
|
|
1915
1933
|
e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
|
|
1916
1934
|
JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}), JSON.stringify(e.cancelledDates ?? []),
|
|
1917
|
-
JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null,
|
|
1935
|
+
JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null, JSON.stringify(e.movedDates ?? {}),
|
|
1918
1936
|
],
|
|
1919
1937
|
})),
|
|
1920
1938
|
// announcements (a backup made before them leaves the current ones). Restored
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -48,7 +48,7 @@ export type EventUpdate = Partial<
|
|
|
48
48
|
CalEvent,
|
|
49
49
|
| 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
|
|
50
50
|
| 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
|
|
51
|
-
| 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates'
|
|
51
|
+
| 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates' | 'movedDates'
|
|
52
52
|
| 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations' | 'roomHold'
|
|
53
53
|
| 'publishedAt' | 'scheduledAt'
|
|
54
54
|
>
|
|
@@ -168,15 +168,18 @@ export interface ContentRepository {
|
|
|
168
168
|
discardEventDraft(id: string): Promise<void>;
|
|
169
169
|
/** replace an event's cancelled occurrence keys at once, keeping any staged draft */
|
|
170
170
|
setEventCancelledDates(id: string, dates: string[]): Promise<void>;
|
|
171
|
+
/** replace an event's moved dates (and cancelled dates, when given) at once, keeping any staged draft; quiet: no member push for it */
|
|
172
|
+
setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts?: { quiet?: boolean }): Promise<void>;
|
|
171
173
|
/** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
|
|
172
174
|
cancelOccurrenceRsvps(id: string, start: number): Promise<number>;
|
|
173
175
|
/**
|
|
174
176
|
* Moves the RSVPs for one date of an event (those starting at `start`) to a
|
|
175
177
|
* new time, and to another event when `to.eventId` is set. Pending and
|
|
176
|
-
* confirmed ones get the "new time" email
|
|
178
|
+
* confirmed ones get the "new time" email (with `message`, when given);
|
|
179
|
+
* places in line move quietly.
|
|
177
180
|
* Returns how many moved.
|
|
178
181
|
*/
|
|
179
|
-
moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }): Promise<number>;
|
|
182
|
+
moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }, message?: string): Promise<number>;
|
|
180
183
|
deleteEvent(id: string): Promise<void>;
|
|
181
184
|
// saved-place sync (lib/maps/places.ts): rewrite just the location of the
|
|
182
185
|
// live row and/or the staged draft, leaving every other field — and the
|
package/src/lib/schema.ts
CHANGED
|
@@ -357,6 +357,24 @@ export const RoomHold = z.object({
|
|
|
357
357
|
});
|
|
358
358
|
export type RoomHold = z.infer<typeof RoomHold>;
|
|
359
359
|
|
|
360
|
+
// One date of a repeating event at another time (and maybe in other rooms or
|
|
361
|
+
// another place): CalEvent.movedDates, keyed by the date's ORIGINAL occurrence
|
|
362
|
+
// key. start/end are wall-clock in the event's zone like the event's own
|
|
363
|
+
// (dates for all-day events, end inclusive). Unset roomIds/location = the
|
|
364
|
+
// event's. note is public ("Moved for the youth retreat"); by is who moved it
|
|
365
|
+
// (a user id, or 'google-sync'), at when. lib/calendar/moves.ts writes these.
|
|
366
|
+
export const MovedDate = z.object({
|
|
367
|
+
start: z.string(),
|
|
368
|
+
end: z.string(),
|
|
369
|
+
roomIds: z.array(z.string()).optional(),
|
|
370
|
+
location: EventLocation.optional(),
|
|
371
|
+
note: z.string().max(500).default(''),
|
|
372
|
+
by: z.string().default(''),
|
|
373
|
+
at: z.number().default(0),
|
|
374
|
+
});
|
|
375
|
+
export type MovedDate = z.infer<typeof MovedDate>;
|
|
376
|
+
export const MovedDates = z.record(z.string(), MovedDate).default({});
|
|
377
|
+
|
|
360
378
|
// A member's event request (lib/calendar/requests.ts): a new event waiting
|
|
361
379
|
// for approval (kind 'new', the event stays a draft), or changes to a live
|
|
362
380
|
// event staged in its `draft` (kind 'changes'). Staff approve or decline;
|
|
@@ -407,6 +425,9 @@ export const CalEvent = z.object({
|
|
|
407
425
|
// cancelled: shown struck through, but they don't hold rooms. Keys that no
|
|
408
426
|
// longer match an occurrence (the series moved) are ignored.
|
|
409
427
|
cancelledDates: z.array(z.string()).default([]),
|
|
428
|
+
// single dates at another time (MovedDate above); a date is moved or
|
|
429
|
+
// cancelled, never both. Keys that no longer match an occurrence are ignored.
|
|
430
|
+
movedDates: MovedDates,
|
|
410
431
|
location: EventLocation.default({}),
|
|
411
432
|
cta: EventCta.default({}),
|
|
412
433
|
calendarIds: z.array(z.string()).default([]),
|
package/src/lib/sections.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface LinkValue { label: string; href: string; external: boolean }
|
|
|
32
32
|
export interface EventItem {
|
|
33
33
|
title: string; url: string; summary: string; image: string; imageAlt: string; location: string;
|
|
34
34
|
start: string; end: string; allDay: boolean; cancelled: boolean;
|
|
35
|
+
/** this date was moved here: from "Thu, Oct 1 · 6:00 PM" (where it was), with staff's public note; null when not moved */
|
|
36
|
+
moved: { from: string; note: string } | null;
|
|
35
37
|
/** "Sat, Oct 3 · 11:00 AM" */ when: string; /** "Saturday, October 3, 2026" */ date: string;
|
|
36
38
|
/** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
|
|
37
39
|
cta: { label: string; href: string } | null;
|
|
@@ -141,11 +143,12 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
|
|
|
141
143
|
return (await upcomingEvents(repo, settings, now, { calendarIds, limit, tag: cfg.tag, excludeTag: cfg.excludeTag })).map((o): EventItem => {
|
|
142
144
|
const e = lang ? localizeDoc(o.event, lang) : o.event;
|
|
143
145
|
const f = formatOccurrence(o, tz, locale);
|
|
146
|
+
const was = o.moved ? formatOccurrence({ ...o, start: o.moved.from.start, end: o.moved.from.end }, tz, locale) : null;
|
|
144
147
|
const ctaHref = safeUrl(e.cta.href);
|
|
145
148
|
return {
|
|
146
149
|
title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
|
|
147
|
-
location: locationLine(e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
148
|
-
allDay: o.allDay, cancelled: o.cancelled, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
|
|
150
|
+
location: locationLine(o.moved?.location ?? e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
151
|
+
allDay: o.allDay, cancelled: o.cancelled, moved: was && o.moved ? { from: o.allDay ? was.date : was.range, note: o.moved.note } : null, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
|
|
149
152
|
cta: e.cta.label && ctaHref && !o.cancelled ? { label: e.cta.label, href: ctaHref } : null,
|
|
150
153
|
};
|
|
151
154
|
});
|
package/src/lib/sync/catalog.ts
CHANGED
|
@@ -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 }));
|
|
@@ -94,6 +100,12 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
|
94
100
|
end: o.allDay ? isoWithOffset(o.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.end, etz),
|
|
95
101
|
allDay: o.allDay,
|
|
96
102
|
cancelled: o.cancelled,
|
|
103
|
+
// the date's key (a moved date keeps its original one), and where it moved from
|
|
104
|
+
key: o.key,
|
|
105
|
+
moved: o.moved ? {
|
|
106
|
+
from: { start: o.allDay ? isoWithOffset(o.moved.from.start, etz).slice(0, 10) : isoWithOffset(o.moved.from.start, etz), end: o.allDay ? isoWithOffset(o.moved.from.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.moved.from.end, etz) },
|
|
107
|
+
note: o.moved.note, location: o.moved.location ?? null,
|
|
108
|
+
} : null,
|
|
97
109
|
// that date's rooms
|
|
98
110
|
rooms: roomsToJson(o.roomIds, resources, full),
|
|
99
111
|
event: toJson(o.event.id),
|
|
@@ -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;
|