ghostguild-org/app/pages/events/index.vue

974 lines
28 KiB
Vue

<template>
<div>
<!-- Page Header -->
<PageHeader
title="Events"
subtitle="Join our community events, workshops, and gatherings"
size="large"
/>
<!-- Events Section with Tabs -->
<section class="bg-ghost-900 dark:bg-ghost-950">
<UContainer>
<UTabs
v-model="activeTab"
:items="[
{ label: 'What\'s On', value: 'upcoming', slot: 'upcoming' },
{ label: 'Calendar', value: 'calendar', slot: 'calendar' },
]"
class="max-w-6xl mx-auto"
>
<template #upcoming>
<!-- Search and Filter Controls -->
<div class="pt-8">
<div class="max-w-4xl mx-auto mb-8">
<div class="flex flex-col gap-4">
<!-- Search Input -->
<div
class="flex flex-row flex-wrap gap-4 items-center justify-center"
>
<UInput
v-model="searchQuery"
placeholder="Search events..."
autocomplete="off"
size="xl"
class="rounded-md shadow-sm text-lg w-full md:w-2/3"
:ui="{ icon: { trailing: { pointer: '' } } }"
>
<template #trailing>
<UButton
v-show="searchQuery"
icon="i-heroicons-x-mark-20-solid"
color="gray"
variant="ghost"
@click="searchQuery = ''"
class="mr-1"
/>
</template>
</UInput>
<!-- Past Events Toggle -->
<div
class="flex flex-col items-center md:items-start gap-2"
>
<label class="text-sm font-medium text-ghost-200">
Show past events?
</label>
<USwitch v-model="includePastEvents" />
</div>
</div>
<!-- Category Filter -->
<div class="flex justify-center">
<UPopover
:ui="{ base: 'overflow-visible' }"
:popper="{
placement: 'bottom-end',
strategy: 'absolute',
}"
>
<UButton
icon="i-heroicons-adjustments-vertical-20-solid"
variant="outline"
color="primary"
size="sm"
class="rounded-md shadow-sm"
>
Filter Events
</UButton>
<template #panel="{ close }">
<div
class="bg-ghost-800 dark:bg-ghost-900 p-6 rounded-md shadow-lg w-80 space-y-4"
>
<div class="space-y-2">
<label class="text-sm font-medium text-ghost-200">
Filter by category
</label>
<USelectMenu
v-model="selectedCategories"
:options="eventCategories"
option-attribute="name"
multiple
value-attribute="id"
placeholder="Select categories..."
:ui="{
base: 'overflow-visible',
menu: { maxHeight: '200px', overflowY: 'auto' },
popper: { strategy: 'absolute' },
}"
/>
</div>
</div>
</template>
</UPopover>
</div>
</div>
<!-- Search Status Message -->
<div v-if="isSearching" class="mt-6 text-center">
<p class="text-ghost-300 text-sm">
{{
filteredEvents.length > 0
? `Found ${filteredEvents.length} event${
filteredEvents.length !== 1 ? "s" : ""
}`
: "No events found matching your search"
}}
</p>
</div>
</div>
</div>
<!-- Events List -->
<div class="max-w-4xl mx-auto space-y-6 pb-8">
<NuxtLink
v-for="event in upcomingEvents"
:key="event.id"
:to="`/events/${event.slug || event.id}`"
class="group flex items-start gap-4 py-2 hover:opacity-80 transition-opacity"
>
<div class="flex-shrink-0 text-center">
<div class="text-2xl font-bold text-ghost-100">
{{ event.start.getDate() }}
</div>
<div class="text-xs text-ghost-400 uppercase">
{{
event.start.toLocaleDateString("en-US", {
month: "short",
})
}}
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start gap-2 mb-1">
<h3
class="text-lg font-semibold text-ghost-100 group-hover:text-primary transition-colors"
>
{{ event.title }}
</h3>
<Icon
v-if="event.membersOnly"
name="heroicons:lock-closed"
class="w-4 h-4 text-purple-500 flex-shrink-0 mt-1"
/>
</div>
<p class="text-sm text-ghost-300 mb-2 line-clamp-2">
{{ event.content }}
</p>
<div v-if="event.series?.isSeriesEvent" class="mt-2">
<EventSeriesBadge
:title="event.series.title"
:position="event.series.position"
:total-events="event.series.totalEvents"
:series-id="event.series.id"
/>
</div>
</div>
<Icon
name="heroicons:arrow-right"
class="w-5 h-5 text-ghost-400 group-hover:text-primary group-hover:translate-x-1 transition-all flex-shrink-0 mt-1"
/>
</NuxtLink>
</div>
</template>
<template #calendar>
<div class="pt-8 pb-8">
<div class="max-w-6xl mx-auto" id="event-calendar">
<ClientOnly>
<div
v-if="pending"
class="min-h-[400px] bg-ghost-800 rounded-xl flex items-center justify-center"
>
<div class="text-center">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div>
<p class="text-ghost-200">Loading calendar...</p>
</div>
</div>
<div v-else style="min-height: 600px">
<VueCal
ref="vuecal"
:events="calendarEvents"
:time="false"
active-view="month"
class="ghost-calendar"
:disable-views="['years', 'year']"
:hide-view-selector="false"
events-on-month-view="short"
today-button
@event-click="onEventClick"
>
</VueCal>
</div>
<template #fallback>
<div
class="min-h-[400px] bg-ghost-800 rounded-xl flex items-center justify-center"
>
<div class="text-center">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div>
<p class="text-ghost-200">Loading calendar...</p>
</div>
</div>
</template>
</ClientOnly>
</div>
</div>
</template>
</UTabs>
</UContainer>
</section>
<!-- Event Series -->
<div v-if="activeSeries.length > 0" class="text-center my-12">
<h2 class="text-3xl font-bold text-ghost-100 mb-8">
Current Event Series
</h2>
</div>
<div
v-if="activeSeries.length > 0"
class="space-y-6 max-w-6xl mx-auto mb-20"
>
<NuxtLink
v-for="series in activeSeries.slice(0, 6)"
:key="series.id"
:to="`/series/${series.id}`"
class="series-list-item block bg-ghost-800/50 dark:bg-ghost-700/30 rounded-xl p-6 border border-ghost-600 dark:border-ghost-600 hover:border-ghost-500 hover:bg-ghost-800/70 dark:hover:bg-ghost-700/50 transition-all duration-300"
>
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-2">
<span
class="series-list-item__label text-sm font-semibold text-ghost-300 dark:text-ghost-300"
>
Event Series
</span>
<span
class="series-list-item__count inline-flex items-center px-2 py-0.5 rounded-md bg-ghost-700/50 dark:bg-ghost-600/50 text-sm font-medium text-ghost-200 dark:text-ghost-200"
>
{{ series.eventCount }} events
</span>
</div>
<span
:class="[
'series-list-item__status inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: series.status === 'upcoming'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
]"
>
{{ series.status }}
</span>
</div>
<h3
class="series-list-item__title text-lg font-semibold text-ghost-100 dark:text-ghost-100 mb-2"
>
{{ series.title }}
</h3>
<p
class="series-list-item__description text-sm text-ghost-300 dark:text-ghost-300 mb-4 line-clamp-2"
>
{{ series.description }}
</p>
<div class="series-list-item__events space-y-2 mb-4">
<div
v-for="(event, index) in series.events.slice(0, 3)"
:key="index"
class="series-list-item__event flex items-center justify-between text-xs"
>
<div class="flex items-center gap-2">
<div
class="series-list-item__event-number w-6 h-6 bg-ghost-700/50 dark:bg-ghost-600/50 text-ghost-200 dark:text-ghost-200 rounded-full flex items-center justify-center text-xs font-medium border border-ghost-600 dark:border-ghost-500"
>
{{ event.series?.position || index + 1 }}
</div>
<span
class="series-list-item__event-title text-ghost-200 dark:text-ghost-200 truncate"
>{{ event.title }}</span
>
</div>
<span
class="series-list-item__event-date text-ghost-300 dark:text-ghost-300"
>
{{ formatEventDate(event.startDate) }}
</span>
</div>
<div
v-if="series.events.length > 3"
class="series-list-item__more-events text-xs text-ghost-300 dark:text-ghost-300 text-center pt-1"
>
+{{ series.events.length - 3 }} more events
</div>
</div>
<div
class="series-list-item__date-range text-sm text-ghost-300 dark:text-ghost-300"
>
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
</NuxtLink>
</div>
<!-- Attend Our Events -->
<section class="py-20 bg-ghost-800 dark:bg-ghost-900">
<UContainer>
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-ghost-100 mb-8">
Attend Our Events
</h2>
</div>
<div class="max-w-4xl mx-auto">
<div
class="bg-ghost-900 rounded-2xl p-8 border border-ghost-700 mb-12"
>
<div class="prose prose-lg dark:prose-invert max-w-none">
<p class="text-lg leading-relaxed text-ghost-200 mb-6">
Our events bring together game developers, founders, and practitioners
who are building more equitable workplaces. From hands-on workshops
about governance and finance to casual co-working sessions and game nights,
there's something for every stage of your journey.
</p>
<p class="text-lg leading-relaxed text-ghost-200 mb-6">
All events are designed to be accessible, with most offered free to members
and sliding-scale pricing for non-members. Can't make it live?
Many sessions are recorded and shared in our resource library.
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="text-center">
<h3 class="text-lg font-semibold text-ghost-100 mb-2">
Monthly Meetups
</h3>
<p class="text-sm text-ghost-300">
Casual knowledge sharing sessions
</p>
</div>
<div class="text-center">
<h3 class="text-lg font-semibold text-ghost-100 mb-2">
Workshops
</h3>
<p class="text-sm text-ghost-300">
Hands-on learning about cooperative and worker-centric business
models
</p>
</div>
<div class="text-center">
<h3 class="text-lg font-semibold text-ghost-100 mb-2">
Social Events
</h3>
<p class="text-sm text-ghost-300">
Game nights, socials, and more
</p>
</div>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
import { VueCal } from "vue-cal";
import "vue-cal/style.css";
// Composables
const { formatDateRange, formatDate, isPastDate } = useEventDateUtils();
const {
searchQuery,
includePastEvents,
searchResults,
selectedCategories,
isSearching,
saveSearchState,
loadSearchState,
clearSearch: clearSearchFilters,
} = useCalendarSearch();
// Active tab state
const activeTab = ref("upcoming");
// Hover state for calendar cells
const hoveredCells = ref({});
const handleMouseEnter = (date) => {
hoveredCells.value[date] = true;
};
const handleMouseLeave = (date) => {
hoveredCells.value[date] = false;
};
const isHovered = (date) => {
return hoveredCells.value[date] || false;
};
// Fetch events from API
const { data: eventsData, pending, error } = await useFetch("/api/events");
// Fetch series from API
const { data: seriesData } = await useFetch("/api/series");
// Event categories for filtering
const eventCategories = computed(() => {
if (!eventsData.value) return [];
const categories = new Set();
eventsData.value.forEach((event) => {
if (event.eventType) {
categories.add(event.eventType);
}
});
return Array.from(categories).map((type) => ({
id: type,
name: type.charAt(0).toUpperCase() + type.slice(1),
}));
});
// Transform events for calendar display
const events = computed(() => {
if (!eventsData.value) {
return [];
}
const transformed = eventsData.value.map((event) => ({
id: event.id || event._id,
slug: event.slug,
start: new Date(event.startDate),
end: new Date(event.endDate),
title: event.title,
content: event.description,
class: `event-${event.eventType}`,
membersOnly: event.membersOnly,
eventType: event.eventType,
location: event.location,
registeredCount: event.registeredCount,
maxAttendees: event.maxAttendees,
featureImage: event.featureImage,
series: event.series,
}));
return transformed;
});
// Helper function to format date for VueCal
// When time is disabled, VueCal expects just 'YYYY-MM-DD' format
const formatDateForVueCal = (date) => {
const d = date instanceof Date ? date : new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
// Transform events for calendar view
const calendarEvents = computed(() => {
const filtered = events.value
.filter((event) => {
// Filter by past events setting
if (!includePastEvents.value && isPastDate(event.start)) {
return false;
}
return true;
})
.map((event) => {
// VueCal expects Date objects when time is disabled
// Only pass title (no content/description) for clean display
return {
start: event.start, // Use Date object directly
end: event.end, // Use Date object directly
title: event.title, // Only title, no description
class: event.class,
};
});
return filtered;
});
// Filter events based on search query and selected categories
const filteredEvents = computed(() => {
return events.value.filter((event) => {
// Filter by past events setting
if (!includePastEvents.value && isPastDate(event.start)) {
return false;
}
// Filter by search query
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
const matchesTitle = event.title.toLowerCase().includes(query);
const matchesDescription =
event.content?.toLowerCase().includes(query) || false;
if (!matchesTitle && !matchesDescription) {
return false;
}
}
// Filter by selected categories
if (selectedCategories.value.length > 0) {
if (!selectedCategories.value.includes(event.eventType)) {
return false;
}
}
return true;
});
});
// Get active event series
const activeSeries = computed(() => {
if (!seriesData.value) return [];
return seriesData.value.filter(
(series) =>
series.status === "active" || series.isOngoing || series.isUpcoming,
);
});
// Get upcoming events (future events, respecting filters)
const upcomingEvents = computed(() => {
const now = new Date();
return filteredEvents.value
.filter((event) => event.start > now)
.sort((a, b) => a.start - b.start)
.slice(0, 10); // Show max 10 upcoming events
});
// Format event date for display
const formatEventDate = (date) => {
const dateObj = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(dateObj);
};
// Get optimized Cloudinary image URL
const getOptimizedImageUrl = (publicId, transformations) => {
if (!publicId) return "";
const config = useRuntimeConfig();
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`;
};
// Get image URL with fallback logic
const getImageUrl = (featureImage) => {
if (!featureImage) return "";
// If we have a direct URL, use it as primary (since seed data uses external URLs)
if (featureImage.url) {
return featureImage.url;
}
// Fallback to Cloudinary if we have a publicId
if (featureImage.publicId) {
return getOptimizedImageUrl(featureImage.publicId, "w_400,h_200,c_fill");
}
return "";
};
// Handle image loading errors
const handleImageError = (event) => {
console.warn("Image failed to load:", event.target.src);
// Optionally hide the image container or show a placeholder
};
// Handle calendar event click
const onEventClick = (event) => {
if (event.id) {
navigateTo(`/events/${event.slug || event.id}`);
}
};
// Series helper functions
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 getSeriesTypeBadgeClass = (type) => {
const classes = {
workshop_series:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
recurring_meetup:
"bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
multi_day:
"bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
course:
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
tournament: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
};
return (
classes[type] ||
"bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400"
);
};
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Calendar styling based on tranzac design principles */
.ghost-calendar :deep(.vuecal__event) {
border-radius: 0.5rem;
border: 2px solid #292524;
transform: translateZ(0);
transition: transform 300ms;
}
.ghost-calendar :deep(.vuecal__event:hover) {
transform: scale(1.05);
}
.ghost-calendar :deep(.vuecal__event-title) {
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ghost-calendar :deep(.vuecal__event-time) {
display: none;
}
.ghost-calendar :deep(.vuecal__event.event-community) {
background-color: #2563eb;
color: #f5f5f4;
border-color: #1d4ed8;
}
.ghost-calendar :deep(.vuecal__event.event-workshop) {
background-color: #059669;
color: #f5f5f4;
border-color: #047857;
}
.ghost-calendar :deep(.vuecal__event.event-social) {
background-color: #7c3aed;
color: #f5f5f4;
border-color: #6d28d9;
}
.ghost-calendar :deep(.vuecal__event.event-showcase) {
background-color: #d97706;
color: #f5f5f4;
border-color: #b45309;
}
#event-calendar {
.vuecal__cell-events-count {
position: absolute;
top: 0.5rem;
right: 0.25rem;
left: auto;
background-color: transparent;
color: #f5f5f4;
font-weight: 700;
font-size: 1.25rem;
}
.month-view .vuecal__cell--today,
.vuecal__cell--today {
background-color: rgba(59, 130, 246, 0.15);
color: #f5f5f4;
border: 2px solid #3b82f6 !important;
.day-of-month {
display: flex;
align-items: center;
justify-content: center;
}
.day-of-month::before {
content: "Today";
text-transform: uppercase;
font-size: 0.75rem;
margin-right: 0.5rem;
display: none;
color: #3b82f6;
font-weight: 600;
}
@media (min-width: 768px) {
.day-of-month::before {
display: block;
}
}
}
.vuecal__cell--selected {
background-color: transparent;
}
.past-date {
background-color: rgba(120, 113, 108, 0.1);
color: #78716c;
}
.vuecal__cell--has-events {
}
.vuecal__cell--disabled {
}
.vuecal__cell-events {
}
.vuecal__event {
border-radius: 0.5rem;
border: 2px solid #3a3a3a;
transform: translateZ(0);
transition: transform 300ms;
&:hover {
transform: scale(1.05);
}
&.vuecal__event--selected {
background-color: #2a2a2a;
}
.vuecal__cell--today {
background-color: transparent;
}
.vuecal__event-title {
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #f5f5f4;
}
.vuecal__event-time {
font-weight: 400;
color: #a8a29e;
}
&.event-community {
background-color: #2563eb;
color: #f5f5f4;
border-color: #1d4ed8;
}
&.event-workshop {
background-color: #059669;
color: #f5f5f4;
border-color: #047857;
}
&.event-social {
background-color: #7c3aed;
color: #f5f5f4;
border-color: #6d28d9;
}
&.event-showcase {
background-color: #d97706;
color: #f5f5f4;
border-color: #b45309;
}
}
.vuecal__cell.vuecal__cell--out-of-scope {
pointer-events: none;
cursor: not-allowed;
border: none;
}
.outOfScope {
height: 100%;
width: 100%;
position: absolute;
background-color: rgba(0, 0, 0, 0.5);
pointer-events: none;
cursor: not-allowed;
}
:not(.vuecal__cell--out-of-scope) .vuecal__cell {
border: 1px solid #3a3a3a;
}
.vuecal__cell:before {
border: none;
}
.vuecal__title-bar {
margin-top: 1rem;
margin-bottom: 1rem;
}
}
/* Ghost calendar theme */
.ghost-calendar {
--vuecal-primary-color: #fff;
--vuecal-text-color: #e7e5e4;
--vuecal-border-color: #57534e;
--vuecal-header-color: #1c1917;
--vuecal-today-color: #292524;
background-color: #292524;
}
.ghost-calendar :deep(.vuecal__bg) {
background-color: #292524;
}
.ghost-calendar :deep(.vuecal__header) {
background-color: #1c1917;
border-bottom: 1px solid #57534e;
}
.ghost-calendar :deep(.vuecal__title-bar) {
background-color: #1c1917;
}
.ghost-calendar :deep(.vuecal__title) {
color: #e7e5e4;
font-weight: 600;
}
.ghost-calendar :deep(.vuecal__weekdays-headings) {
background-color: #1c1917;
border-bottom: 1px solid #57534e;
}
.ghost-calendar :deep(.vuecal__heading) {
color: #a8a29e;
font-weight: 500;
}
.ghost-calendar :deep(.vuecal__cell) {
background-color: #292524;
border-color: #57534e;
color: #e7e5e4;
}
.ghost-calendar :deep(.vuecal__cell:hover) {
background-color: #44403c;
border-color: #78716c;
}
.ghost-calendar :deep(.vuecal__cell-content) {
color: #e7e5e4;
}
.ghost-calendar :deep(.vuecal__cell--today) {
background-color: rgba(59, 130, 246, 0.1);
border: 2px solid #3b82f6;
}
.ghost-calendar :deep(.vuecal__cell--out-of-scope) {
background-color: #1c1917;
color: #78716c;
}
.ghost-calendar :deep(.vuecal__arrow) {
color: #a8a29e;
}
.ghost-calendar :deep(.vuecal__arrow:hover) {
background-color: #44403c;
}
.ghost-calendar :deep(.vuecal__today-btn) {
background-color: #44403c;
color: #fff;
border: 1px solid #78716c;
font-weight: 600;
}
.ghost-calendar :deep(.vuecal__today-btn:hover) {
background-color: #57534e;
border-color: #a8a29e;
}
.ghost-calendar :deep(.vuecal__view-btn),
.ghost-calendar :deep(button[class*="view"]) {
background-color: #44403c !important;
color: #ffffff !important;
border: 1px solid #78716c !important;
font-weight: 600 !important;
}
.ghost-calendar :deep(.vuecal__view-btn:hover),
.ghost-calendar :deep(button[class*="view"]:hover) {
background-color: #57534e !important;
border-color: #a8a29e !important;
color: #ffffff !important;
}
.ghost-calendar :deep(.vuecal__view-btn--active),
.ghost-calendar :deep(button[class*="view"][class*="active"]) {
background-color: #0c0a09 !important;
color: #ffffff !important;
border-color: #a8a29e !important;
}
.ghost-calendar :deep(.vuecal__view-btn--active:hover),
.ghost-calendar :deep(button[class*="view"][class*="active"]:hover) {
background-color: #1c1917 !important;
border-color: #d6d3d1 !important;
color: #ffffff !important;
}
.ghost-calendar :deep(.vuecal__title-bar button) {
color: #ffffff !important;
font-weight: 600 !important;
}
.ghost-calendar :deep(.vuecal__title-bar .default-view-btn) {
background-color: #44403c !important;
color: #ffffff !important;
border: 1px solid #78716c !important;
}
.ghost-calendar :deep(.vuecal__title-bar .default-view-btn.active) {
background-color: #0c0a09 !important;
border-color: #a8a29e !important;
}
/* Responsive calendar */
.vuecal {
border-radius: 0.75rem;
overflow: hidden;
}
.vuecal__header {
background-color: var(--vuecal-header-color);
color: var(--vuecal-text-color);
}
.vuecal__title {
color: var(--vuecal-primary-color);
font-weight: 600;
}
</style>