From a800bea7c66367ae8f1dceb9a52f2d3ac07e3311 Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Fri, 31 Jul 2026 19:41:22 +0100 Subject: [PATCH 1/4] feat(events): events page with atproto lexicons Adds an events feature backed by atproto records: - vendors the community calendar lexicons (community.lexicon.calendar.event/rsvp, location.address/geo) and adds npmx extensions (dev.npmx.calendar.talk for recordings/slides/speakers, dev.npmx.calendar.eventMeta for cover/kind/tags/hosts) - events list with past/upcoming split + kind badge filters, compact date ranges, cover images and attendee avatars - event detail: schedule, talks (Watch/Slides/PDF), masonry photo gallery with a lightbox, bsky social embed, attending, hosts, related events - reserves /events in canonical-redirects so bare names don't resolve to packages - seed data (real npmx meetup photos) behind useEvents() with a swap-to-PDS seam --- app/components/AppFooter.vue | 4 + app/components/Events/Card.vue | 99 +++++++ app/components/Events/Gallery.vue | 129 +++++++++ app/components/Events/TalkRow.vue | 50 ++++ .../useCommandPaletteGlobalCommands.ts | 13 + app/composables/useEvents.ts | 35 +++ app/pages/events/[slug].vue | 194 ++++++++++++++ app/pages/events/index.vue | 94 +++++++ app/types/events.ts | 78 ++++++ app/utils/events/format.ts | 40 +++ app/utils/events/seed.data.ts | 251 ++++++++++++++++++ i18n/locales/en.json | 39 ++- .../community/lexicon/calendar/event.json | 143 ++++++++++ lexicons/community/lexicon/calendar/rsvp.json | 42 +++ .../community/lexicon/location/address.json | 39 +++ lexicons/community/lexicon/location/geo.json | 26 ++ lexicons/dev/npmx/calendar/eventMeta.json | 77 ++++++ lexicons/dev/npmx/calendar/talk.json | 102 +++++++ .../middleware/canonical-redirects.global.ts | 1 + test/unit/a11y-component-coverage.spec.ts | 4 + test/unit/events-format.spec.ts | 43 +++ 21 files changed, 1502 insertions(+), 1 deletion(-) create mode 100644 app/components/Events/Card.vue create mode 100644 app/components/Events/Gallery.vue create mode 100644 app/components/Events/TalkRow.vue create mode 100644 app/composables/useEvents.ts create mode 100644 app/pages/events/[slug].vue create mode 100644 app/pages/events/index.vue create mode 100644 app/types/events.ts create mode 100644 app/utils/events/format.ts create mode 100644 app/utils/events/seed.data.ts create mode 100644 lexicons/community/lexicon/calendar/event.json create mode 100644 lexicons/community/lexicon/calendar/rsvp.json create mode 100644 lexicons/community/lexicon/location/address.json create mode 100644 lexicons/community/lexicon/location/geo.json create mode 100644 lexicons/dev/npmx/calendar/eventMeta.json create mode 100644 lexicons/dev/npmx/calendar/talk.json create mode 100644 test/unit/events-format.spec.ts diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue index 4580671cd3..2f78a61022 100644 --- a/app/components/AppFooter.vue +++ b/app/components/AppFooter.vue @@ -50,6 +50,10 @@ const footerSections = computed>(( name: t('footer.blog'), href: '/blog', }, + { + name: t('nav.events'), + href: '/events', + }, { name: t('footer.about'), href: '/about', diff --git a/app/components/Events/Card.vue b/app/components/Events/Card.vue new file mode 100644 index 0000000000..4ee38264ed --- /dev/null +++ b/app/components/Events/Card.vue @@ -0,0 +1,99 @@ + + + diff --git a/app/components/Events/Gallery.vue b/app/components/Events/Gallery.vue new file mode 100644 index 0000000000..c7dbaeaa66 --- /dev/null +++ b/app/components/Events/Gallery.vue @@ -0,0 +1,129 @@ + + + diff --git a/app/components/Events/TalkRow.vue b/app/components/Events/TalkRow.vue new file mode 100644 index 0000000000..67ac9f4611 --- /dev/null +++ b/app/components/Events/TalkRow.vue @@ -0,0 +1,50 @@ + + + diff --git a/app/composables/useCommandPaletteGlobalCommands.ts b/app/composables/useCommandPaletteGlobalCommands.ts index 0ba7af3c6e..299ea1ff2a 100644 --- a/app/composables/useCommandPaletteGlobalCommands.ts +++ b/app/composables/useCommandPaletteGlobalCommands.ts @@ -347,6 +347,19 @@ export function useCommandPaletteGlobalCommands() { ), to: { name: 'blog' }, }, + { + id: 'events', + group: 'npmx', + label: t('events.title'), + keywords: [t('events.title'), t('events.talks')], + iconClass: 'i-lucide:calendar', + active: route.name === 'events' || `${route.name ?? ''}`.startsWith('events-'), + activeLabel: activeLabel( + route.name === 'events' || `${route.name ?? ''}`.startsWith('events-'), + t('command_palette.here'), + ), + to: { name: 'events' }, + }, { id: 'noodles', group: 'npmx', diff --git a/app/composables/useEvents.ts b/app/composables/useEvents.ts new file mode 100644 index 0000000000..22dbdf9671 --- /dev/null +++ b/app/composables/useEvents.ts @@ -0,0 +1,35 @@ +import type { EventDetail, EventKind, EventSummary } from '~/types/events' +import { isPastEvent } from '~/utils/events/format' +import { SEED_EVENTS } from '~/utils/events/seed.data' + +export function useEvents() { + const { locale } = useI18n() + + const all = computed(() => SEED_EVENTS) + + const upcoming = computed(() => + all.value.filter(e => !isPastEvent(e)).sort((a, b) => a.startsAt.localeCompare(b.startsAt)), + ) + + const past = computed(() => + all.value.filter(e => isPastEvent(e)).sort((a, b) => b.startsAt.localeCompare(a.startsAt)), + ) + + const kinds = computed(() => { + const set = new Set() + for (const e of all.value) set.add(e.kind) + return [...set] + }) + + function findBySlug(slug: string): EventDetail | undefined { + return all.value.find(e => e.slug === slug) + } + + function relatedTo(slug: string, limit = 3): EventSummary[] { + const current = findBySlug(slug) + if (!current) return [] + return all.value.filter(e => e.slug !== slug && e.kind === current.kind).slice(0, limit) + } + + return { all, upcoming, past, kinds, findBySlug, relatedTo, locale } +} diff --git a/app/pages/events/[slug].vue b/app/pages/events/[slug].vue new file mode 100644 index 0000000000..4132f7bc26 --- /dev/null +++ b/app/pages/events/[slug].vue @@ -0,0 +1,194 @@ + + + diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue new file mode 100644 index 0000000000..44f7f56f0c --- /dev/null +++ b/app/pages/events/index.vue @@ -0,0 +1,94 @@ + + + diff --git a/app/types/events.ts b/app/types/events.ts new file mode 100644 index 0000000000..008a7286a9 --- /dev/null +++ b/app/types/events.ts @@ -0,0 +1,78 @@ +export type EventKind = 'meetup' | 'theatre' | 'meeting' | 'conference' | 'talk' +export type EventMode = 'inperson' | 'virtual' | 'hybrid' +export type EventStatus = 'scheduled' | 'planned' | 'cancelled' | 'postponed' | 'rescheduled' + +export interface Attendee { + name: string + handle?: string + did?: string + avatar?: string +} + +export interface EventHost { + name: string + uri?: string +} + +export interface Speaker { + name: string + handle?: string + did?: string + avatar?: string +} + +export interface Talk { + id: string + title: string + abstract?: string + speakers: Speaker[] + startsAt?: string + watchUrl?: string + slidesUrl?: string + pdfUrl?: string +} + +export interface EventLocation { + name?: string + locality?: string + country?: string + lat?: string + lon?: string +} + +export interface EventLink { + uri: string + name?: string +} + +export interface GalleryImage { + url: string + alt?: string +} + +export interface EventSummary { + slug: string + name: string + description?: string + kind: EventKind + mode: EventMode + status: EventStatus + startsAt: string + endsAt?: string + cover?: string + location?: EventLocation + tags: string[] + attendees: Attendee[] + attendeeCount: number +} + +export interface EventDetail extends EventSummary { + hosts: EventHost[] + scheduleImage?: string + schedule?: Array<{ time: string; label: string }> + links: EventLink[] + registerUrl?: string + bskyPostUrl?: string + talks: Talk[] + gallery?: GalleryImage[] +} diff --git a/app/utils/events/format.ts b/app/utils/events/format.ts new file mode 100644 index 0000000000..a5443fc2c3 --- /dev/null +++ b/app/utils/events/format.ts @@ -0,0 +1,40 @@ +function sameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) +} + +export function formatEventDateRange(startsAt: string, endsAt?: string, locale = 'en'): string { + const start = new Date(startsAt) + const end = endsAt ? new Date(endsAt) : undefined + + const day = (d: Date) => new Intl.DateTimeFormat(locale, { day: 'numeric' }).format(d) + const dayMonth = (d: Date) => + new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short' }).format(d) + const full = (d: Date) => + new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', year: 'numeric' }).format(d) + const time = (d: Date) => + new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(d) + + if (!end || sameDay(start, end)) { + const base = full(start) + if (end && (start.getHours() !== end.getHours() || start.getMinutes() !== end.getMinutes())) { + return `${base} · ${time(start)}–${time(end)}` + } + return base + } + + const sameYear = start.getFullYear() === end.getFullYear() + const sameMonth = sameYear && start.getMonth() === end.getMonth() + + if (sameMonth) return `${day(start)}–${full(end)}` + if (sameYear) return `${dayMonth(start)} – ${full(end)}` + return `${full(start)} – ${full(end)}` +} + +export function isPastEvent(event: { startsAt: string; endsAt?: string }, now = new Date()): boolean { + const reference = event.endsAt ? new Date(event.endsAt) : new Date(event.startsAt) + return reference.getTime() < now.getTime() +} diff --git a/app/utils/events/seed.data.ts b/app/utils/events/seed.data.ts new file mode 100644 index 0000000000..6c5addca77 --- /dev/null +++ b/app/utils/events/seed.data.ts @@ -0,0 +1,251 @@ +import type { EventDetail } from '~/types/events' + +const AVATARS = { + a: '/blog/avatar/868d264bb8f2c10ec09365ae712ab4bf0323caa129e04d77876da88df09d7a02.jpg', + b: '/blog/avatar/ce38208f9a08dd02a8d22c67b51af0201848990165b705e9df98dd1bed4bcaad.jpg', + c: '/blog/avatar/57fe535feed8fba7a9112a94a9f5e23589e7fd02555aead6ad8e1dcb289dc46a.jpg', + d: '/blog/avatar/fab2b15f2d0926e983e63dc63b398eb5a5c57f2f7c744162945055ddc6cf412e.jpg', + e: '/blog/avatar/bfaa93a26e6ee803038d27575c92535acd1d94783917c5b39fe4583713bbe738.jpg', +} + +export const SEED_EVENTS: EventDetail[] = [ + { + slug: 'npmx-london-meetup', + name: 'npmx London Meetup', + description: + 'All the magic of npmx is achieved through its community — strong, active, and warm. And behind the development itself are real people and their encounters.', + kind: 'meetup', + mode: 'hybrid', + status: 'scheduled', + startsAt: '2026-06-19T18:00:00Z', + endsAt: '2026-06-19T21:00:00Z', + cover: + 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreibnhisre2l6ub6yamni425afvmuwqnelcgftuooeuyr247tbrhlgu', + location: { name: 'AG Grid HQ', locality: 'London', country: 'GB' }, + tags: ['meetup', 'community', 'atproto'], + hosts: [{ name: 'AG Grid', uri: 'https://www.ag-grid.com' }], + attendeeCount: 42, + attendees: [ + { name: 'Alex', handle: 'alex.npmx.dev', avatar: AVATARS.a }, + { name: 'Lope', handle: 'lope.npmx.dev', avatar: AVATARS.b }, + { name: 'Felix', handle: 'felixs.dev', avatar: AVATARS.c }, + { name: 'Patak', handle: 'patak.dev' }, + ], + schedule: [ + { time: '18:00', label: 'Socialising & Food' }, + { time: '19:00', label: 'Introductions & House Keeping' }, + { time: '19:15', label: 'Alex: Trust Network' }, + { time: '19:45', label: 'Break' }, + { time: '20:00', label: 'Panel: the future of npmx' }, + { time: '21:00', label: 'End & Pub Time' }, + ], + links: [ + { uri: 'https://youtube.com', name: 'Watch Live on YouTube' }, + { uri: 'https://stream.place', name: 'stream.place' }, + { uri: 'https://chat.npmx.dev', name: 'npmx community discord' }, + ], + bskyPostUrl: 'https://bsky.app/profile/ag-grid.bsky.social/post/3moo34mkdy22g', + gallery: [ + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreicrin7umxwhh54cybm4ilklexsvanwjtbq7yt6ujwayy2hvoohobe', + alt: 'The panel discussion begins, the audience takes their seats, and Matthias is already discussing something interesting with someone', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreibnhisre2l6ub6yamni425afvmuwqnelcgftuooeuyr247tbrhlgu', + alt: 'The panel discussion stage with the core team: Willow, James, Alex, Mattias (Patak), and Daniel', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreihoe4bm7v4l5uhvcaomssulrgjqlnuvasabnxqsjfwjzl5c52dpzq', + alt: 'Wonderful people standing during the break and discussing something good', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreifs5bwmoa5kqzddaqnm2yh4clpt6jg77x3nfip3b6k4g5ek65rtpm', + alt: 'A group of people standing to the side and calmly talking, seen from the seated area', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreig6nay6zf4uqpe66h7fb2dhlcgzdy2ijq6z6iyj56sxo7fs5wqtqe', + alt: 'People browsing the AG Grid merch table', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreihnqkt2h56ub5rqkfwr3e2hbzrptbisu3msxuoueu4ls65hrammpq', + alt: 'A break with people chatting, a pizza box in the foreground', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreiarek7vxnac2sywthyuplicjkq244pc6iytxgddti3s4qzagrfsou', + alt: 'Several small groups discussing technologies and stories across the room', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreihn3o5sen5f7zi5td4la2m453oyxbyipuadl6yfvvkm7wznlznycy', + alt: 'People still talking as the space returns to its original state, Mattias sharing stories', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreiamio6wifho455hd6ylakbxhz6e6bxl4bipsgjiu7qryw7twdhn3y', + alt: 'A table with leftover pizza, a discussion continuing in the background', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreifiy4yovbpjsx6ohz5q3luo24cmyu7tojsg53muiudoldl5k7mudy', + alt: 'The empty room after the event, chairs back in place and the last participant leaving', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreiejusc25bveorsukp7b6eyzu3kujeerkqqk4xqtq24gq2o7kjlwme', + alt: 'People socializing in groups around a table with pizza', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreieg473dkdpebirricskeepgm3w2elnda5kxnvnkdbrrnfdaybeh5q', + alt: 'Several attendees together in a single shot', + }, + ], + talks: [ + { + id: 'trust-network', + title: 'Trust Network', + abstract: 'How npmx builds a web of trust on top of atproto identities.', + speakers: [{ name: 'Alex', handle: 'alex.npmx.dev', avatar: AVATARS.a }], + startsAt: '2026-06-19T19:15:00Z', + watchUrl: 'https://youtube.com', + slidesUrl: 'https://speakerdeck.com', + pdfUrl: 'https://example.com/trust-network.pdf', + }, + { + id: 'future-of-npmx', + title: 'Panel: The Future of npmx', + abstract: 'The core team on where npmx goes next.', + speakers: [ + { name: 'Alex', handle: 'alex.npmx.dev', avatar: AVATARS.a }, + { name: 'Lope', handle: 'lope.npmx.dev', avatar: AVATARS.b }, + ], + startsAt: '2026-06-19T20:00:00Z', + watchUrl: 'https://youtube.com', + }, + ], + }, + { + slug: 'vienna-meetup-3', + name: 'npmx Vienna Meetup #3', + description: 'The third npmx Vienna meetup — talks, hallway track, and drinks after.', + kind: 'meetup', + mode: 'inperson', + status: 'scheduled', + startsAt: '2026-05-14T17:00:00Z', + endsAt: '2026-05-14T21:00:00Z', + cover: + 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreiakvo6fu563g3j7a6pcioeijq7ni7i462dboss24uerzeisuxrwj4', + location: { name: 'Vienna', locality: 'Vienna', country: 'AT', lat: '48.2082', lon: '16.3738' }, + tags: ['meetup', 'vienna'], + hosts: [{ name: 'Felix Schneider', uri: 'https://felixs.dev/events/' }], + attendeeCount: 28, + attendees: [ + { name: 'Felix', handle: 'felixs.dev', avatar: AVATARS.c }, + { name: 'Alex', handle: 'alex.npmx.dev', avatar: AVATARS.a }, + ], + schedule: [ + { time: '17:00', label: 'Doors & Socialising' }, + { time: '17:30', label: 'atproto 101' }, + { time: '18:15', label: 'Lightning talks' }, + { time: '19:00', label: 'Hallway track & drinks' }, + { time: '21:00', label: 'Wrap up' }, + ], + links: [{ uri: 'https://felixs.dev/events/', name: 'Event page' }], + gallery: [ + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreieg473dkdpebirricskeepgm3w2elnda5kxnvnkdbrrnfdaybeh5q', + alt: 'Several attendees together in a single shot', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreidavrldq6zigzwcdu46pgr4indo62ne3v5it2czlvota3igduofe4', + alt: 'Conversations among attendees, one talking about technology with passion', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreif7uwalqlhgqeizxw6ksivwofoy74vexkyfwejwestnyz3n353bmy', + alt: 'Attendees gathered at the stage listening to presentations', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreibj27mrk6v6b6gaaceged36uamfqi2womo3jar6ttxpejetd7dere', + alt: 'The audience watching a presentation intently, some taking photos', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreiakvo6fu563g3j7a6pcioeijq7ni7i462dboss24uerzeisuxrwj4', + alt: 'The opening talk on stage', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreibccpe3ys2zzdqw6t6b4n2hlnfbbvponzy2r6tvdgc2q5u2waq6t4', + alt: 'Empty chairs on stage at the end of the event', + }, + ], + talks: [ + { + id: 'atproto-101', + title: 'atproto 101', + abstract: 'A gentle intro to repos, lexicons and the firehose.', + speakers: [{ name: 'Felix', handle: 'felixs.dev', avatar: AVATARS.c }], + watchUrl: 'https://youtube.com', + slidesUrl: 'https://speakerdeck.com', + }, + ], + }, + { + slug: 'npmx-online-townhall', + name: 'npmx Online Town Hall', + description: 'A fully online town hall — roadmap updates and community Q&A.', + kind: 'conference', + mode: 'virtual', + status: 'scheduled', + startsAt: '2026-08-26T14:00:00Z', + endsAt: '2026-08-26T16:00:00Z', + cover: + 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:akon7og3z6ihjpoclxzjrglf/bafkreigc3zmk3ayfw2w55c4hhaoyhi35occrrneiehksa2eyo4il6vtvui', + tags: ['conference', 'online'], + hosts: [{ name: 'npmx', uri: 'https://npmx.dev' }], + attendeeCount: 113, + attendees: [ + { name: 'Lope', handle: 'lope.npmx.dev', avatar: AVATARS.b }, + { name: 'Alex', handle: 'alex.npmx.dev', avatar: AVATARS.a }, + { name: 'Dana', handle: 'dana.example', avatar: AVATARS.d }, + { name: 'Sam', handle: 'sam.example', avatar: AVATARS.e }, + ], + schedule: [ + { time: '14:00', label: 'Welcome & intros' }, + { time: '14:15', label: 'Roadmap update' }, + { time: '15:00', label: 'Community Q&A' }, + { time: '15:45', label: 'Open floor' }, + ], + links: [{ uri: 'https://npmx.dev', name: 'Register' }], + registerUrl: 'https://npmx.dev', + gallery: [ + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreicsxn7blppolvtrf6w5kxc5hxuv2454licb3c5jcx6njygenvax2u', + alt: 'A screen showing "npmx London meetup #2"', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreigk7hqvly7rfn3aldhtdrgx7a4yhf5ydraj6igiymv7zeaanjswsq', + alt: 'A group of people talking together', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreiczqaiflwnokgljuazi5dpmm3hq4hhpclrywp52rrd3v2k37peqia', + alt: 'Empty chairs arranged in a semicircle before the event', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreifka6pryqaxkuxqa43h4uiucxc4dln4gergjspqclatngdjefzjey', + alt: 'A conversation at the edge of a table with pizza boxes', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreih5ilqvdk2grb5glwx76rfav5nhfaxl5tuwwkzot2fuxciyi4enfu', + alt: 'Patak chatting with James while Willow sets things up', + }, + { + url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:akon7og3z6ihjpoclxzjrglf/bafkreigda6qfmea4yyvvlwf7rckpdzflp6fwe6gixugqchgs27i3qtnxpm', + alt: 'Alex presenting a talk on trust', + }, + ], + talks: [ + { + id: 'roadmap', + title: 'npmx Roadmap', + abstract: 'Where npmx is headed over the next few months.', + speakers: [{ name: 'Lope', handle: 'lope.npmx.dev', avatar: AVATARS.b }], + watchUrl: 'https://youtube.com', + slidesUrl: 'https://speakerdeck.com', + }, + ], + }, +] diff --git a/i18n/locales/en.json b/i18n/locales/en.json index a09e22b59f..e25b1dc5de 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -212,7 +212,44 @@ "mobile_menu": "Navigation menu", "open_menu": "Open menu", "links": "Links", - "tap_to_search": "Tap to search" + "tap_to_search": "Tap to search", + "events": "events" + }, + "events": { + "title": "events", + "meta_description": "npmx community events, meetups, talks and recordings.", + "intro": "All the magic of npmx is achieved through its community — strong, active, and warm. And behind the development itself are real people and their encounters.", + "upcoming": "Upcoming", + "past": "Past", + "empty": "No events yet.", + "filter_all": "all", + "kind": { + "meetup": "meetup", + "theatre": "theatre", + "meeting": "meeting", + "conference": "conference", + "talk": "talk" + }, + "mode": { + "online": "online", + "in_person": "in person", + "hybrid": "hybrid" + }, + "about": "About", + "location": "Location", + "talks": "Talks", + "watch": "Watch", + "slides": "Slides", + "pdf": "PDF", + "gallery": "Gallery", + "gallery_prev": "Previous image", + "gallery_next": "Next image", + "social": "Social", + "related": "Related events", + "attending": "Attending", + "hosted_by": "Hosted by", + "register": "Register", + "back": "All events" }, "blog": { "title": "Blog", diff --git a/lexicons/community/lexicon/calendar/event.json b/lexicons/community/lexicon/calendar/event.json new file mode 100644 index 0000000000..657e947985 --- /dev/null +++ b/lexicons/community/lexicon/calendar/event.json @@ -0,0 +1,143 @@ +{ + "lexicon": 1, + "id": "community.lexicon.calendar.event", + "defs": { + "main": { + "type": "record", + "description": "A calendar event.", + "key": "tid", + "record": { + "type": "object", + "required": ["createdAt", "name"], + "properties": { + "name": { + "type": "string", + "description": "The name of the event." + }, + "description": { + "type": "string", + "description": "The description of the event." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event was created." + }, + "startsAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event starts." + }, + "endsAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event ends." + }, + "mode": { + "type": "ref", + "ref": "community.lexicon.calendar.event#mode", + "description": "The attendance mode of the event." + }, + "status": { + "type": "ref", + "ref": "community.lexicon.calendar.event#status", + "description": "The status of the event." + }, + "locations": { + "type": "array", + "description": "The locations where the event takes place.", + "items": { + "type": "union", + "refs": [ + "community.lexicon.calendar.event#uri", + "community.lexicon.location.address", + "community.lexicon.location.geo" + ] + } + }, + "uris": { + "type": "array", + "description": "URIs associated with the event.", + "items": { + "type": "ref", + "ref": "community.lexicon.calendar.event#uri" + } + }, + "rsvpExpected": { + "type": "boolean", + "description": "Whether a response is requested from attendees." + } + } + } + }, + "mode": { + "type": "string", + "description": "The mode of the event.", + "default": "community.lexicon.calendar.event#inperson", + "knownValues": [ + "community.lexicon.calendar.event#hybrid", + "community.lexicon.calendar.event#inperson", + "community.lexicon.calendar.event#virtual" + ] + }, + "virtual": { + "type": "token", + "description": "A virtual event that takes place online." + }, + "inperson": { + "type": "token", + "description": "An in-person event that takes place offline." + }, + "hybrid": { + "type": "token", + "description": "A hybrid event that takes place both online and offline." + }, + "status": { + "type": "string", + "description": "The status of the event.", + "default": "community.lexicon.calendar.event#scheduled", + "knownValues": [ + "community.lexicon.calendar.event#cancelled", + "community.lexicon.calendar.event#planned", + "community.lexicon.calendar.event#postponed", + "community.lexicon.calendar.event#rescheduled", + "community.lexicon.calendar.event#scheduled" + ] + }, + "planned": { + "type": "token", + "description": "The event has been created, but not finalized." + }, + "scheduled": { + "type": "token", + "description": "The event has been created and scheduled." + }, + "rescheduled": { + "type": "token", + "description": "The event has been rescheduled." + }, + "cancelled": { + "type": "token", + "description": "The event has been cancelled." + }, + "postponed": { + "type": "token", + "description": "The event has been postponed and a new start date has not been set." + }, + "uri": { + "type": "object", + "description": "A URI associated with the event.", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "uri" + }, + "name": { + "type": "string", + "description": "The display name of the URI." + } + } + } + } +} diff --git a/lexicons/community/lexicon/calendar/rsvp.json b/lexicons/community/lexicon/calendar/rsvp.json new file mode 100644 index 0000000000..4d338922b0 --- /dev/null +++ b/lexicons/community/lexicon/calendar/rsvp.json @@ -0,0 +1,42 @@ +{ + "lexicon": 1, + "id": "community.lexicon.calendar.rsvp", + "defs": { + "main": { + "type": "record", + "description": "An RSVP for an event.", + "key": "tid", + "record": { + "type": "object", + "required": ["subject", "status"], + "properties": { + "subject": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "status": { + "type": "string", + "default": "community.lexicon.calendar.rsvp#going", + "knownValues": [ + "community.lexicon.calendar.rsvp#interested", + "community.lexicon.calendar.rsvp#going", + "community.lexicon.calendar.rsvp#notgoing" + ] + } + } + } + }, + "interested": { + "type": "token", + "description": "Interested in the event" + }, + "going": { + "type": "token", + "description": "Going to the event" + }, + "notgoing": { + "type": "token", + "description": "Not going to the event" + } + } +} diff --git a/lexicons/community/lexicon/location/address.json b/lexicons/community/lexicon/location/address.json new file mode 100644 index 0000000000..2b4903f869 --- /dev/null +++ b/lexicons/community/lexicon/location/address.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "community.lexicon.location.address", + "defs": { + "main": { + "type": "object", + "description": "A physical location in the form of a street address.", + "required": ["country"], + "properties": { + "country": { + "type": "string", + "description": "The ISO 3166 country code. Preferably the 2-letter code.", + "minLength": 2, + "maxLength": 10 + }, + "postalCode": { + "type": "string", + "description": "The postal code of the location." + }, + "region": { + "type": "string", + "description": "The administrative region of the country. For example, a state in the USA." + }, + "locality": { + "type": "string", + "description": "The locality of the region. For example, a city in the USA." + }, + "street": { + "type": "string", + "description": "The street address." + }, + "name": { + "type": "string", + "description": "The name of the location." + } + } + } + } +} diff --git a/lexicons/community/lexicon/location/geo.json b/lexicons/community/lexicon/location/geo.json new file mode 100644 index 0000000000..bd703b8df9 --- /dev/null +++ b/lexicons/community/lexicon/location/geo.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "community.lexicon.location.geo", + "defs": { + "main": { + "type": "object", + "description": "A physical location in the form of a WGS84 coordinate.", + "required": ["latitude", "longitude"], + "properties": { + "latitude": { + "type": "string" + }, + "longitude": { + "type": "string" + }, + "altitude": { + "type": "string" + }, + "name": { + "type": "string", + "description": "The name of the location." + } + } + } + } +} diff --git a/lexicons/dev/npmx/calendar/eventMeta.json b/lexicons/dev/npmx/calendar/eventMeta.json new file mode 100644 index 0000000000..86dc7853a4 --- /dev/null +++ b/lexicons/dev/npmx/calendar/eventMeta.json @@ -0,0 +1,77 @@ +{ + "lexicon": 1, + "id": "dev.npmx.calendar.eventMeta", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "npmx-specific presentation metadata that decorates a community.lexicon.calendar.event. Kept separate so the underlying event record stays portable and standard.", + "record": { + "type": "object", + "required": ["event", "createdAt"], + "properties": { + "event": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "A strong reference to the community.lexicon.calendar.event this metadata decorates." + }, + "slug": { + "type": "string", + "description": "URL-friendly identifier used for the event page path.", + "maxLength": 512 + }, + "kind": { + "type": "string", + "description": "The npmx event category, used for filtering.", + "knownValues": ["meetup", "theatre", "meeting", "conference", "talk"] + }, + "cover": { + "type": "blob", + "accept": ["image/png", "image/jpeg", "image/webp"], + "maxSize": 2000000, + "description": "A cover image shown on event cards and the event header." + }, + "tags": { + "type": "array", + "maxLength": 12, + "items": { + "type": "string", + "maxLength": 640, + "maxGraphemes": 64 + } + }, + "hosts": { + "type": "array", + "description": "Organisations or people hosting the event.", + "items": { + "type": "ref", + "ref": "#host" + } + }, + "bskyPostRef": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "A Bluesky post to feature as the event's social embed." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + }, + "host": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + }, + "uri": { + "type": "string", + "format": "uri" + } + } + } + } +} diff --git a/lexicons/dev/npmx/calendar/talk.json b/lexicons/dev/npmx/calendar/talk.json new file mode 100644 index 0000000000..2654d97f1f --- /dev/null +++ b/lexicons/dev/npmx/calendar/talk.json @@ -0,0 +1,102 @@ +{ + "lexicon": 1, + "id": "dev.npmx.calendar.talk", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "A talk given at an npmx community event.", + "record": { + "type": "object", + "required": ["title", "createdAt"], + "properties": { + "event": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "A strong reference to the community.lexicon.calendar.event this talk was given at." + }, + "title": { + "type": "string", + "maxLength": 3000, + "maxGraphemes": 300 + }, + "abstract": { + "type": "string", + "maxLength": 30000, + "maxGraphemes": 3000, + "description": "A short summary of the talk." + }, + "speakers": { + "type": "array", + "description": "The people who gave the talk.", + "items": { + "type": "ref", + "ref": "#speaker" + } + }, + "startsAt": { + "type": "string", + "format": "datetime" + }, + "slides": { + "type": "ref", + "ref": "#asset", + "description": "The presentation slides, hosted as a blob or linked externally." + }, + "recording": { + "type": "ref", + "ref": "#asset", + "description": "A recording of the talk, hosted as a blob or linked externally." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + }, + "speaker": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "maxLength": 640, + "maxGraphemes": 64 + }, + "did": { + "type": "string", + "format": "did", + "description": "The speaker's atproto account, if they have one." + }, + "handle": { + "type": "string", + "format": "handle" + }, + "avatar": { + "type": "blob", + "accept": ["image/png", "image/jpeg"], + "maxSize": 1000000 + } + } + }, + "asset": { + "type": "object", + "description": "A media asset, either hosted as a blob or linked externally.", + "properties": { + "uri": { + "type": "string", + "format": "uri", + "description": "External link to the asset (e.g. YouTube, SpeakerDeck, Google Slides)." + }, + "blob": { + "type": "blob", + "description": "The hosted file." + }, + "name": { + "type": "string" + } + } + } + } +} diff --git a/server/middleware/canonical-redirects.global.ts b/server/middleware/canonical-redirects.global.ts index 738612c2a7..9b22a2e121 100644 --- a/server/middleware/canonical-redirects.global.ts +++ b/server/middleware/canonical-redirects.global.ts @@ -21,6 +21,7 @@ const pages = [ '/blog', '/brand', '/compare', + '/events', '/noodles', '/sponsors', '/org', diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts index d6f82a4d62..923ffe83e5 100644 --- a/test/unit/a11y-component-coverage.spec.ts +++ b/test/unit/a11y-component-coverage.spec.ts @@ -63,6 +63,10 @@ const SKIPPED_COMPONENTS: Record = { 'Translation/StatusByFile.unused.vue': 'Unused component, might be needed in the future', 'ColorScheme/Img.vue': 'Image component, basic ui', 'VideoPlayer.vue': 'Atproto video component, basic ui', + 'Events/Card.vue': 'Presentational event card - a NuxtLink wrapper, no interactive state', + 'Events/TalkRow.vue': 'Presentational talk row - external links only, no interactive state', + 'Events/Gallery.vue': + 'Masonry gallery with a Teleport lightbox, keyboard nav and scroll lock - requires full app context', } function normalizeComponentPath(filePath: string): string { diff --git a/test/unit/events-format.spec.ts b/test/unit/events-format.spec.ts new file mode 100644 index 0000000000..b1ce695528 --- /dev/null +++ b/test/unit/events-format.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { formatEventDateRange, isPastEvent } from '~/utils/events/format' + +describe('formatEventDateRange', () => { + it('collapses a same-day event to one line with a time range', () => { + const out = formatEventDateRange('2026-07-26T14:00:00Z', '2026-07-26T20:00:00Z', 'en-GB') + expect(out).toMatch(/2026/) + expect(out).toContain('·') + expect(out).toContain('–') + }) + + it('shows a compact day range for a multi-day, same-month event', () => { + const out = formatEventDateRange('2026-07-26T00:00:00Z', '2026-07-27T00:00:00Z', 'en-GB') + // e.g. "26–27 Jul 2026" — no time range, single year/month + expect(out).toMatch(/26.*27/) + expect(out).not.toContain('·') + }) + + it('spans months when needed', () => { + const out = formatEventDateRange('2026-07-30T00:00:00Z', '2026-08-02T00:00:00Z', 'en-GB') + expect(out).toMatch(/Jul/) + expect(out).toMatch(/Aug/) + }) + + it('falls back to a single date when there is no end', () => { + const out = formatEventDateRange('2026-07-26T14:00:00Z', undefined, 'en-GB') + expect(out).not.toContain('–') + }) +}) + +describe('isPastEvent', () => { + const now = new Date('2026-07-31T00:00:00Z') + + it('is past when the end date is before now', () => { + expect(isPastEvent({ startsAt: '2026-07-26T14:00:00Z', endsAt: '2026-07-26T20:00:00Z' }, now)).toBe( + true, + ) + }) + + it('is upcoming when the start date is after now', () => { + expect(isPastEvent({ startsAt: '2026-08-10T14:00:00Z' }, now)).toBe(false) + }) +}) From 5fe6e23c1743a434699ee4d05a6a52b177262f51 Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Fri, 31 Jul 2026 23:28:48 +0100 Subject: [PATCH 2/4] feat(events): read live events from npmx.social, add not-found page - /api/events reads real community.lexicon.calendar.event records from the npmx.dev account (did:plc:u5zp7...) on npmx.social via raw XRPC listRecords, maps mode/status/dates/uris and resolves the atmo.rsvp media thumbnail to a getBlob cover URL - useEvents() now fetches /api/events (SSR-blocking, shared key); seed removed - custom in-page event not-found state with a real 404 status, mirroring noodles --- app/composables/useEvents.ts | 9 +- app/pages/events/[slug].vue | 42 +++++- app/utils/events/seed.data.ts | 251 --------------------------------- i18n/locales/en.json | 7 +- server/api/events/index.get.ts | 93 ++++++++++++ 5 files changed, 140 insertions(+), 262 deletions(-) delete mode 100644 app/utils/events/seed.data.ts create mode 100644 server/api/events/index.get.ts diff --git a/app/composables/useEvents.ts b/app/composables/useEvents.ts index 22dbdf9671..554bca5dca 100644 --- a/app/composables/useEvents.ts +++ b/app/composables/useEvents.ts @@ -1,11 +1,14 @@ import type { EventDetail, EventKind, EventSummary } from '~/types/events' import { isPastEvent } from '~/utils/events/format' -import { SEED_EVENTS } from '~/utils/events/seed.data' export function useEvents() { const { locale } = useI18n() + const { data, pending, error } = useFetch('/api/events', { + key: 'events', + default: () => [], + }) - const all = computed(() => SEED_EVENTS) + const all = computed(() => data.value ?? []) const upcoming = computed(() => all.value.filter(e => !isPastEvent(e)).sort((a, b) => a.startsAt.localeCompare(b.startsAt)), @@ -31,5 +34,5 @@ export function useEvents() { return all.value.filter(e => e.slug !== slug && e.kind === current.kind).slice(0, limit) } - return { all, upcoming, past, kinds, findBySlug, relatedTo, locale } + return { all, pending, error, upcoming, past, kinds, findBySlug, relatedTo, locale } } diff --git a/app/pages/events/[slug].vue b/app/pages/events/[slug].vue index 4132f7bc26..d6057cb94d 100644 --- a/app/pages/events/[slug].vue +++ b/app/pages/events/[slug].vue @@ -2,15 +2,25 @@ import { formatEventDateRange } from '~/utils/events/format' const route = useRoute() +const slug = computed(() => String(route.params.slug)) + const { findBySlug, relatedTo, locale } = useEvents() +await useFetch('/api/events', { key: 'events', default: () => [] }) -const slug = computed(() => String(route.params.slug)) const event = computed(() => findBySlug(slug.value)) -if (!event.value) { - throw createError({ statusCode: 404, statusMessage: 'Event not found', fatal: true }) +if (import.meta.server && !event.value) { + const reqEvent = useRequestEvent() + if (reqEvent) setResponseStatus(reqEvent, 404) } +onMounted(() => { + if (!event.value) { + const reqEvent = useRequestEvent() + if (reqEvent) setResponseStatus(reqEvent, 404) + } +}) + const related = computed(() => relatedTo(slug.value)) const dateLabel = computed(() => event.value ? formatEventDateRange(event.value.startsAt, event.value.endsAt, locale.value) : '', @@ -32,15 +42,15 @@ const mapUrl = computed(() => { }) useSeoMeta({ - title: () => `${event.value?.name} - npmx`, - ogTitle: () => `${event.value?.name} - npmx`, - description: () => event.value?.description, + title: () => `${event.value?.name ?? $t('events.missing.title')} - npmx`, + ogTitle: () => `${event.value?.name ?? $t('events.missing.title')} - npmx`, + description: () => event.value?.description ?? $t('events.missing.body', { slug: slug.value }), ogDescription: () => event.value?.description, }) diff --git a/app/utils/events/seed.data.ts b/app/utils/events/seed.data.ts deleted file mode 100644 index 6c5addca77..0000000000 --- a/app/utils/events/seed.data.ts +++ /dev/null @@ -1,251 +0,0 @@ -import type { EventDetail } from '~/types/events' - -const AVATARS = { - a: '/blog/avatar/868d264bb8f2c10ec09365ae712ab4bf0323caa129e04d77876da88df09d7a02.jpg', - b: '/blog/avatar/ce38208f9a08dd02a8d22c67b51af0201848990165b705e9df98dd1bed4bcaad.jpg', - c: '/blog/avatar/57fe535feed8fba7a9112a94a9f5e23589e7fd02555aead6ad8e1dcb289dc46a.jpg', - d: '/blog/avatar/fab2b15f2d0926e983e63dc63b398eb5a5c57f2f7c744162945055ddc6cf412e.jpg', - e: '/blog/avatar/bfaa93a26e6ee803038d27575c92535acd1d94783917c5b39fe4583713bbe738.jpg', -} - -export const SEED_EVENTS: EventDetail[] = [ - { - slug: 'npmx-london-meetup', - name: 'npmx London Meetup', - description: - 'All the magic of npmx is achieved through its community — strong, active, and warm. And behind the development itself are real people and their encounters.', - kind: 'meetup', - mode: 'hybrid', - status: 'scheduled', - startsAt: '2026-06-19T18:00:00Z', - endsAt: '2026-06-19T21:00:00Z', - cover: - 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreibnhisre2l6ub6yamni425afvmuwqnelcgftuooeuyr247tbrhlgu', - location: { name: 'AG Grid HQ', locality: 'London', country: 'GB' }, - tags: ['meetup', 'community', 'atproto'], - hosts: [{ name: 'AG Grid', uri: 'https://www.ag-grid.com' }], - attendeeCount: 42, - attendees: [ - { name: 'Alex', handle: 'alex.npmx.dev', avatar: AVATARS.a }, - { name: 'Lope', handle: 'lope.npmx.dev', avatar: AVATARS.b }, - { name: 'Felix', handle: 'felixs.dev', avatar: AVATARS.c }, - { name: 'Patak', handle: 'patak.dev' }, - ], - schedule: [ - { time: '18:00', label: 'Socialising & Food' }, - { time: '19:00', label: 'Introductions & House Keeping' }, - { time: '19:15', label: 'Alex: Trust Network' }, - { time: '19:45', label: 'Break' }, - { time: '20:00', label: 'Panel: the future of npmx' }, - { time: '21:00', label: 'End & Pub Time' }, - ], - links: [ - { uri: 'https://youtube.com', name: 'Watch Live on YouTube' }, - { uri: 'https://stream.place', name: 'stream.place' }, - { uri: 'https://chat.npmx.dev', name: 'npmx community discord' }, - ], - bskyPostUrl: 'https://bsky.app/profile/ag-grid.bsky.social/post/3moo34mkdy22g', - gallery: [ - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreicrin7umxwhh54cybm4ilklexsvanwjtbq7yt6ujwayy2hvoohobe', - alt: 'The panel discussion begins, the audience takes their seats, and Matthias is already discussing something interesting with someone', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreibnhisre2l6ub6yamni425afvmuwqnelcgftuooeuyr247tbrhlgu', - alt: 'The panel discussion stage with the core team: Willow, James, Alex, Mattias (Patak), and Daniel', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreihoe4bm7v4l5uhvcaomssulrgjqlnuvasabnxqsjfwjzl5c52dpzq', - alt: 'Wonderful people standing during the break and discussing something good', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreifs5bwmoa5kqzddaqnm2yh4clpt6jg77x3nfip3b6k4g5ek65rtpm', - alt: 'A group of people standing to the side and calmly talking, seen from the seated area', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreig6nay6zf4uqpe66h7fb2dhlcgzdy2ijq6z6iyj56sxo7fs5wqtqe', - alt: 'People browsing the AG Grid merch table', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreihnqkt2h56ub5rqkfwr3e2hbzrptbisu3msxuoueu4ls65hrammpq', - alt: 'A break with people chatting, a pizza box in the foreground', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreiarek7vxnac2sywthyuplicjkq244pc6iytxgddti3s4qzagrfsou', - alt: 'Several small groups discussing technologies and stories across the room', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreihn3o5sen5f7zi5td4la2m453oyxbyipuadl6yfvvkm7wznlznycy', - alt: 'People still talking as the space returns to its original state, Mattias sharing stories', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreiamio6wifho455hd6ylakbxhz6e6bxl4bipsgjiu7qryw7twdhn3y', - alt: 'A table with leftover pizza, a discussion continuing in the background', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreifiy4yovbpjsx6ohz5q3luo24cmyu7tojsg53muiudoldl5k7mudy', - alt: 'The empty room after the event, chairs back in place and the last participant leaving', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreiejusc25bveorsukp7b6eyzu3kujeerkqqk4xqtq24gq2o7kjlwme', - alt: 'People socializing in groups around a table with pizza', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreieg473dkdpebirricskeepgm3w2elnda5kxnvnkdbrrnfdaybeh5q', - alt: 'Several attendees together in a single shot', - }, - ], - talks: [ - { - id: 'trust-network', - title: 'Trust Network', - abstract: 'How npmx builds a web of trust on top of atproto identities.', - speakers: [{ name: 'Alex', handle: 'alex.npmx.dev', avatar: AVATARS.a }], - startsAt: '2026-06-19T19:15:00Z', - watchUrl: 'https://youtube.com', - slidesUrl: 'https://speakerdeck.com', - pdfUrl: 'https://example.com/trust-network.pdf', - }, - { - id: 'future-of-npmx', - title: 'Panel: The Future of npmx', - abstract: 'The core team on where npmx goes next.', - speakers: [ - { name: 'Alex', handle: 'alex.npmx.dev', avatar: AVATARS.a }, - { name: 'Lope', handle: 'lope.npmx.dev', avatar: AVATARS.b }, - ], - startsAt: '2026-06-19T20:00:00Z', - watchUrl: 'https://youtube.com', - }, - ], - }, - { - slug: 'vienna-meetup-3', - name: 'npmx Vienna Meetup #3', - description: 'The third npmx Vienna meetup — talks, hallway track, and drinks after.', - kind: 'meetup', - mode: 'inperson', - status: 'scheduled', - startsAt: '2026-05-14T17:00:00Z', - endsAt: '2026-05-14T21:00:00Z', - cover: - 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreiakvo6fu563g3j7a6pcioeijq7ni7i462dboss24uerzeisuxrwj4', - location: { name: 'Vienna', locality: 'Vienna', country: 'AT', lat: '48.2082', lon: '16.3738' }, - tags: ['meetup', 'vienna'], - hosts: [{ name: 'Felix Schneider', uri: 'https://felixs.dev/events/' }], - attendeeCount: 28, - attendees: [ - { name: 'Felix', handle: 'felixs.dev', avatar: AVATARS.c }, - { name: 'Alex', handle: 'alex.npmx.dev', avatar: AVATARS.a }, - ], - schedule: [ - { time: '17:00', label: 'Doors & Socialising' }, - { time: '17:30', label: 'atproto 101' }, - { time: '18:15', label: 'Lightning talks' }, - { time: '19:00', label: 'Hallway track & drinks' }, - { time: '21:00', label: 'Wrap up' }, - ], - links: [{ uri: 'https://felixs.dev/events/', name: 'Event page' }], - gallery: [ - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreieg473dkdpebirricskeepgm3w2elnda5kxnvnkdbrrnfdaybeh5q', - alt: 'Several attendees together in a single shot', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreidavrldq6zigzwcdu46pgr4indo62ne3v5it2czlvota3igduofe4', - alt: 'Conversations among attendees, one talking about technology with passion', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreif7uwalqlhgqeizxw6ksivwofoy74vexkyfwejwestnyz3n353bmy', - alt: 'Attendees gathered at the stage listening to presentations', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreibj27mrk6v6b6gaaceged36uamfqi2womo3jar6ttxpejetd7dere', - alt: 'The audience watching a presentation intently, some taking photos', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreiakvo6fu563g3j7a6pcioeijq7ni7i462dboss24uerzeisuxrwj4', - alt: 'The opening talk on stage', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreibccpe3ys2zzdqw6t6b4n2hlnfbbvponzy2r6tvdgc2q5u2waq6t4', - alt: 'Empty chairs on stage at the end of the event', - }, - ], - talks: [ - { - id: 'atproto-101', - title: 'atproto 101', - abstract: 'A gentle intro to repos, lexicons and the firehose.', - speakers: [{ name: 'Felix', handle: 'felixs.dev', avatar: AVATARS.c }], - watchUrl: 'https://youtube.com', - slidesUrl: 'https://speakerdeck.com', - }, - ], - }, - { - slug: 'npmx-online-townhall', - name: 'npmx Online Town Hall', - description: 'A fully online town hall — roadmap updates and community Q&A.', - kind: 'conference', - mode: 'virtual', - status: 'scheduled', - startsAt: '2026-08-26T14:00:00Z', - endsAt: '2026-08-26T16:00:00Z', - cover: - 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:akon7og3z6ihjpoclxzjrglf/bafkreigc3zmk3ayfw2w55c4hhaoyhi35occrrneiehksa2eyo4il6vtvui', - tags: ['conference', 'online'], - hosts: [{ name: 'npmx', uri: 'https://npmx.dev' }], - attendeeCount: 113, - attendees: [ - { name: 'Lope', handle: 'lope.npmx.dev', avatar: AVATARS.b }, - { name: 'Alex', handle: 'alex.npmx.dev', avatar: AVATARS.a }, - { name: 'Dana', handle: 'dana.example', avatar: AVATARS.d }, - { name: 'Sam', handle: 'sam.example', avatar: AVATARS.e }, - ], - schedule: [ - { time: '14:00', label: 'Welcome & intros' }, - { time: '14:15', label: 'Roadmap update' }, - { time: '15:00', label: 'Community Q&A' }, - { time: '15:45', label: 'Open floor' }, - ], - links: [{ uri: 'https://npmx.dev', name: 'Register' }], - registerUrl: 'https://npmx.dev', - gallery: [ - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreicsxn7blppolvtrf6w5kxc5hxuv2454licb3c5jcx6njygenvax2u', - alt: 'A screen showing "npmx London meetup #2"', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreigk7hqvly7rfn3aldhtdrgx7a4yhf5ydraj6igiymv7zeaanjswsq', - alt: 'A group of people talking together', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreiczqaiflwnokgljuazi5dpmm3hq4hhpclrywp52rrd3v2k37peqia', - alt: 'Empty chairs arranged in a semicircle before the event', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreifka6pryqaxkuxqa43h4uiucxc4dln4gergjspqclatngdjefzjey', - alt: 'A conversation at the edge of a table with pizza boxes', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:er6erflnnxcozlbqmrpflt6h/bafkreih5ilqvdk2grb5glwx76rfav5nhfaxl5tuwwkzot2fuxciyi4enfu', - alt: 'Patak chatting with James while Willow sets things up', - }, - { - url: 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:akon7og3z6ihjpoclxzjrglf/bafkreigda6qfmea4yyvvlwf7rckpdzflp6fwe6gixugqchgs27i3qtnxpm', - alt: 'Alex presenting a talk on trust', - }, - ], - talks: [ - { - id: 'roadmap', - title: 'npmx Roadmap', - abstract: 'Where npmx is headed over the next few months.', - speakers: [{ name: 'Lope', handle: 'lope.npmx.dev', avatar: AVATARS.b }], - watchUrl: 'https://youtube.com', - slidesUrl: 'https://speakerdeck.com', - }, - ], - }, -] diff --git a/i18n/locales/en.json b/i18n/locales/en.json index e25b1dc5de..dc5204b5bc 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -249,7 +249,12 @@ "attending": "Attending", "hosted_by": "Hosted by", "register": "Register", - "back": "All events" + "back": "All events", + "missing": { + "label": "no such event", + "title": "This event isn't on the calendar.", + "body": "We couldn't find an event at \"{slug}\". It may have been moved, or it never happened. Head back to all events to see what's on." + } }, "blog": { "title": "Blog", diff --git a/server/api/events/index.get.ts b/server/api/events/index.get.ts new file mode 100644 index 0000000000..433dbd07bb --- /dev/null +++ b/server/api/events/index.get.ts @@ -0,0 +1,93 @@ +import type { EventDetail, EventKind, EventLink, EventMode, EventStatus } from '~/types/events' + +const NPMX_PDS_HOST = 'https://npmx.social' +const NPMX_EVENTS_DID = 'did:plc:u5zp7npt5kpueado77kuihyz' +const EVENT_COLLECTION = 'community.lexicon.calendar.event' + +interface RawBlobRef { + ref?: { $link?: string } + mimeType?: string +} + +interface RawEvent { + name: string + description?: string + mode?: string + status?: string + startsAt?: string + endsAt?: string + createdAt: string + uris?: Array<{ uri: string; name?: string }> + locations?: Array<{ uri?: string; name?: string }> + media?: Array<{ role?: string; content?: RawBlobRef }> +} + +interface ListRecordsResponse { + records: Array<{ uri: string; value: RawEvent }> + cursor?: string +} + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function tokenTail(value: string | undefined, fallback: string): string { + if (!value) return fallback + const tail = value.split('#').pop() + return tail || fallback +} + +function blobUrl(did: string, ref?: RawBlobRef): string | undefined { + const cid = ref?.ref?.$link + if (!cid) return undefined + return `${NPMX_PDS_HOST}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}` +} + +function mapEvent(did: string, value: RawEvent): EventDetail { + const uris = value.uris ?? [] + const links: EventLink[] = uris.map(u => ({ uri: u.uri, name: u.name })) + const registerUrl = uris.find(u => u.uri.includes('guild.host'))?.uri + const cover = blobUrl(did, value.media?.find(m => m.role === 'thumbnail')?.content) + + return { + slug: slugify(value.name), + name: value.name, + description: value.description, + kind: 'meetup' as EventKind, + mode: tokenTail(value.mode, 'inperson') as EventMode, + status: tokenTail(value.status, 'scheduled') as EventStatus, + startsAt: value.startsAt ?? value.createdAt, + endsAt: value.endsAt, + cover, + tags: [], + attendees: [], + attendeeCount: 0, + hosts: [], + links, + registerUrl, + talks: [], + } +} + +export default defineEventHandler(async event => { + const url = new URL(`${NPMX_PDS_HOST}/xrpc/com.atproto.repo.listRecords`) + url.searchParams.set('repo', NPMX_EVENTS_DID) + url.searchParams.set('collection', EVENT_COLLECTION) + url.searchParams.set('limit', '100') + + const response = await fetch(url.toString()) + if (!response.ok) { + throw createError({ statusCode: 502, message: 'Failed to load events from the npmx PDS' }) + } + + const data = (await response.json()) as ListRecordsResponse + const events = data.records + .map(record => mapEvent(NPMX_EVENTS_DID, record.value)) + .sort((a, b) => b.startsAt.localeCompare(a.startsAt)) + + setHeader(event, 'cache-control', 's-maxage=300, stale-while-revalidate=3600') + return events +}) From 7bf9ca53ef2f967aba37ed7ac2f92aba2031d8f7 Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Fri, 31 Jul 2026 23:34:31 +0100 Subject: [PATCH 3/4] refactor(events): simplify detail-page data fetch --- app/pages/events/[slug].vue | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/app/pages/events/[slug].vue b/app/pages/events/[slug].vue index d6057cb94d..96382e759a 100644 --- a/app/pages/events/[slug].vue +++ b/app/pages/events/[slug].vue @@ -1,27 +1,32 @@