ghostguild-org/app/pages/events/index.vue
Jennie Robinson Faber b7d9d91b1a fix(events): replace 50% opacity on cancelled rows with strikethrough
The .is-cancelled row used opacity:0.5, which dragged --text-faint
(#665c4b) on the cream background to a 2.1:1 ratio against #f4efe4 —
serious axe violation flagged in CI. Strikethrough on the title and
tagline conveys the cancelled state without crushing contrast; the
existing .cancelled-tag in --ember still flags the row.
2026-05-01 12:08:29 +01:00

502 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.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>
<span v-if="isSoldOut(event)" class="capacity-badge sold-out"
>Sold out</span
>
<span
v-else-if="isAlmostFull(event)"
class="capacity-badge limited"
>Limited tickets</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">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>
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 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 = (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 isSoldOut = (event) => {
if (!event.maxAttendees) return false;
return (event.registeredCount || 0) >= event.maxAttendees;
};
const isAlmostFull = (event) => {
if (!event.maxAttendees) return false;
if (isSoldOut(event)) 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 .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;
}
.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;
display: inline-flex;
align-items: center;
gap: 6px;
}
.seats-warn {
color: var(--ember);
}
.capacity-badge {
font-size: 9px;
letter-spacing: 0.07em;
text-transform: uppercase;
padding: 1px 5px;
border: 1px dashed currentColor;
line-height: 1.5;
white-space: nowrap;
}
.capacity-badge.limited {
color: var(--ember);
}
.capacity-badge.sold-out {
color: var(--text-faint);
border-style: solid;
}
.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-capacity,
.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>