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 = {
{event.coverUrl && } {cfg.label} - {event.format.replaceAll('_', ' ')}
@@ -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; --- -
- - -
-
- ← All events -
- {event.coverUrl && ( -
- -
- )} -
-
- {statusLabel} - {formatLabel(event.format)} -
-

- {formatDate(event.startsAt, event.timezone)} · {formatTime(event.startsAt, event.timezone)} · {getLocation(event)} -

-

{event.title}

-

{event.summary}

- {actionUrl && ( - - {actionLabel} - - )} -
-
+
+ +
+ ← All events +
+
+

About

+

About this event

+

{event.summary}

+
+
+ + diff --git a/src/pages/events/index.astro b/src/pages/events/index.astro index e94b755..c71a364 100644 --- a/src/pages/events/index.astro +++ b/src/pages/events/index.astro @@ -94,8 +94,34 @@ const calendarEvents = events.map((event) => {
-
- )}
@@ -308,21 +312,32 @@ const calendarEvents = events.map((event) => {
-