diff --git a/src/components/AvatarPile.astro b/src/components/AvatarPile.astro new file mode 100644 index 0000000..615fc01 --- /dev/null +++ b/src/components/AvatarPile.astro @@ -0,0 +1,56 @@ +--- +import Avatar from './Avatar.astro'; +import type { UserPublic } from '../lib/db'; + +interface Props { + users: UserPublic[]; + max?: number; + size?: number; + /** Border color between overlapping avatars — defaults to --surface for cream surfaces. */ + borderColor?: string; +} + +const { users, max = 5, size = 22, borderColor = 'var(--surface)' } = Astro.props; + +const shown = users.slice(0, max); +const overflow = Math.max(0, users.length - shown.length); +--- +
+ {shown.map((u, i) => ( + + + + ))} + {overflow > 0 && ( + + +{overflow} + + )} +
+ + diff --git a/src/lib/format.ts b/src/lib/format.ts index 07d7583..7126bc1 100644 Binary files a/src/lib/format.ts and b/src/lib/format.ts differ diff --git a/src/pages/events.astro b/src/pages/events.astro new file mode 100644 index 0000000..e2da838 --- /dev/null +++ b/src/pages/events.astro @@ -0,0 +1,529 @@ +--- +import AppLayout from '../layouts/AppLayout.astro'; +import AvatarPile from '../components/AvatarPile.astro'; +import { + getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees, + getEventRsvpCount, getUserRsvp, setEventRsvp, recordActivity, +} from '../lib/db'; +import { eventKindLabel, defaultActionLabel, pigmentForId } from '../lib/format'; + +const user = Astro.locals.user; + +// ── POST: RSVP ────────────────────────────────────────────────────── +if (Astro.request.method === 'POST') { + const data = await Astro.request.formData(); + const action = String(data.get('action') ?? ''); + if (action === 'rsvp') { + const slug = String(data.get('event_slug') ?? ''); + const status = String(data.get('status') ?? '') as 'yes' | 'no' | 'interested'; + if (slug && ['yes', 'no', 'interested'].includes(status)) { + const ev = getEventBySlug(slug); + if (ev) { + setEventRsvp(user.id, slug, status); + recordActivity(user.id, 'rsvped', 'event', ev.id); + } + } + return Astro.redirect('/events'); + } +} + +// ── Data ─────────────────────────────────────────────────────────── +const upcoming = getUpcomingEvents(20); +const hero = upcoming.find(e => e.kind !== 'office_hours') ?? null; +const alsoUpcoming = upcoming.filter(e => e.id !== hero?.id); +const past = getPastEvents(8); + +function parseUtc(s: string): Date { + if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s); + return new Date(s.replace(' ', 'T') + 'Z'); +} + +function fmt(part: Intl.DateTimeFormatOptions, iso: string): string { + return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso)); +} +function dayNum(iso: string) { return fmt({ day: 'numeric' }, iso); } +function weekday(iso: string) { return fmt({ weekday: 'short' }, iso).toUpperCase(); } +function monthShort(iso: string) { return fmt({ month: 'short' }, iso).toUpperCase(); } +function timeStr(iso: string) { return fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, iso); } + +const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : []; +const heroConfirmedCount = heroAttendees.length; +const heroMyRsvp = hero ? getUserRsvp(user.id, hero.slug) : null; +const heroAudience = hero?.audience ?? 'Members only'; +--- + +
+ +
+

Events

+

Where the council gathers.

+

Dinners, working sessions, the occasional summit. Always small, always off the record.

+
+ + + {hero ? ( +
+
+ Next up · {heroAudience} + Invitation by hand +
+ +
+
+ {weekday(hero.starts_at)} + {dayNum(hero.starts_at)} + {monthShort(hero.starts_at)} +
+ +
+

{hero.title}

+

{hero.description}

+

{hero.location}{hero.location && ' · '}{timeStr(hero.starts_at)}{hero.duration_label ? ` · ${hero.duration_label}` : ''}

+
+
+ +
+
+ + {hero.capacity ? `${hero.capacity} seats · ` : ''}{heroConfirmedCount} confirmed + + {heroAttendees.length > 0 && ( + + )} +
+ +
+ + + {heroMyRsvp === 'yes' ? ( + <> + You're confirmed ✓ + + + ) : ( + + )} +
+
+
+ ) : ( +
+

+ Nothing scheduled yet — when we have something, you'll be the first to know. +

+
+ )} + + + {alsoUpcoming.length > 0 && ( +
+

Also coming up

+
    + {alsoUpcoming.map(ev => ( +
  • +
    + {dayNum(ev.starts_at)} + {monthShort(ev.starts_at)} +
    +
    +

    {ev.title}

    +

    + {[ev.duration_label, ev.audience, ev.location].filter(Boolean).join(' · ') + || eventKindLabel(ev.kind)} +

    +
    +
    + + + +
    +
  • + ))} +
+
+ )} + + + {past.length > 0 && ( +
+
+

Past gatherings

+ View all → +
+
    + {past.map(ev => { + const monthCode = monthShort(ev.starts_at); + const attended = getEventRsvpCount(ev.slug).going; + const hasNotes = !!ev.notes_url; + const pigA = pigmentForId(ev.id); + const pigB = pigmentForId(ev.id + 1); + return ( +
  • + {ev.photo_url ? ( + + ) : hasNotes ? ( + + {monthCode} + + ) : ( + + )} +
    +

    {ev.title}

    +

    {fmt({ day: 'numeric', month: 'long', year: 'numeric' }, ev.starts_at)}{ev.location && ` · ${ev.location}`}

    +

    + {attended} attended · {hasNotes ? 'Notes shared' : 'No notes'} +

    +
    +
  • + ); + })} +
+
+ )} + +
+
+ + diff --git a/src/pages/events/past.astro b/src/pages/events/past.astro new file mode 100644 index 0000000..63529f9 --- /dev/null +++ b/src/pages/events/past.astro @@ -0,0 +1,171 @@ +--- +import AppLayout from '../../layouts/AppLayout.astro'; +import { getPastEvents, getEventRsvpCount } from '../../lib/db'; +import { pigmentForId } from '../../lib/format'; + +const user = Astro.locals.user; +const past = getPastEvents(500); + +function parseUtc(s: string): Date { + if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s); + return new Date(s.replace(' ', 'T') + 'Z'); +} +function fmt(part: Intl.DateTimeFormatOptions, iso: string): string { + return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso)); +} +--- + +
+ +
+

Past gatherings

+

The archive.

+

Everything the council has gathered around so far.

+ ← Back to upcoming +
+ + {past.length === 0 ? ( +

No past events yet.

+ ) : ( + + )} + +
+
+ +