ghostguild-org/app/pages/series/index.vue
Jennie Robinson Faber 31144617d7 feat(seo): site meta composable + Open Graph image generation
Adds `useSiteMeta()` composable that wraps useSeoMeta with site defaults
(title template, canonical URL, og/twitter image, og:site_name) and
absolute-URL handling via runtimeConfig.public.appUrl.

Adds /og/events/[slug].png route that renders per-event OG images via
satori + @resvg/resvg-js, cached on disk by slug + updatedAt. Bundles
Brygada 1918 + Commit Mono fonts as server assets, ships a fallback
default.png, and patches @shuding/opentype.js via patch-package.

Converts ~25 pages from useHead to useSiteMeta and adds noindex on
private/auth/admin pages.
2026-05-21 17:50:34 +01:00

240 lines
5.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div>
<PageHeader
title="Event Series"
subtitle="Multi-session events on cooperative topics"
/>
<div v-if="pending" class="state-msg">Loading series...</div>
<div v-else-if="!filteredSeries.length" class="state-msg">
<p>
No series right now. Check back later or browse
<NuxtLink to="/events">upcoming events</NuxtLink>.
</p>
</div>
<div v-else>
<section
v-for="series in filteredSeries"
:key="series.id"
class="series-section"
>
<div class="series-head">
<h2>{{ series.title }}</h2>
<div class="series-meta-row">
<span v-if="series.type" class="badge all">{{ formatSeriesType(series.type) }}</span>
<span class="meta-text">
{{ series.eventCount }} sessions<template v-if="series.totalEvents"> of {{ series.totalEvents }} planned</template>
</span>
<span v-if="series.startDate && series.endDate" class="meta-text">
{{ formatDateRange(series.startDate, series.endDate) }}
</span>
<span v-if="series.totalRegistrations" class="meta-text">
{{ series.totalRegistrations }} registered
</span>
</div>
<p v-if="series.description" class="series-desc">
{{ series.description }}
</p>
</div>
<div v-if="series.events?.length" class="sessions">
<div
v-for="(event, index) in series.events"
:key="event.id"
class="event-row"
>
<span class="event-num">
{{ String(event.series?.position || index + 1).padStart(2, '0') }}
</span>
<span class="event-date">{{ formatEventDate(event.startDate) }}</span>
<div class="event-info">
<NuxtLink
:to="`/events/${event.slug || event.id}`"
class="event-title-link"
>
{{ event.title }}
</NuxtLink>
<span class="event-status">{{ getEventStatus(event) }}</span>
</div>
</div>
</div>
<div class="series-foot">
<NuxtLink :to="`/series/${series.id}`" class="view-link">
View series &rarr;
</NuxtLink>
</div>
</section>
</div>
</div>
</template>
<script setup>
useSiteMeta({
title: "Event Series",
description:
"Multi-session event series on cooperative topics — from foundations courses to practitioner cohorts.",
});
const { data: seriesData, pending } = await useFetch("/api/series", {
query: { includeHidden: false },
});
const filteredSeries = computed(() => {
if (!seriesData.value) return [];
return seriesData.value.filter(
(series) => series.status === "active" || series.status === "upcoming",
);
});
const formatSeriesType = (type) => {
const types = {
workshop_series: "Workshop Series",
recurring_meetup: "Recurring Meetup",
multi_day: "Multi-Day Event",
course: "Course",
tournament: "Tournament",
};
return types[type] || type;
};
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const formatDateRange = (startDate, endDate) => {
const start = new Date(startDate);
const end = new Date(endDate);
const formatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
return `${formatter.format(start)} ${formatter.format(end)}`;
};
const getEventStatus = (event) => {
const now = new Date();
const startDate = new Date(event.startDate);
const endDate = new Date(event.endDate);
if (now < startDate) return "Upcoming";
if (now >= startDate && now <= endDate) return "Ongoing";
return "Completed";
};
</script>
<style scoped>
.state-msg {
padding: 32px 28px;
color: var(--text-dim);
font-size: 12px;
}
.state-msg p { max-width: 560px; }
.series-section {
border-bottom: 1px dashed var(--border);
}
.series-head {
padding: 24px 28px 16px;
}
.series-head h2 {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.2;
margin-bottom: 10px;
}
.series-meta-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;
font-size: 12px;
}
.meta-text {
color: var(--text-faint);
font-size: 12px;
}
.series-desc {
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
max-width: 640px;
margin: 0;
}
.sessions {
border-top: 1px dashed var(--border);
border-bottom: 1px dashed var(--border);
}
.event-row {
display: flex;
align-items: baseline;
gap: 12px;
padding: 12px 28px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.event-row:last-child { border-bottom: none; }
.event-num {
flex: 0 0 24px;
color: var(--text-faint);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.event-date {
flex: 0 0 110px;
color: var(--text-faint);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.event-info { flex: 1 1 0; min-width: 0; }
.event-title-link {
color: var(--text);
text-decoration: none;
font-size: 13px;
}
.event-title-link:hover { color: var(--candle); }
.event-status {
font-size: 10px;
color: var(--text-faint);
margin-left: 8px;
}
.series-foot {
padding: 14px 28px 24px;
}
.view-link {
font-size: 12px;
color: var(--candle);
letter-spacing: 0.04em;
}
.view-link:hover { text-decoration: underline; }
@media (max-width: 768px) {
.series-head,
.series-foot {
padding-left: 20px;
padding-right: 20px;
}
.event-row {
flex-wrap: wrap;
padding-left: 20px;
padding-right: 20px;
row-gap: 2px;
}
.event-info {
flex-basis: 100%;
margin-left: 36px;
}
}
</style>