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'.
510 lines
18 KiB
Vue
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>
|