diff --git a/src/components/MeetupsSection.astro b/src/components/MeetupsSection.astro
index 593e36d..c495b32 100644
--- a/src/components/MeetupsSection.astro
+++ b/src/components/MeetupsSection.astro
@@ -58,7 +58,6 @@ const statusConfig = {
@@ -138,10 +137,7 @@ const statusConfig = {
const statusBadge = document.createElement('span');
statusBadge.className = `meetup-status-badge status-${status}`;
statusBadge.textContent = config.label;
- const typeBadge = document.createElement('span');
- typeBadge.className = 'meetup-photo-badge';
- typeBadge.textContent = event.format.replaceAll('_', ' ');
- cover.append(statusBadge, typeBadge);
+ cover.append(statusBadge);
coverLink.append(cover);
const body = document.createElement('div');
@@ -260,7 +256,6 @@ const statusConfig = {
align-items: flex-end;
justify-content: space-between;
padding: var(--s3);
- transition: opacity 200ms ease;
}
.meetup-cover-image {
position: absolute;
@@ -268,12 +263,13 @@ const statusConfig = {
width: 100%;
height: 100%;
object-fit: cover;
+ transition: opacity 160ms cubic-bezier(0.4, 0, 0.2, 1);
}
- .meetup-status-badge,
- .meetup-photo-badge {
- position: relative;
+ .meetup-status-badge { position: relative; }
+ @media (hover: hover) and (pointer: fine) {
+ .meetup-card:hover { transform: none; }
+ .meetup-card:hover .meetup-cover-image { opacity: 0.92; }
}
- .meetup-card:hover .meetup-cover { opacity: 0.9; }
.meetup-status-badge {
font-size: var(--text-xs);
@@ -286,15 +282,6 @@ const statusConfig = {
.status-live { background: var(--pink); color: #fff; }
.status-past { background: var(--bg-white); color: var(--text-muted); }
- .meetup-photo-badge {
- font-size: var(--text-xs);
- font-weight: 700;
- background: rgba(0,0,0,0.6);
- color: #fff;
- border-radius: var(--r-sm);
- padding: 2px var(--s2);
- }
-
.meetup-body {
padding: var(--s5);
display: flex;
@@ -348,5 +335,9 @@ const statusConfig = {
flex: 1;
}
- .meetup-cta { align-self: flex-start; margin-top: var(--s2); }
+ .meetup-cta { align-self: flex-end; margin-top: auto; }
+
+ @media (prefers-reduced-motion: reduce) {
+ .meetup-cover-image { transition: none; }
+ }
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
index e480903..e16fb73 100644
--- a/src/layouts/Base.astro
+++ b/src/layouts/Base.astro
@@ -231,6 +231,144 @@ const site = siteEntries[0]!.data;
box-shadow: var(--shadow-solid);
}
+ /* Runtime event cards are created after Astro's scoped styles run. Keep this
+ compatibility layer narrow so only the live homepage/list card surfaces
+ opt into the same geometry as their server-rendered counterparts. */
+ [data-home-events-grid] > .meetup-card {
+ width: calc((min(var(--max-w), 100vw) - 2 * var(--pad) - 2 * var(--s5)) / 3);
+ flex-shrink: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ }
+ [data-home-events-grid] > .meetup-card .meetup-cover-link,
+ #events-list-view .event-card .event-cover-link { display: block; }
+ [data-home-events-grid] > .meetup-card .meetup-cover,
+ #events-list-view .event-card .event-cover {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ position: relative;
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ padding: var(--s3);
+ background-color: var(--border-light);
+ }
+ [data-home-events-grid] > .meetup-card .meetup-cover-image,
+ #events-list-view .event-card .event-cover-image {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: opacity 160ms cubic-bezier(0.4, 0, 0.2, 1);
+ }
+ [data-home-events-grid] > .meetup-card .meetup-status-badge,
+ #events-list-view .event-card .event-status-badge { position: relative; }
+ [data-home-events-grid] > .meetup-card .meetup-status-badge,
+ #events-list-view .event-card .event-status-badge {
+ padding: 2px var(--s2);
+ border: 2px solid var(--black);
+ border-radius: var(--r-sm);
+ font-size: var(--text-xs);
+ font-weight: 700;
+ }
+ [data-home-events-grid] > .meetup-card .status-upcoming,
+ #events-list-view .event-card .status-upcoming { background: var(--yellow); color: var(--black); }
+ [data-home-events-grid] > .meetup-card .status-live,
+ #events-list-view .event-card .status-live { background: var(--pink); color: #fff; }
+ [data-home-events-grid] > .meetup-card .status-past,
+ #events-list-view .event-card .status-past { background: var(--bg-white); color: var(--text-muted); }
+ [data-home-events-grid] > .meetup-card .meetup-body,
+ #events-list-view .event-card .event-body {
+ min-width: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: var(--s3);
+ padding: var(--s5);
+ border-top: 2px solid var(--black);
+ }
+ [data-home-events-grid] > .meetup-card .meetup-meta,
+ #events-list-view .event-card .event-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: nowrap;
+ gap: var(--s2);
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: var(--text-muted);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ }
+ [data-home-events-grid] > .meetup-card .meetup-location,
+ #events-list-view .event-card .event-location {
+ min-width: 0;
+ max-width: 100%;
+ flex: 1 1 0;
+ overflow: hidden;
+ color: var(--pink);
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ [data-home-events-grid] > .meetup-card .meetup-meta time,
+ #events-list-view .event-card .event-meta time {
+ flex: 0 0 auto;
+ white-space: nowrap;
+ }
+ [data-home-events-grid] > .meetup-card .meetup-name,
+ #events-list-view .event-card .event-name {
+ min-width: 0;
+ overflow: hidden;
+ font-size: var(--text-xl);
+ line-height: var(--leading-snug);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ [data-home-events-grid] > .meetup-card .meetup-desc,
+ #events-list-view .event-card .event-desc {
+ display: -webkit-box;
+ flex: 1;
+ overflow: hidden;
+ color: var(--text-mid);
+ font-size: var(--text-sm);
+ line-height: var(--leading-body);
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ }
+ [data-home-events-grid] > .meetup-card .meetup-cta,
+ #events-list-view .event-card .event-cta {
+ align-self: flex-end;
+ margin-top: auto;
+ }
+ #events-list-view .events-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--s5);
+ }
+ #events-list-view[hidden],
+ #events-calendar-view[hidden],
+ #event-detail-view[hidden] { display: none !important; }
+
+ @media (hover: hover) and (pointer: fine) {
+ [data-home-events-grid] > .meetup-card.card:hover,
+ #events-list-view .event-card.card:hover { transform: none; }
+ [data-home-events-grid] > .meetup-card:hover .meetup-cover-image,
+ #events-list-view .event-card:hover .event-cover-image { opacity: 0.92; }
+ }
+
+ @media (max-width: 900px) {
+ [data-home-events-grid] > .meetup-card {
+ width: calc((100vw - 2 * var(--pad) - var(--s5)) / 2);
+ }
+ }
+ @media (max-width: 640px) {
+ [data-home-events-grid] > .meetup-card { width: 85vw; }
+ #events-list-view .events-grid { grid-template-columns: 1fr; }
+ }
+
/* ─── Divider ────────────────────────────────────────────── */
.divider { border: none; border-top: 2px solid var(--black); }
diff --git a/src/lib/events.ts b/src/lib/events.ts
index e37c788..0044f91 100644
--- a/src/lib/events.ts
+++ b/src/lib/events.ts
@@ -84,6 +84,8 @@ const publicEventSchema = z
venue_name: z.string().trim().min(1).max(500).nullable(),
venue_address: z.string().trim().min(1).max(1_000).nullable(),
online_url: publicHttpUrlSchema.nullable(),
+ stream_url: publicHttpUrlSchema.nullable().optional(),
+ embed_stream: z.boolean().optional().default(false),
registration_url: publicWebsiteUrlSchema.nullable(),
organizer_name: z.string().trim().min(1).max(300),
organizer_website: publicHttpUrlSchema.nullable(),
@@ -140,6 +142,8 @@ export interface WebsiteEvent {
venueName: string | null;
venueAddress: string | null;
onlineUrl: string | null;
+ streamUrl: string | null;
+ embedStream: boolean;
registrationUrl: string | null;
organizerName: string;
organizerWebsite: string | null;
@@ -243,6 +247,8 @@ async function fetchMeetupFallback(): Promise
{
venueName: meetup.data.location.name,
venueAddress: meetup.data.location.label ?? null,
onlineUrl: meetup.data.stream_url ?? null,
+ streamUrl: meetup.data.stream_url ?? null,
+ embedStream: meetup.data.embed_stream,
registrationUrl: meetup.data.registration_url ?? null,
organizerName: "DevCongress",
organizerWebsite: "https://devcongress.org",
@@ -268,6 +274,8 @@ function mapPublicEvent(event: PublicEvent): WebsiteEvent {
venueName: event.venue_name,
venueAddress: event.venue_address,
onlineUrl: event.online_url,
+ streamUrl: event.stream_url ?? null,
+ embedStream: event.embed_stream,
registrationUrl: resolveOptionalWebsiteUrl(event.registration_url),
organizerName: event.organizer_name,
organizerWebsite: event.organizer_website,
diff --git a/src/lib/live-public-events.ts b/src/lib/live-public-events.ts
index 350b5ba..c813884 100644
--- a/src/lib/live-public-events.ts
+++ b/src/lib/live-public-events.ts
@@ -76,6 +76,8 @@ function normalizePublicEvent(value: unknown): WebsiteEvent | null {
const venueName = nullableString(value.venue_name, 500);
const venueAddress = nullableString(value.venue_address, 1_000);
const onlineUrl = safePublicUrl(value.online_url);
+ const streamUrl = safePublicUrl(value.stream_url);
+ const embedStream = value.embed_stream === true;
const registrationUrl = safePublicUrl(value.registration_url);
const organizerWebsite = safePublicUrl(value.organizer_website);
const coverUrl = safePublicUrl(value.cover_url);
@@ -95,6 +97,8 @@ function normalizePublicEvent(value: unknown): WebsiteEvent | null {
venueName,
venueAddress,
onlineUrl,
+ streamUrl,
+ embedStream,
registrationUrl,
organizerName,
organizerWebsite,
diff --git a/src/pages/events/[slug].astro b/src/pages/events/[slug].astro
index 6f4da2b..4a353e6 100644
--- a/src/pages/events/[slug].astro
+++ b/src/pages/events/[slug].astro
@@ -13,6 +13,8 @@ export async function getStaticPaths() {
interface Props { event: WebsiteEvent; }
const { event } = Astro.props;
+type EventStatus = 'upcoming' | 'live' | 'past';
+
function formatDate(iso: string, timezone: string): string {
return new Date(iso).toLocaleDateString('en-GB', {
day: 'numeric', month: 'long', year: 'numeric', timeZone: timezone,
@@ -25,106 +27,193 @@ function formatTime(iso: string, timezone: string): string {
});
}
-function formatLabel(value: string): string {
- if (value === 'conference') return 'Conference / congress';
- return value.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
-}
-
function getLocation(value: WebsiteEvent): string {
if (value.locationType === 'online') return 'Online';
if (value.locationType === 'hybrid') return value.venueName ? `${value.venueName} + online` : 'Hybrid';
return value.venueName ?? 'In person';
}
-function getStatus(value: WebsiteEvent): 'upcoming' | 'live' | 'past' {
+function getStatus(value: WebsiteEvent): EventStatus {
const now = new Date();
const start = new Date(value.startsAt);
const end = new Date(value.endsAt);
return now < start ? 'upcoming' : now <= end ? 'live' : 'past';
}
+function getPrimaryAction(value: WebsiteEvent, valueStatus: EventStatus): { href: string; label: string } | null {
+ if (valueStatus === 'past') {
+ return value.streamUrl && !value.embedStream ? { href: value.streamUrl, label: 'Watch recording' } : null;
+ }
+
+ if (valueStatus === 'live') {
+ if (value.streamUrl) return { href: value.streamUrl, label: 'Watch live' };
+ if (value.onlineUrl) return { href: value.onlineUrl, label: 'Join online' };
+ return null;
+ }
+
+ return value.registrationUrl ? { href: value.registrationUrl, label: 'Register' } : null;
+}
+
const status = getStatus(event);
-const statusLabel = status === 'live' ? '● Live' : status === 'upcoming' ? 'Upcoming' : 'Past';
-const actionUrl = event.registrationUrl ?? event.onlineUrl;
-const actionLabel = event.registrationUrl ? 'Register for this event →' : 'Join online →';
-const externalAction = actionUrl?.startsWith('http') ?? false;
+const action = getPrimaryAction(event, status);
+const statusLabel = status === 'live' ? 'Live' : status === 'upcoming' ? 'Upcoming' : 'Past';
+const externalAction = action?.href.startsWith('http') ?? false;
---
-
-