Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .astro/collections/meetups.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@
"location": {
"type": "object",
"properties": {
"label": {
"type": "string",
"minLength": 2
},
"name": {
"type": "string",
"minLength": 2
},
"map_url": {
"url": {
"anyOf": [
{
"type": "string",
Expand Down
2 changes: 1 addition & 1 deletion .astro/data-store.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
pull_request:
branches:
- main
workflow_dispatch:
schedule:
- cron: '0 6 * * 1,4'

permissions:
contents: read
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ All commands are run from the root of the project, from a terminal:
| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` |
| `pnpm astro -- --help` | Get help using the Astro CLI |

## Meetup data

Meetup pages are built from the DevCongress Community public API by default:

```txt
DEVCONGRESS_COMM_API_BASE_URL=https://devcongress-comm-api.elvis-yt211.workers.dev
DEVCONGRESS_COMM_ASSET_BASE_URL=https://devcon-comm.pages.dev
```

If the API cannot be reached during `pnpm build`, the site falls back to `content/meetups/*.yaml` so deploys do not fail because of a temporary API outage.

## 👀 Want to learn more?

Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
20 changes: 3 additions & 17 deletions src/components/MeetupsSection.astro
Original file line number Diff line number Diff line change
@@ -1,20 +1,8 @@
---
interface MeetupLocation { label?: string; name: string; url?: string | null; }
interface Meetup {
id: string;
data: {
name: string;
start: string;
end: string;
location: MeetupLocation;
description: string;
cover: string;
photos?: string[];
registration_url?: string | null;
stream_url?: string | null;
};
}
import type { WebsiteMeetup } from '../lib/meetups';

interface Props { meetups: Meetup[]; }
type Meetup = WebsiteMeetup;
const { meetups } = Astro.props;

function getMeetupStatus(start: string, end: string) {
Expand All @@ -39,8 +27,6 @@ const sorted = [...meetups]
}));

const recent = sorted.slice(0, 3);
const earlier = sorted.slice(3);

const statusConfig = {
upcoming: { label: 'Upcoming', cta: 'Register →' },
live: { label: '● Live', cta: 'Follow live →' },
Expand Down
222 changes: 222 additions & 0 deletions src/lib/meetups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { getCollection, type CollectionEntry } from 'astro:content';

const DEFAULT_API_BASE_URL = 'https://devcongress-comm-api.elvis-yt211.workers.dev';
const DEFAULT_ASSET_BASE_URL = 'https://devcon-comm.pages.dev';

const apiBaseUrl =
import.meta.env.DEVCONGRESS_COMM_API_BASE_URL ??
import.meta.env.PUBLIC_DEVCONGRESS_COMM_API_BASE_URL ??
DEFAULT_API_BASE_URL;

const assetBaseUrl =
import.meta.env.DEVCONGRESS_COMM_ASSET_BASE_URL ??
import.meta.env.PUBLIC_DEVCONGRESS_COMM_ASSET_BASE_URL ??
DEFAULT_ASSET_BASE_URL;

let meetupsPromise: Promise<WebsiteMeetup[]> | undefined;

type LocalMeetup = CollectionEntry<'meetups'>;

export interface MeetupLocation {
label?: string;
name: string;
url?: string | null;
}

export interface MeetupPhoto {
url: string;
type?: 'image' | 'folder';
}

export interface MeetupSocial {
platform: 'x' | 'linkedin' | 'github' | 'website' | 'youtube' | 'instagram' | 'facebook' | 'discord' | 'slack';
url: string;
}

export interface MeetupSpeaker {
name: string;
title: string;
bio: string;
image: string;
talk_title: string;
talk_description: string;
slides_url?: string | null;
recording_url?: string | null;
socials?: MeetupSocial[];
}

export interface MeetupScheduleItem {
time: string;
title: string;
type: 'networking' | 'talk' | 'panel' | 'workshop' | 'open_discussion' | 'break';
lead?: string | null;
resources?: Array<{
title: string;
url: string;
}>;
}

export interface MeetupVideo {
title: string;
embed_url: string;
}

export interface WebsiteMeetup {
id: string;
data: {
name: string;
start: string;
end: string;
description: string;
cover: string;
location: MeetupLocation;
stream_url?: string | null;
embed_stream?: boolean;
registration_url?: string | null;
speakers?: MeetupSpeaker[];
schedule?: MeetupScheduleItem[];
photos?: MeetupPhoto[];
videos?: MeetupVideo[];
};
}

interface PublicMeetupsResponse {
data?: PublicMeetupDto[];
}

interface PublicMeetupDto {
id?: string;
slug?: string;
name?: string;
start?: string;
end?: string;
description?: string;
cover?: string;
location?: MeetupLocation;
stream_url?: string | null;
embed_stream?: boolean;
registration_url?: string | null;
speakers?: MeetupSpeaker[];
schedule?: MeetupScheduleItem[];
photos?: MeetupPhoto[];
videos?: MeetupVideo[];
}

export async function getMeetups(): Promise<WebsiteMeetup[]> {
meetupsPromise ??= loadMeetups();
return meetupsPromise;
}

export function sortMeetupsByNewest(meetups: WebsiteMeetup[]): WebsiteMeetup[] {
return [...meetups].sort((a, b) => new Date(b.data.start).getTime() - new Date(a.data.start).getTime());
}

async function loadMeetups(): Promise<WebsiteMeetup[]> {
try {
const remoteMeetups = await fetchRemoteMeetups();
if (remoteMeetups.length > 0) {
return remoteMeetups;
}
} catch (error) {
console.warn(
`[meetups] Falling back to local meetup YAML because ${getApiUrl('/api/public/meetups')} could not be loaded: ${getErrorMessage(error)}`,
);
}

return fetchLocalMeetups();
}

async function fetchRemoteMeetups(): Promise<WebsiteMeetup[]> {
const response = await fetch(getApiUrl('/api/public/meetups'), {
headers: { accept: 'application/json' },
});

if (!response.ok) {
throw new Error(`Meetups API returned ${response.status}`);
}

const body = await response.json() as PublicMeetupsResponse;
if (!Array.isArray(body.data)) {
throw new Error('Meetups API response did not include a data array');
}

return body.data.map(mapPublicMeetup).filter((meetup): meetup is WebsiteMeetup => Boolean(meetup));
}

async function fetchLocalMeetups(): Promise<WebsiteMeetup[]> {
const localMeetups = await getCollection('meetups');
return localMeetups.map(mapLocalMeetup);
}

function mapLocalMeetup(meetup: LocalMeetup): WebsiteMeetup {
return {
id: meetup.id,
data: {
...meetup.data,
cover: resolveAssetUrl(meetup.data.cover, false),
speakers: meetup.data.speakers?.map((speaker) => ({
...speaker,
image: resolveAssetUrl(speaker.image, false),
})),
photos: meetup.data.photos?.map((photo) => ({
...photo,
url: resolveAssetUrl(photo.url, false),
})),
},
};
}

function mapPublicMeetup(meetup: PublicMeetupDto): WebsiteMeetup | null {
if (!meetup.slug || !meetup.name || !meetup.start || !meetup.end || !meetup.description || !meetup.cover) {
return null;
}

return {
id: meetup.slug,
data: {
name: meetup.name,
start: meetup.start,
end: meetup.end,
description: meetup.description,
cover: resolveAssetUrl(meetup.cover, true),
location: {
label: meetup.location?.label,
name: meetup.location?.name ?? 'Online',
url: meetup.location?.url ?? null,
},
stream_url: meetup.stream_url ?? null,
embed_stream: meetup.embed_stream ?? false,
registration_url: meetup.registration_url ?? null,
speakers: meetup.speakers?.map((speaker) => ({
...speaker,
image: resolveAssetUrl(speaker.image, true),
})),
schedule: meetup.schedule ?? [],
photos: meetup.photos?.map((photo) => ({
...photo,
url: resolveAssetUrl(photo.url, true),
})),
videos: meetup.videos ?? [],
},
};
}

function getApiUrl(path: string): string {
return new URL(path, ensureTrailingSlash(apiBaseUrl)).toString();
}

function resolveAssetUrl(url: string, fromRemote: boolean): string {
if (!fromRemote || !url.startsWith('/')) {
return url;
}

return new URL(url, ensureTrailingSlash(assetBaseUrl)).toString();
}

function ensureTrailingSlash(url: string): string {
return url.endsWith('/') ? url : `${url}/`;
}

function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
3 changes: 2 additions & 1 deletion src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import MeetupsSection from '../components/MeetupsSection.astro';
import AdminsSection from '../components/AdminsSection.astro';
import PartnersSection from '../components/PartnersSection.astro';
import DonateSection from '../components/DonateSection.astro';
import { getMeetups } from '../lib/meetups';

const siteEntries = await getCollection('site');
const site = siteEntries[0]!.data;
const admins = await getCollection('admins');
const activities = await getCollection('activities');
const partners = await getCollection('partners');
const meetups = await getCollection('meetups');
const meetups = await getMeetups();
---

<Base>
Expand Down
4 changes: 2 additions & 2 deletions src/pages/meetups/[slug].astro
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
import { getMeetups } from '../../lib/meetups';

export async function getStaticPaths() {
const meetups = await getCollection('meetups');
const meetups = await getMeetups();
return meetups.map((meetup) => ({
params: { slug: meetup.id },
props: { meetup },
Expand Down
7 changes: 3 additions & 4 deletions src/pages/meetups/index.astro
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
import { getMeetups, sortMeetupsByNewest } from '../../lib/meetups';

const meetups = await getCollection('meetups');
const meetups = await getMeetups();

function getMeetupStatus(start: string, end: string) {
const now = new Date();
Expand All @@ -17,8 +17,7 @@ function formatDate(iso: string): string {
});
}

const sorted = [...meetups]
.sort((a, b) => new Date(b.data.start).getTime() - new Date(a.data.start).getTime())
const sorted = sortMeetupsByNewest(meetups)
.map((m) => ({
...m,
slug: m.id,
Expand Down
Loading