ghostguild-org/app/pages/events/index.vue
Jennie Robinson Faber 622cc8e53b feat(events): markdown body, registered indicator, drop capacity counters
- Public event detail page renders description/series-description/content
  as markdown via useMarkdown, with prose styles; agenda becomes an
  unordered list with the same custom bullets.
- Events list API returns `isRegistered` per event (derived from the
  requester's registrations, ignoring cancelled rows), and the list page
  shows a "Registered" tag. Stops exposing the full registrations array
  in the list response.
- Removes the seats/sold-out/limited capacity UI from list and detail
  pages.
- EventTicketPurchase: minor template formatting (self-closing inputs,
  prettier wrapping).
2026-05-21 17:50:48 +01:00

471 lines
11 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>
<!-- HERO (compact) -->
<div class="hero">
<h1>Events</h1>
<p>
Workshops, meetups, and gatherings for game developers practicing
cooperative models. Some events are open to the public.
</p>
</div>
<!-- FILTER BAR -->
<FilterBar v-model="activeFilter" :filters="filterOptions">
<button
type="button"
class="past-toggle"
:class="{ active: includePastEvents }"
:aria-pressed="includePastEvents"
@click="includePastEvents = !includePastEvents"
>
<span class="past-toggle-box" aria-hidden="true">
<span v-if="includePastEvents" class="past-toggle-check">×</span>
</span>
Show past events
</button>
</FilterBar>
<!-- EVENT LIST -->
<div class="event-list-full">
<div
v-for="event in filteredEvents"
:key="event._id"
class="event-row"
:class="{ 'is-cancelled': event.isCancelled }"
>
<div class="event-date-col">
<span class="event-date">{{ formatDate(event) }}</span>
<span class="event-time">{{ formatTime(event) }}</span>
</div>
<div class="event-info">
<div class="event-title">
<NuxtLink :to="`/events/${event.slug || event._id}`">{{
event.title
}}</NuxtLink>
<span v-if="event.isCancelled" class="cancelled-tag"
>cancelled</span
>
<span v-if="event.isRegistered" class="registered-tag"
>Registered</span
>
</div>
<div v-if="event.tagline" class="event-tagline">
{{ event.tagline }}
</div>
<div class="event-sub">
<span v-if="event.eventType" class="event-type-tag">{{
eventTypeLabel(event.eventType)
}}</span>
<span v-if="event.eventType" class="sep">·</span>
<span class="event-location">{{ formatLocation(event) }}</span>
</div>
</div>
<div class="event-badges">
<span v-if="event.membersOnly" class="members-badge">Members</span>
<CircleBadge v-if="event.circle" :circle="event.circle" />
<span v-else class="badge all">Public</span>
</div>
</div>
<div v-if="!filteredEvents?.length" class="empty">No events found</div>
</div>
<!-- EVENT SERIES -->
<div v-if="activeSeries?.length" class="full-section">
<div class="section-label">Event Series</div>
<div class="series-grid">
<NuxtLink
v-for="series in activeSeries"
:key="series.id"
:to="`/series/${series.id}`"
class="series-box"
>
<h2>{{ series.title }}</h2>
<p class="series-desc">{{ series.description }}</p>
<div class="series-meta">
<span
>{{
series.eventCount || series.events?.length || 0
}}
sessions</span
>
<span v-if="series.startDate"
>{{ formatDate(series.startDate) }} &ndash;
{{ formatDate(series.endDate) }}</span
>
</div>
</NuxtLink>
<div
v-if="activeSeries.length % 2"
class="series-box series-box-filler"
aria-hidden="true"
/>
</div>
</div>
</div>
</template>
<script setup>
import { EVENT_TYPES, eventTypeLabel } from "~/config/eventTypes";
useSiteMeta({
title: "Events",
description:
"Workshops, meetups, and gatherings for game developers practicing cooperative models. Some events are open to the public; others are for Ghost Guild members.",
});
const activeFilter = ref("all");
const includePastEvents = ref(false);
const filterOptions = [
{ label: "All", value: "all" },
...EVENT_TYPES.map((t) => ({ label: t.label, value: t.value })),
];
const { data: eventsData } = await useFetch("/api/events");
const { data: seriesData } = await useFetch("/api/series");
const filteredEvents = computed(() => {
const now = new Date();
if (!eventsData.value) return [];
return eventsData.value.filter((event) => {
if (!includePastEvents.value && new Date(event.startDate) < now)
return false;
if (activeFilter.value !== "all" && event.eventType !== activeFilter.value)
return false;
return true;
});
});
const activeSeries = computed(() => {
if (!seriesData.value) return [];
return seriesData.value.filter(
(s) => s.status === "active" || s.isOngoing || s.isUpcoming,
);
});
const formatDate = (event) => {
if (!event?.startDate) return "";
const tz = event.displayTimezone || "America/Toronto";
const d = new Date(event.startDate);
const opts = { month: "short", day: "numeric", timeZone: tz };
const dYear = d.toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
const nowYear = new Date().toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
if (dYear !== nowYear) opts.year = "numeric";
return d.toLocaleDateString("en-US", opts);
};
const formatTime = (event) => {
if (!event?.startDate) return "";
return new Date(event.startDate).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
timeZone: event.displayTimezone || "America/Toronto",
});
};
const formatLocation = (event) => {
if (event.isOnline) return "Online";
if (!event.location) return "";
if (event.location.startsWith("#")) return event.location;
// Treat any URL as an online link
if (event.location.startsWith("http")) return "Online";
return event.location;
};
</script>
<style scoped>
.hero {
padding: 32px 28px 24px;
border-bottom: 1px dashed var(--border);
}
.hero h1 {
font-family: var(--font-display);
font-size: 28px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.2;
margin-bottom: 8px;
}
.hero p {
color: var(--text-dim);
font-size: 13px;
line-height: 1.7;
max-width: 460px;
}
/* ---- EVENT LIST ---- */
.event-list-full {
padding: 0 28px;
border-bottom: 1px dashed var(--border);
}
.event-row {
display: grid;
grid-template-columns: 90px 1fr auto;
gap: 16px;
align-items: start;
padding: 14px 0;
border-bottom: 1px dashed var(--border);
transition: padding-left 0.2s;
}
.event-row:first-child {
padding-top: 18px;
}
.event-row:last-child {
border-bottom: none;
padding-bottom: 18px;
}
.event-row:hover {
padding-left: 4px;
}
.event-row.is-cancelled .event-title a {
text-decoration: line-through;
text-decoration-thickness: 1px;
}
.event-row.is-cancelled .event-tagline {
text-decoration: line-through;
}
.event-date-col {
display: flex;
flex-direction: column;
gap: 3px;
padding-top: 1px;
}
.event-date {
color: var(--text-faint);
font-size: 12px;
white-space: nowrap;
}
.event-time {
color: var(--text-faint);
font-size: 11px;
white-space: nowrap;
}
.event-info {
min-width: 0;
}
.event-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
color: var(--text);
}
.event-title a {
color: var(--text);
text-decoration: none;
}
.event-title a:hover {
color: var(--candle);
}
.cancelled-tag {
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--ember);
border: 1px solid currentColor;
padding: 1px 5px;
line-height: 1.5;
flex-shrink: 0;
}
.registered-tag {
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--candle);
border: 1px solid currentColor;
padding: 1px 5px;
line-height: 1.5;
flex-shrink: 0;
}
.event-tagline {
font-size: 11px;
color: var(--text-dim);
line-height: 1.55;
margin-top: 3px;
}
.event-sub {
display: flex;
align-items: center;
gap: 5px;
margin-top: 3px;
}
.event-type-tag {
font-size: 10px;
color: var(--text-faint);
text-transform: capitalize;
}
.sep {
font-size: 10px;
color: var(--text-faint);
}
.event-location {
font-size: 10px;
color: var(--text-faint);
}
.event-badges {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.members-badge {
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-faint);
border: 1px dashed var(--border);
padding: 1px 5px;
white-space: nowrap;
line-height: 1.5;
}
/* ---- FULL SECTION ---- */
.full-section {
padding: 32px 28px;
border-bottom: 1px dashed var(--border);
}
/* ---- SERIES ---- */
.series-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
border: 1px dashed var(--border);
}
.series-box {
padding: 20px 24px;
text-decoration: none;
transition: background 0.15s;
border-right: 1px dashed var(--border);
border-bottom: 1px dashed var(--border);
}
.series-box:nth-child(2n) {
border-right: none;
}
.series-box:nth-last-child(-n + 2) {
border-bottom: none;
}
.series-box-filler {
pointer-events: none;
}
.series-box:not(.series-box-filler):hover {
background: var(--surface-hover);
}
.series-box h2 {
font-family: var(--font-display);
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 4px;
}
.series-desc {
font-size: 12px;
color: var(--text-dim);
line-height: 1.6;
margin-bottom: 8px;
}
.series-meta {
font-size: 10px;
color: var(--text-faint);
display: flex;
gap: 12px;
align-items: center;
}
.past-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
font-family: "Commit Mono", monospace;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--text-faint);
background: transparent;
border: 1px dashed var(--border);
padding: 4px 10px;
cursor: pointer;
transition: all 0.15s;
}
.past-toggle:hover {
border-color: var(--candle-faint);
color: var(--text-dim);
}
.past-toggle:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 3px;
}
.past-toggle.active {
border-color: var(--candle);
border-style: solid;
color: var(--candle);
}
.past-toggle-box {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
border: 1px solid currentColor;
flex-shrink: 0;
}
.past-toggle-check {
font-size: 12px;
line-height: 1;
color: var(--candle);
}
.empty {
padding: 24px 0;
color: var(--text-faint);
font-size: 12px;
}
@media (max-width: 768px) {
.hero,
.event-list-full,
.full-section {
padding-left: 20px;
padding-right: 20px;
}
.event-row {
grid-template-columns: 70px 1fr;
gap: 8px;
}
.event-badges {
display: none;
}
.series-grid {
grid-template-columns: 1fr;
}
.series-box {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.series-box:nth-child(2n) {
border-right: none;
}
.series-box:nth-last-child(-n + 2) {
border-bottom: 1px dashed var(--border);
}
.series-box:last-child {
border-bottom: none;
}
.series-box-filler {
display: none;
}
}
</style>