@acms-subir/core 0.3.45 → 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.
Files changed (36) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/cli/migrations.generated.mjs +117 -16
  3. package/docs/environment-sync.md +6 -0
  4. package/package.json +1 -1
  5. package/src/actions/index.ts +2 -4
  6. package/src/lib/calendar/agent.ts +3 -4
  7. package/src/lib/calendar/cards.ts +9 -6
  8. package/src/lib/calendar/change-migration.ts +65 -0
  9. package/src/lib/calendar/data.ts +25 -9
  10. package/src/lib/calendar/event-words.ts +124 -0
  11. package/src/lib/calendar/feeds.ts +11 -3
  12. package/src/lib/calendar/filter.ts +140 -0
  13. package/src/lib/calendar/hooks.ts +76 -31
  14. package/src/lib/calendar/render.ts +20 -16
  15. package/src/lib/calendar/requests.ts +2 -3
  16. package/src/lib/calendar/schedule.ts +4 -4
  17. package/src/lib/content-service.ts +15 -12
  18. package/src/lib/mobile/calendar.ts +27 -12
  19. package/src/lib/mobile/http.ts +5 -3
  20. package/src/lib/push/service.ts +1 -1
  21. package/src/lib/repo/migrations.ts +4 -0
  22. package/src/lib/repo/sql.ts +29 -0
  23. package/src/lib/repo/types.ts +21 -0
  24. package/src/lib/scheduler.ts +3 -0
  25. package/src/lib/sync/store.ts +4 -0
  26. package/src/middleware.ts +13 -2
  27. package/src/pages/admin/calendar/events/index.astro +4 -9
  28. package/src/pages/api/calendar/docs.ts +2 -0
  29. package/src/pages/api/calendar/events/[id].ts +3 -3
  30. package/src/pages/api/calendar/events/index.ts +18 -12
  31. package/src/pages/api/webhooks/docs.ts +5 -0
  32. package/src/pages/calendar/[slug].ics.ts +8 -2
  33. package/src/pages/calendar/feed.ics.ts +9 -2
  34. package/src/pages/calendar/index.astro +104 -48
  35. package/src/pages/embed/calendar/[slug].astro +29 -9
  36. package/src/styles/site.css +10 -0
@@ -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 { CALENDAR, calendarFeedUrl, calendarPageUrl } from '../../lib/calendar/config';
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 settings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
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 all = await repo.listCalendars();
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
- const calSlug = Astro.url.searchParams.get('cal') ?? '';
30
- // unlisted calendars work when linked directly, they just aren't offered
31
- const active = calSlug ? all.find((c) => c.slug === calSlug && c.visibility !== 'private') ?? null : null;
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 = Boolean(viewParam);
35
- const monthParam = /^(\d{4})-(\d{2})$/.exec(Astro.url.searchParams.get('m') ?? '');
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 calendarHtml = renderCalendarView(events, settings, now, {
60
+ const gridHtml = renderCalendarView(events, settings, now, {
44
61
  view: view === 'list' ? 'upcoming' : view,
45
62
  limit: 50,
46
- multi: !active && calendars.length > 1,
47
- emptyText: 'Nothing on the calendar yet.',
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
- const feed = origin + calendarFeedUrl(active?.slug);
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 viewHref = (v: string) => {
87
- const p = new URLSearchParams(Astro.url.search);
88
- p.set('view', v);
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
- {(listed.length > 1 || active) && (
106
- <nav class="pc-calpage-chips" aria-label="Calendars">
107
- <a class={!active ? 'is-on' : ''} href={CALENDAR.base} aria-current={!active ? 'page' : undefined}>All</a>
108
- {listed.map((c) => (
109
- <a class={active?.id === c.id ? 'is-on' : ''} href={calendarPageUrl(c.slug)} aria-current={active?.id === c.id ? 'page' : undefined}>
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="View">
118
- <a class={view === 'month' ? 'is-on' : ''} href={viewHref('month')}>Month</a>
119
- <a class={view === 'week' ? 'is-on' : ''} href={viewHref('week')}>Week</a>
120
- <a class={view === 'list' ? 'is-on' : ''} href={viewHref('list')}>List</a>
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
- Subscribe
168
+ {cw.subscribe}
126
169
  </summary>
127
170
  <div class="pc-calpage-sub-menu">
128
- <p>Add {active ? `“${active.name}”` : 'this calendar'} to your calendar app. New events show up on their own.</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}>Copy link</button>
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
- <div class="pc-calpage-strip" aria-label="Next two weeks">
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="Agenda">
149
- {agenda.size === 0 ? <p class="pc-cal-empty">Nothing in the next two months.</p> : [...agenda.entries()].map(([k, list]) => (
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 ? 'All day' : timeLabel.format(v.start)}</span>
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>Coming up</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>Times in {zoneName}</span>
175
- {errors.length > 0 && <span>Some linked calendars couldn’t be loaded right now.</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 = 'Link copied';
184
- setTimeout(() => (b.textContent = 'Copy link'), 1400);
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;
@@ -1,36 +1,56 @@
1
1
  ---
2
2
  import Site from '../../../layouts/Site.astro';
3
3
  import { getRepo } from '../../../lib/repo';
4
- import { CALENDAR } from '../../../lib/calendar/config';
4
+ import { siteLang } from '../../../lib/dates';
5
+ import { I18N, localePath } from '../../../lib/i18n';
6
+ import { isExtraLocale } from '../../../lib/translations';
7
+ import { eventStrings } from '../../../lib/calendar/event-words';
8
+ import { CALENDAR, eventUrl } from '../../../lib/calendar/config';
5
9
  import { calendarWindow, renderCalendarView } from '../../../lib/calendar/render';
6
10
  import { collectViewEvents } from '../../../lib/calendar/data';
7
11
  import { upcomingEvents, eventCardHtml } from '../../../lib/calendar/cards';
12
+ import { readEventFilter, resolveEventFilter } from '../../../lib/calendar/filter';
8
13
 
9
14
  // Embeddable calendar for other websites:
10
15
  // <iframe src="https://site/embed/calendar/all?view=month" …>
11
16
  // slug 'all' = every public calendar. view: month | week | list | cards.
12
- // Links open in the parent window; height is posted to the parent so a
13
- // small script can auto-size the iframe.
17
+ // The /calendar page's filters work here too (?tag=Class, ?notag=,
18
+ // ?cal=a,b to add calendars, ?room=), and ?lang=es picks one of the site's
19
+ // languages (embeds are never under /es). Links open in the parent window;
20
+ // height is posted to the parent so a small script can auto-size the iframe.
14
21
  if (!CALENDAR.enabled) return new Response(null, { status: 404 });
15
22
  const repo = await getRepo(Astro.locals);
16
- const settings = await repo.getSettings();
23
+ const base = await repo.getSettings();
17
24
  const slug = Astro.params.slug ?? 'all';
18
25
  const cal = slug === 'all' ? null : await repo.getCalendarBySlug(slug);
19
26
  if (slug !== 'all' && (!cal || cal.visibility === 'private')) return new Response(null, { status: 404 });
27
+ const langParam = Astro.url.searchParams.get('lang') ?? '';
28
+ const reqLocale = I18N.enabled ? (I18N.locales.includes(langParam) ? langParam : I18N.defaultLocale) : '';
29
+ // the layout's <html lang> reads the request's language
30
+ if (reqLocale) (Astro.locals as { acmsLocale?: string }).acmsLocale = reqLocale;
31
+ const settings = isExtraLocale(reqLocale) ? { ...base, locale: reqLocale } : base;
32
+ const lang = reqLocale || siteLang(settings);
33
+ const w = eventStrings(lang).calendar;
34
+
35
+ const query = readEventFilter(Astro.url.searchParams);
36
+ const filter = cal ? { ...query, cal: [cal.slug, ...query.cal] } : query;
37
+ const [calendars, resources] = await Promise.all([repo.listCalendars(), filter.room.length ? repo.listResources() : []]);
38
+ const resolved = resolveEventFilter(filter, { calendars, resources });
20
39
  const view = (['month', 'week', 'list', 'cards'] as const).find((v) => v === Astro.url.searchParams.get('view')) ?? 'month';
21
40
  const now = Date.now();
22
41
  let html: string;
23
42
  if (view === 'cards') {
24
- const items = await upcomingEvents(repo, settings, now, { calendarIds: cal ? [cal.id] : undefined, limit: Math.min(12, Number(Astro.url.searchParams.get('limit')) || 6) });
25
- html = `<section class="pc-ev-cards" data-layout="grid">${items.map((o) => eventCardHtml(o, settings, { showImage: true })).join('') || '<p class="pc-ev-empty">No upcoming events.</p>'}</section>`;
43
+ const items = await upcomingEvents(repo, settings, now, { filter: resolved, locale: lang, limit: Math.min(12, Number(Astro.url.searchParams.get('limit')) || 6) });
44
+ const cards = items.map((o) => eventCardHtml(o, settings, { showImage: true, href: localePath(eventUrl(o.event.slug), reqLocale) })).join('');
45
+ html = '<section class="pc-ev-cards" data-layout="grid">' + (cards || '<p class="pc-ev-empty">' + w.noUpcoming + '</p>') + '</section>';
26
46
  } else {
27
- const { events, calendars } = await collectViewEvents(repo, settings, calendarWindow(settings, now), { audience: 'public', ...(cal ? { calendarIds: [cal.id] } : {}) });
28
- html = renderCalendarView(events, settings, now, { view: view === 'list' ? 'upcoming' : view, limit: 30, multi: !cal && calendars.length > 1 });
47
+ const { events, calendars: shown } = await collectViewEvents(repo, settings, calendarWindow(settings, now), { audience: 'public', filter: resolved, locale: lang });
48
+ html = renderCalendarView(events, settings, now, { view: view === 'list' ? 'upcoming' : view, limit: 30, multi: shown.length > 1 });
29
49
  }
30
50
  Astro.response.headers.set('Content-Security-Policy', "frame-ancestors *");
31
51
  ---
32
52
 
33
- <Site title={cal?.name ?? 'Calendar'} bare noindex>
53
+ <Site title={cal?.name ?? w.title} bare noindex>
34
54
  <base target="_top" />
35
55
  <div class="pc-embed" set:html={html} />
36
56
  <script is:inline>
@@ -930,6 +930,16 @@ li::marker { color: var(--muted); }
930
930
  .pc-calpage-chips a:hover { color: var(--ink); }
931
931
  .pc-calpage-chips a.is-on { color: var(--ink); font-weight: 600; box-shadow: inset 0 -2px 0 var(--ink); }
932
932
  .pc-calpage-chips i { width: 7px; height: 7px; border-radius: 2px; }
933
+ /* active filters from the address (tag, left-out tag, room): removable chips */
934
+ .pc-calpage-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: -2px 0 16px; }
935
+ .pc-calpage-filter {
936
+ display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 8px 0 11px; border-radius: var(--r-sm);
937
+ background: var(--soft); color: var(--ink); font-size: 13px; font-weight: 550; text-decoration: none;
938
+ }
939
+ .pc-calpage-filter svg { width: 13px; height: 13px; color: var(--muted); }
940
+ .pc-calpage-filter:hover svg { color: var(--ink); }
941
+ .pc-calpage-clear { padding: 0 6px; font-size: 13px; color: var(--muted); text-decoration: none; }
942
+ .pc-calpage-clear:hover { color: var(--ink); }
933
943
  .pc-calpage-tools { display: flex; gap: 8px; align-items: center; margin-left: auto; }
934
944
  .pc-calpage-views { display: inline-flex; background: var(--soft); border-radius: 10px; padding: 3px; }
935
945
  .pc-calpage-views a { padding: 5px 12px; border-radius: 7px; font-size: 13px; font-weight: 550; color: var(--muted); text-decoration: none; }