ghostguild-org/app/pages/admin/events/index.vue
Jennie Robinson Faber f16f9ada64 fix: resolve sidebar nav hydration mismatch and admin events 500 error
Wrap auth-dependent sidebar navigation and meta in ClientOnly with
SSR fallback slots to prevent hydration mismatch that caused all
authenticated nav links to point to wrong pages. Fix admin events
page crash by replacing empty string USelect values with 'all'.
2026-04-03 09:24:29 +01:00

510 lines
18 KiB
Vue

<template>
<div>
<div class="bg-guild-900 border-b border-guild-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-display font-bold text-guild-100">Event Management</h1>
<p class="text-guild-400">
Create, manage, and monitor Ghost Guild events and workshops
</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Search and Actions -->
<div class="mb-6 flex justify-between items-center">
<div class="flex gap-4 items-center">
<UInput
v-model="searchQuery"
placeholder="Search events..."
class="w-80"
/>
<USelect
v-model="typeFilter"
:items="[
{ label: 'All Types', value: 'all' },
{ label: 'Community', value: 'community' },
{ label: 'Workshop', value: 'workshop' },
{ label: 'Social', value: 'social' },
{ label: 'Showcase', value: 'showcase' },
]"
class="w-full"
/>
<USelect
v-model="statusFilter"
:items="[
{ label: 'All Status', value: 'all' },
{ label: 'Upcoming', value: 'upcoming' },
{ label: 'Ongoing', value: 'ongoing' },
{ label: 'Past', value: 'past' },
]"
class="w-full"
/>
<USelect
v-model="seriesFilter"
:items="[
{ label: 'All Events', value: 'all' },
{ label: 'Series Events Only', value: 'series-only' },
{ label: 'Standalone Only', value: 'standalone-only' },
]"
class="w-full"
/>
</div>
<NuxtLink
to="/admin/events/create"
class="bg-candlelight-600 text-white px-4 py-2 rounded-lg hover:bg-candlelight-700 focus:ring-2 focus:ring-candlelight-500 focus:ring-offset-2 inline-flex items-center"
>
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
Create Event
</NuxtLink>
</div>
<!-- Events Table -->
<div class="bg-guild-900 rounded-lg shadow overflow-hidden">
<div v-if="pending" class="p-8 text-center text-guild-100">
<div class="inline-flex items-center">
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-candlelight-500 mr-3"
></div>
Loading events...
</div>
</div>
<div v-else-if="error" class="p-8 text-center text-ember-400">
Error loading events: {{ error }}
</div>
<table v-else class="w-full">
<thead class="bg-guild-950">
<tr>
<th
class="px-6 py-4 text-left text-xs font-medium text-guild-500 uppercase tracking-wider"
>
Title
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-guild-500 uppercase tracking-wider"
>
Type
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-guild-500 uppercase tracking-wider"
>
Date
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-guild-500 uppercase tracking-wider"
>
Status
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-guild-500 uppercase tracking-wider"
>
Registration
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-guild-500 uppercase tracking-wider"
>
Tickets
</th>
<th
class="px-6 py-4 text-right text-xs font-medium text-guild-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody class="bg-guild-900 divide-y divide-guild-700">
<tr
v-for="event in filteredEvents"
:key="event._id"
class="hover:bg-guild-800"
>
<!-- Title Column -->
<td class="px-6 py-6">
<div class="flex items-start space-x-3">
<div
v-if="
event.featureImage?.url && !event.featureImage?.publicId
"
class="flex-shrink-0 w-12 h-12 bg-guild-800 rounded-lg overflow-hidden"
>
<img
:src="event.featureImage.url"
:alt="event.title"
class="w-full h-full object-cover"
@error="handleImageError($event)"
/>
</div>
<div
v-else
class="flex-shrink-0 w-12 h-12 bg-guild-800 rounded-lg flex items-center justify-center"
>
<Icon
name="heroicons:calendar-days"
class="w-6 h-6 text-guild-400"
/>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-guild-100 mb-1">
{{ event.title }}
</div>
<div class="text-sm text-guild-500 line-clamp-2">
{{ event.description.substring(0, 100) }}...
</div>
<div v-if="event.series?.isSeriesEvent" class="mt-2 mb-2">
<div
class="inline-flex items-center gap-1 px-2 py-1 bg-earth-900/20 text-earth-400 text-xs font-medium rounded-full"
>
<div
class="w-4 h-4 bg-earth-800 text-earth-400 rounded-full flex items-center justify-center text-xs font-bold"
>
{{ event.series.position }}
</div>
{{ event.series.title }}
</div>
</div>
<div class="flex items-center space-x-4 mt-2">
<div
v-if="event.membersOnly"
class="flex items-center text-xs text-earth-400"
>
<Icon
name="heroicons:lock-closed"
class="w-3 h-3 mr-1"
/>
Members Only
</div>
<div
v-if="
event.targetCircles && event.targetCircles.length > 0
"
class="flex items-center space-x-1"
>
<Icon
name="heroicons:user-group"
class="w-3 h-3 text-guild-400"
/>
<span class="text-xs text-guild-500">{{
event.targetCircles.join(", ")
}}</span>
</div>
<div
v-if="!event.isVisible"
class="flex items-center text-xs text-guild-500"
>
<Icon name="heroicons:eye-slash" class="w-3 h-3 mr-1" />
Hidden
</div>
</div>
</div>
</div>
</td>
<!-- Type Column -->
<td class="px-4 py-6 whitespace-nowrap">
<span
:class="getEventTypeClasses(event.eventType)"
class="inline-flex px-3 py-1 text-xs font-semibold rounded-full capitalize"
>
{{ event.eventType }}
</span>
</td>
<!-- Date Column -->
<td class="px-4 py-6 whitespace-nowrap text-sm text-guild-400">
<div class="space-y-1">
<div class="font-medium">
{{ formatDate(event.startDate) }}
</div>
<div class="text-xs text-guild-500">
{{ formatTime(event.startDate) }}
</div>
</div>
</td>
<!-- Status Column -->
<td class="px-4 py-6 whitespace-nowrap">
<div class="space-y-2">
<span
:class="getStatusClasses(event)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ getEventStatus(event) }}
</span>
<div
v-if="event.isCancelled"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-ember-900/20 text-ember-400"
>
Cancelled
</div>
</div>
</td>
<!-- Registration Column -->
<td class="px-4 py-6 whitespace-nowrap">
<div class="space-y-2">
<div
v-if="event.registrationRequired"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-candlelight-900/20 text-candlelight-400"
>
Required
</div>
<div
v-else
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-guild-800 text-guild-300"
>
Optional
</div>
<div v-if="event.maxAttendees" class="text-xs text-guild-500">
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
</div>
</div>
</td>
<!-- Tickets Column -->
<td class="px-4 py-6 whitespace-nowrap">
<div class="space-y-1">
<div v-if="event.tickets?.enabled" class="space-y-1">
<div class="flex items-center gap-1 text-xs">
<Icon
name="heroicons:ticket"
class="w-3.5 h-3.5 text-candlelight-400"
/>
<span class="font-medium text-guild-100">Ticketing On</span>
</div>
<div
v-if="event.tickets?.requiresSeriesTicket"
class="text-xs text-earth-400"
>
Series Pass Required
</div>
<div v-else class="space-y-0.5">
<div
v-if="event.tickets.member?.available"
class="text-xs text-guild-500"
>
Member:
{{
event.tickets.member.isFree
? "Free"
: `$${event.tickets.member.price}`
}}
</div>
<div
v-if="event.tickets.public?.available"
class="text-xs text-guild-500"
>
Public: ${{ event.tickets.public.price || 0 }}
<span v-if="event.tickets.public.quantity" class="ml-1">
({{ event.tickets.public.sold || 0 }}/{{
event.tickets.public.quantity
}})
</span>
</div>
</div>
</div>
<div v-else class="text-xs text-guild-500">No tickets</div>
</div>
</td>
<!-- Actions Column -->
<td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end space-x-2">
<NuxtLink
:to="`/events/${event.slug || String(event._id)}`"
class="p-2 text-guild-500 hover:text-guild-100 hover:bg-guild-800 rounded-full transition-colors"
title="View Event"
>
<Icon name="heroicons:eye" class="w-4 h-4" />
</NuxtLink>
<button
@click="editEvent(event)"
class="p-2 text-candlelight-400 hover:text-candlelight-300 hover:bg-candlelight-900/20 rounded-full transition-colors"
title="Edit Event"
>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
</button>
<button
@click="duplicateEvent(event)"
class="p-2 text-candlelight-400 hover:text-candlelight-300 hover:bg-candlelight-900/20 rounded-full transition-colors"
title="Duplicate Event"
>
<Icon name="heroicons:document-duplicate" class="w-4 h-4" />
</button>
<button
@click="deleteEvent(event)"
class="p-2 text-ember-400 hover:text-ember-300 hover:bg-ember-900/20 rounded-full transition-colors"
title="Delete Event"
>
<Icon name="heroicons:trash" class="w-4 h-4" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div
v-if="!pending && !error && filteredEvents.length === 0"
class="p-8 text-center text-guild-500"
>
No events found matching your criteria
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: "admin",
});
const {
data: events,
pending,
error,
refresh,
} = await useFetch("/api/admin/events");
const searchQuery = ref("");
const typeFilter = ref("all");
const statusFilter = ref("all");
const seriesFilter = ref("all");
const filteredEvents = computed(() => {
if (!events.value) return [];
return events.value.filter((event) => {
const matchesSearch =
!searchQuery.value ||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase());
const matchesType =
typeFilter.value === "all" || event.eventType === typeFilter.value;
const eventStatus = getEventStatus(event);
const matchesStatus =
statusFilter.value === "all" || eventStatus.toLowerCase() === statusFilter.value;
const matchesSeries =
seriesFilter.value === "all" ||
(seriesFilter.value === "series-only" && event.series?.isSeriesEvent) ||
(seriesFilter.value === "standalone-only" &&
!event.series?.isSeriesEvent);
return matchesSearch && matchesType && matchesStatus && matchesSeries;
});
});
const getEventTypeClasses = (type) => {
const classes = {
community: "bg-candlelight-900/20 text-candlelight-400",
workshop: "bg-candlelight-900/20 text-candlelight-400",
social: "bg-earth-900/20 text-earth-400",
showcase: "bg-ember-900/20 text-ember-400",
};
return classes[type] || "bg-guild-800 text-guild-300";
};
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 "Past";
};
const getStatusClasses = (event) => {
const status = getEventStatus(event);
const classes = {
Upcoming: "bg-candlelight-900/20 text-candlelight-400",
Ongoing: "bg-candlelight-900/20 text-candlelight-400",
Past: "bg-guild-800 text-guild-300",
};
return classes[status] || "bg-guild-800 text-guild-300";
};
const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleString();
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const formatTime = (dateString) => {
return new Date(dateString).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
};
// 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}`;
};
const duplicateEvent = (event) => {
// Navigate to create page with duplicate query parameter
const duplicateData = {
title: `${event.title} (Copy)`,
description: event.description,
content: event.content || "",
featureImage: event.featureImage || null,
eventType: event.eventType,
location: event.location || "",
isOnline: event.isOnline,
isVisible: true,
isCancelled: false,
cancellationMessage: "",
targetCircles: event.targetCircles || [],
maxAttendees: event.maxAttendees || "",
registrationRequired: event.registrationRequired,
};
// Store duplicate data in session storage for the create page to use
sessionStorage.setItem("duplicateEventData", JSON.stringify(duplicateData));
navigateTo("/admin/events/create?duplicate=true");
};
const deleteEvent = async (event) => {
if (confirm(`Are you sure you want to delete "${event.title}"?`)) {
try {
await $fetch(`/api/admin/events/${String(event._id)}`, {
method: "DELETE",
});
await refresh();
alert("Event deleted successfully!");
} catch (error) {
console.error("Failed to delete event:", error);
alert("Failed to delete event");
}
}
};
const handleImageError = (event) => {
const img = event.target;
const container = img?.parentElement;
if (container) {
container.style.display = "none";
}
};
const editEvent = (event) => {
navigateTo(`/admin/events/create?edit=${String(event._id)}`);
};
</script>