ghostguild-org/app/pages/events/index.vue
Jennie Robinson Faber c6b970a621
Some checks failed
Test / vitest (push) Successful in 10m47s
Test / playwright (push) Failing after 9m11s
Test / visual (push) Failing after 9m11s
Test / Notify on failure (push) Successful in 2s
Design token updates.
2026-04-11 23:24:38 +01:00

459 lines
11 KiB
Vue

<template>
<div>
<!-- HERO (compact) -->
<div class="hero">
<h1>Events</h1>
<p>
Workshops, meetups, and gatherings for game developers practicing
cooperative models.
</p>
</div>
<!-- FILTER BAR -->
<FilterBar v-model="activeFilter" :filters="filterOptions">
<label class="filter-toggle">
<input v-model="includePastEvents" type="checkbox" /> Show past events
</label>
</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.startDate) }}</span>
<span class="event-time">{{ formatTime(event.startDate) }}</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
>
</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">{{
event.eventType
}}</span>
<span v-if="event.eventType" class="sep">·</span>
<span class="event-location">{{ formatLocation(event) }}</span>
</div>
</div>
<span class="event-capacity">
<template v-if="event.maxAttendees">
<span :class="{ 'seats-warn': isAlmostFull(event) }">
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
</span>
</template>
<template v-else>Open</template>
</span>
<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">All</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>
</div>
<!-- PROPOSE AN EVENT -->
<!-- TODO: Build /events/propose page + form for members to submit event ideas.
Think through before building:
- Who can propose? Members only, or any circle?
- Required fields: title, description, proposed date/time, target circle,
format (workshop/social/talk/etc.), estimated attendance
- Approval workflow: does an admin review and publish, or does it auto-post
as a draft?
- Interest threshold mechanic: can other members +1 a proposal to signal
demand before it gets formally scheduled?
- Notifications: proposer gets notified when approved/declined
See CLAUDE.md product spec for additional context. -->
<div class="full-section">
<div class="section-label">Have an idea?</div>
<DashedBox>
<h2>Propose an Event</h2>
<p>
Members can propose events for any circle. Workshops, social hangs,
talks, or anything else that serves the community.
</p>
<span class="cta cta-soon"
>Propose an event &rarr; <em>coming soon</em></span
>
</DashedBox>
</div>
</div>
</template>
<script setup>
const activeFilter = ref("all");
const includePastEvents = ref(false);
const filterOptions = [
{ label: "All", value: "all" },
{ label: "Workshops", value: "workshop" },
{ label: "Community", value: "community" },
{ label: "Social", value: "social" },
{ label: "Showcase", value: "showcase" },
];
const { data: eventsData } = await useFetch("/api/events");
const { data: seriesData } = await useFetch("/api/series");
const now = new Date();
const filteredEvents = computed(() => {
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 = (dateStr) => {
if (!dateStr) return "";
const d = new Date(dateStr);
const opts = { month: "short", day: "numeric" };
if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric";
return d.toLocaleDateString("en-US", opts);
};
const formatTime = (dateStr) => {
if (!dateStr) return "";
const d = new Date(dateStr);
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
};
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;
};
const isAlmostFull = (event) => {
if (!event.maxAttendees) return false;
return (event.registeredCount || 0) / event.maxAttendees > 0.8;
};
</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 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 {
opacity: 0.5;
}
.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;
}
.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-capacity {
font-size: 11px;
color: var(--text-faint);
white-space: nowrap;
padding-top: 2px;
}
.seats-warn {
color: var(--ember);
}
.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;
border-right: 1px dashed var(--border);
text-decoration: none;
transition: background 0.15s;
}
.series-box:last-child {
border-right: none;
}
.series-box: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;
}
/* ---- PROPOSE ---- */
.full-section h2 {
font-family: var(--font-display);
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 4px;
}
.full-section p {
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.cta {
display: inline-block;
margin-top: 8px;
font-size: 12px;
color: var(--candle);
}
.cta-soon {
color: var(--text-dim);
cursor: default;
}
.cta-soon em {
font-style: normal;
font-size: 10px;
}
.filter-toggle {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 11px;
color: var(--text-faint);
cursor: pointer;
}
.filter-toggle input {
accent-color: var(--candle-dim);
}
.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-capacity,
.event-badges {
display: none;
}
.series-grid {
grid-template-columns: 1fr;
}
.series-box {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.series-box:last-child {
border-bottom: none;
}
}
</style>