Lots of UI fixes

This commit is contained in:
Jennie Robinson Faber 2025-10-08 19:02:24 +01:00
parent 1f7a0f40c0
commit e8e3b84276
24 changed files with 3652 additions and 1770 deletions

View file

@ -4,11 +4,13 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Admin Dashboard</h1>
<p class="text-gray-600">Manage Ghost Guild members, events, and community operations</p>
<p class="text-gray-600">
Manage Ghost Guild members, events, and community operations
</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Quick Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
@ -20,9 +22,21 @@
{{ stats.totalMembers || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"></path>
<div
class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"
></path>
</svg>
</div>
</div>
@ -36,9 +50,21 @@
{{ stats.activeEvents || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
<div
class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
></path>
</svg>
</div>
</div>
@ -52,9 +78,21 @@
${{ stats.monthlyRevenue || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"></path>
<div
class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
></path>
</svg>
</div>
</div>
@ -68,9 +106,21 @@
{{ stats.pendingSlackInvites || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
<div
class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-orange-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
></path>
</svg>
</div>
</div>
@ -81,16 +131,31 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="text-center">
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path>
<div
class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
></path>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Add New Member</h3>
<p class="text-gray-600 text-sm mb-4">
Add a new member to the Ghost Guild community
</p>
<button @click="navigateTo('/admin/members-working')" class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<button
@click="navigateTo('/admin/members-working')"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Manage Members
</button>
</div>
@ -98,16 +163,31 @@
<div class="bg-white rounded-lg shadow p-6">
<div class="text-center">
<div class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
<div
class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
></path>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Create Event</h3>
<p class="text-gray-600 text-sm mb-4">
Schedule a new community event or workshop
</p>
<button @click="navigateTo('/admin/events-working')" class="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
<button
@click="navigateTo('/admin/events-working')"
class="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
>
Manage Events
</button>
</div>
@ -115,16 +195,31 @@
<div class="bg-white rounded-lg shadow p-6">
<div class="text-center">
<div class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
<div
class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
></path>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">View Analytics</h3>
<p class="text-gray-600 text-sm mb-4">
Review member engagement and growth metrics
</p>
<button disabled class="w-full bg-gray-300 text-gray-500 py-2 px-4 rounded-lg cursor-not-allowed">
<button
disabled
class="w-full bg-gray-300 text-gray-500 py-2 px-4 rounded-lg cursor-not-allowed"
>
Coming Soon
</button>
</div>
@ -137,27 +232,41 @@
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Recent Members</h3>
<button @click="navigateTo('/admin/members-working')" class="text-sm text-blue-600 hover:text-blue-900">
<button
@click="navigateTo('/admin/members-working')"
class="text-sm text-primary-600 hover:text-primary-900"
>
View All
</button>
</div>
</div>
<div class="p-6">
<div v-if="pending" class="text-center py-4">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
></div>
</div>
<div v-else-if="recentMembers.length" class="space-y-3">
<div v-for="member in recentMembers" :key="member._id" class="flex items-center justify-between p-3 rounded-lg border border-gray-200">
<div
v-for="member in recentMembers"
:key="member._id"
class="flex items-center justify-between p-3 rounded-lg border border-gray-200"
>
<div>
<p class="font-medium">{{ member.name }}</p>
<p class="text-sm text-gray-600">{{ member.email }}</p>
</div>
<div class="text-right">
<span :class="getCircleBadgeClasses(member.circle)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1">
<span
:class="getCircleBadgeClasses(member.circle)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
>
{{ member.circle }}
</span>
<p class="text-xs text-gray-500">{{ formatDate(member.createdAt) }}</p>
<p class="text-xs text-gray-500">
{{ formatDate(member.createdAt) }}
</p>
</div>
</div>
</div>
@ -171,27 +280,43 @@
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Upcoming Events</h3>
<button @click="navigateTo('/admin/events-working')" class="text-sm text-blue-600 hover:text-blue-900">
<button
@click="navigateTo('/admin/events-working')"
class="text-sm text-primary-600 hover:text-primary-900"
>
View All
</button>
</div>
</div>
<div class="p-6">
<div v-if="pending" class="text-center py-4">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
></div>
</div>
<div v-else-if="upcomingEvents.length" class="space-y-3">
<div v-for="event in upcomingEvents" :key="event._id" class="flex items-center justify-between p-3 rounded-lg border border-gray-200">
<div
v-for="event in upcomingEvents"
:key="event._id"
class="flex items-center justify-between p-3 rounded-lg border border-gray-200"
>
<div>
<p class="font-medium">{{ event.title }}</p>
<p class="text-sm text-gray-600">{{ formatDateTime(event.startDate) }}</p>
<p class="text-sm text-gray-600">
{{ formatDateTime(event.startDate) }}
</p>
</div>
<div class="text-right">
<span :class="getEventTypeBadgeClasses(event.eventType)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1">
<span
:class="getEventTypeBadgeClasses(event.eventType)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
>
{{ event.eventType }}
</span>
<p class="text-xs text-gray-500">{{ event.location || 'Online' }}</p>
<p class="text-xs text-gray-500">
{{ event.location || "Online" }}
</p>
</div>
</div>
</div>
@ -207,44 +332,46 @@
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const { data: dashboardData, pending } = await useFetch('/api/admin/dashboard')
const { data: dashboardData, pending } = await useFetch("/api/admin/dashboard");
const stats = computed(() => dashboardData.value?.stats || {})
const recentMembers = computed(() => dashboardData.value?.recentMembers || [])
const upcomingEvents = computed(() => dashboardData.value?.upcomingEvents || [])
const stats = computed(() => dashboardData.value?.stats || {});
const recentMembers = computed(() => dashboardData.value?.recentMembers || []);
const upcomingEvents = computed(
() => dashboardData.value?.upcomingEvents || [],
);
const getCircleBadgeClasses = (circle) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
founder: 'bg-purple-100 text-purple-800',
practitioner: 'bg-green-100 text-green-800'
}
return classes[circle] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
founder: "bg-purple-100 text-purple-800",
practitioner: "bg-green-100 text-green-800",
};
return classes[circle] || "bg-gray-100 text-gray-800";
};
const getEventTypeBadgeClasses = (type) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
workshop: 'bg-green-100 text-green-800',
social: 'bg-purple-100 text-purple-800',
showcase: 'bg-orange-100 text-orange-800'
}
return classes[type] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
workshop: "bg-green-100 text-green-800",
social: "bg-purple-100 text-purple-800",
showcase: "bg-orange-100 text-orange-800",
};
return classes[type] || "bg-gray-100 text-gray-800";
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString()
}
return new Date(dateString).toLocaleDateString();
};
const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}
</script>
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
</script>

View file

@ -4,67 +4,119 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Event Management</h1>
<p class="text-gray-600">Create, manage, and monitor Ghost Guild events and workshops</p>
<p class="text-gray-600">
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">
<input v-model="searchQuery" placeholder="Search events..." class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<select v-model="typeFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<input
v-model="searchQuery"
placeholder="Search events..."
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<select
v-model="typeFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Types</option>
<option value="community">Community</option>
<option value="workshop">Workshop</option>
<option value="social">Social</option>
<option value="showcase">Showcase</option>
</select>
<select v-model="statusFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<select
v-model="statusFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Status</option>
<option value="upcoming">Upcoming</option>
<option value="ongoing">Ongoing</option>
<option value="past">Past</option>
</select>
</div>
<button @click="showCreateModal = true" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<button
@click="showCreateModal = true"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Create Event
</button>
</div>
<!-- Events Table -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div v-if="pending" class="p-8 text-center">
<div class="inline-flex items-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"
></div>
Loading events...
</div>
</div>
<div v-else-if="error" class="p-8 text-center text-red-600">
Error loading events: {{ error }}
</div>
<table v-else class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Start Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Registration</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Title
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Type
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Start Date
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Registration
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="event in filteredEvents" :key="event._id" class="hover:bg-gray-50">
<tr
v-for="event in filteredEvents"
:key="event._id"
class="hover:bg-gray-50"
>
<td class="px-6 py-4">
<div class="text-sm font-medium text-gray-900">{{ event.title }}</div>
<div class="text-sm text-gray-500">{{ event.description.substring(0, 100) }}...</div>
<div class="text-sm font-medium text-gray-900">
{{ event.title }}
</div>
<div class="text-sm text-gray-500">
{{ event.description.substring(0, 100) }}...
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="getEventTypeClasses(event.eventType)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
<span
:class="getEventTypeClasses(event.eventType)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ event.eventType }}
</span>
</td>
@ -72,111 +124,227 @@
{{ formatDateTime(event.startDate) }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="getStatusClasses(event)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
<span
:class="getStatusClasses(event)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ getEventStatus(event) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="event.registrationRequired ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
{{ event.registrationRequired ? 'Required' : 'Open' }}
<span
:class="
event.registrationRequired
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ event.registrationRequired ? "Required" : "Open" }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<div class="flex gap-2">
<button @click="editEvent(event)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
<button @click="duplicateEvent(event)" class="text-blue-600 hover:text-blue-900">Duplicate</button>
<button @click="deleteEvent(event)" class="text-red-600 hover:text-red-900">Delete</button>
<button
@click="editEvent(event)"
class="text-primary-600 hover:text-primary-900"
>
Edit
</button>
<button
@click="duplicateEvent(event)"
class="text-primary-600 hover:text-primary-900"
>
Duplicate
</button>
<button
@click="deleteEvent(event)"
class="text-red-600 hover:text-red-900"
>
Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div v-if="!pending && !error && filteredEvents.length === 0" class="p-8 text-center text-gray-500">
<div
v-if="!pending && !error && filteredEvents.length === 0"
class="p-8 text-center text-gray-500"
>
No events found matching your criteria
</div>
</div>
</div>
<!-- Create/Edit Event Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
<div
v-if="showCreateModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto"
>
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 my-8">
<div class="px-6 py-4 border-b">
<h3 class="text-lg font-semibold">
{{ editingEvent ? 'Edit Event' : 'Create New Event' }}
{{ editingEvent ? "Edit Event" : "Create New Event" }}
</h3>
</div>
<form @submit.prevent="saveEvent" class="p-6 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">Event Title</label>
<input v-model="eventForm.title" placeholder="Enter event title" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Event Title</label
>
<input
v-model="eventForm.title"
placeholder="Enter event title"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Event Type</label>
<select v-model="eventForm.eventType" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Event Type</label
>
<select
v-model="eventForm.eventType"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="community">Community Meetup</option>
<option value="workshop">Workshop</option>
<option value="social">Social Event</option>
<option value="showcase">Showcase</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Location</label>
<input v-model="eventForm.location" placeholder="Event location" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Location</label
>
<input
v-model="eventForm.location"
placeholder="Event location"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Start Date & Time</label>
<input v-model="eventForm.startDate" type="datetime-local" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Start Date & Time</label
>
<input
v-model="eventForm.startDate"
type="datetime-local"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">End Date & Time</label>
<input v-model="eventForm.endDate" type="datetime-local" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>End Date & Time</label
>
<input
v-model="eventForm.endDate"
type="datetime-local"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Max Attendees</label>
<input v-model="eventForm.maxAttendees" type="number" placeholder="Optional" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Max Attendees</label
>
<input
v-model="eventForm.maxAttendees"
type="number"
placeholder="Optional"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Registration Deadline</label>
<input v-model="eventForm.registrationDeadline" type="datetime-local" placeholder="Optional" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Registration Deadline</label
>
<input
v-model="eventForm.registrationDeadline"
type="datetime-local"
placeholder="Optional"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea v-model="eventForm.description" placeholder="Event description" required rows="3" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
<label class="block text-sm font-medium text-gray-700 mb-1"
>Description</label
>
<textarea
v-model="eventForm.description"
placeholder="Event description"
required
rows="3"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Additional Content</label>
<textarea v-model="eventForm.content" placeholder="Detailed event information, agenda, etc." rows="4" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
<label class="block text-sm font-medium text-gray-700 mb-1"
>Additional Content</label
>
<textarea
v-model="eventForm.content"
placeholder="Detailed event information, agenda, etc."
rows="4"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
></textarea>
</div>
<div class="flex items-center gap-6">
<label class="flex items-center">
<input v-model="eventForm.isOnline" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<input
v-model="eventForm.isOnline"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-700">Online Event</span>
</label>
<label class="flex items-center">
<input v-model="eventForm.registrationRequired" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span class="ml-2 text-sm text-gray-700">Registration Required</span>
<input
v-model="eventForm.registrationRequired"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-700"
>Registration Required</span
>
</label>
</div>
<div class="flex justify-end gap-3 pt-4">
<button type="button" @click="cancelEdit" class="px-4 py-2 text-gray-600 hover:text-gray-900">
<button
type="button"
@click="cancelEdit"
class="px-4 py-2 text-gray-600 hover:text-gray-900"
>
Cancel
</button>
<button type="submit" :disabled="creating" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
{{ creating ? 'Saving...' : (editingEvent ? 'Update Event' : 'Create Event') }}
<button
type="submit"
:disabled="creating"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{{
creating
? "Saving..."
: editingEvent
? "Update Event"
: "Create Event"
}}
</button>
</div>
</form>
@ -187,175 +355,185 @@
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const { data: events, pending, error, refresh } = await useFetch("/api/admin/events")
const {
data: events,
pending,
error,
refresh,
} = await useFetch("/api/admin/events");
const searchQuery = ref('')
const typeFilter = ref('')
const statusFilter = ref('')
const showCreateModal = ref(false)
const creating = ref(false)
const editingEvent = ref(null)
const searchQuery = ref("");
const typeFilter = ref("");
const statusFilter = ref("");
const showCreateModal = ref(false);
const creating = ref(false);
const editingEvent = ref(null);
const eventForm = reactive({
title: '',
description: '',
content: '',
startDate: '',
endDate: '',
eventType: 'community',
location: '',
title: "",
description: "",
content: "",
startDate: "",
endDate: "",
eventType: "community",
location: "",
isOnline: false,
maxAttendees: '',
maxAttendees: "",
registrationRequired: false,
registrationDeadline: ''
})
registrationDeadline: "",
});
const filteredEvents = computed(() => {
if (!events.value) return []
return events.value.filter(event => {
const matchesSearch = !searchQuery.value ||
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 || event.eventType === typeFilter.value
const eventStatus = getEventStatus(event)
const matchesStatus = !statusFilter.value || eventStatus.toLowerCase() === statusFilter.value
return matchesSearch && matchesType && matchesStatus
})
})
event.description.toLowerCase().includes(searchQuery.value.toLowerCase());
const matchesType =
!typeFilter.value || event.eventType === typeFilter.value;
const eventStatus = getEventStatus(event);
const matchesStatus =
!statusFilter.value || eventStatus.toLowerCase() === statusFilter.value;
return matchesSearch && matchesType && matchesStatus;
});
});
const getEventTypeClasses = (type) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
workshop: 'bg-green-100 text-green-800',
social: 'bg-purple-100 text-purple-800',
showcase: 'bg-orange-100 text-orange-800'
}
return classes[type] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
workshop: "bg-green-100 text-green-800",
social: "bg-purple-100 text-purple-800",
showcase: "bg-orange-100 text-orange-800",
};
return classes[type] || "bg-gray-100 text-gray-800";
};
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 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 status = getEventStatus(event);
const classes = {
'Upcoming': 'bg-blue-100 text-blue-800',
'Ongoing': 'bg-green-100 text-green-800',
'Past': 'bg-gray-100 text-gray-800'
}
return classes[status] || 'bg-gray-100 text-gray-800'
}
Upcoming: "bg-blue-100 text-blue-800",
Ongoing: "bg-green-100 text-green-800",
Past: "bg-gray-100 text-gray-800",
};
return classes[status] || "bg-gray-100 text-gray-800";
};
const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleString()
}
return new Date(dateString).toLocaleString();
};
const saveEvent = async () => {
creating.value = true
creating.value = true;
try {
if (editingEvent.value) {
await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
method: 'PUT',
body: eventForm
})
method: "PUT",
body: eventForm,
});
} else {
await $fetch('/api/admin/events', {
method: 'POST',
body: eventForm
})
await $fetch("/api/admin/events", {
method: "POST",
body: eventForm,
});
}
cancelEdit()
await refresh()
alert('Event saved successfully!')
cancelEdit();
await refresh();
alert("Event saved successfully!");
} catch (error) {
console.error('Failed to save event:', error)
alert('Failed to save event')
console.error("Failed to save event:", error);
alert("Failed to save event");
} finally {
creating.value = false
creating.value = false;
}
}
};
const editEvent = (event) => {
editingEvent.value = event
editingEvent.value = event;
Object.assign(eventForm, {
title: event.title,
description: event.description,
content: event.content || '',
content: event.content || "",
startDate: new Date(event.startDate).toISOString().slice(0, 16),
endDate: new Date(event.endDate).toISOString().slice(0, 16),
eventType: event.eventType,
location: event.location || '',
location: event.location || "",
isOnline: event.isOnline,
maxAttendees: event.maxAttendees || '',
maxAttendees: event.maxAttendees || "",
registrationRequired: event.registrationRequired,
registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : ''
})
showCreateModal.value = true
}
registrationDeadline: event.registrationDeadline
? new Date(event.registrationDeadline).toISOString().slice(0, 16)
: "",
});
showCreateModal.value = true;
};
const duplicateEvent = (event) => {
editingEvent.value = null
editingEvent.value = null;
Object.assign(eventForm, {
title: `${event.title} (Copy)`,
description: event.description,
content: event.content || '',
startDate: '',
endDate: '',
content: event.content || "",
startDate: "",
endDate: "",
eventType: event.eventType,
location: event.location || '',
location: event.location || "",
isOnline: event.isOnline,
maxAttendees: event.maxAttendees || '',
maxAttendees: event.maxAttendees || "",
registrationRequired: event.registrationRequired,
registrationDeadline: ''
})
showCreateModal.value = true
}
registrationDeadline: "",
});
showCreateModal.value = true;
};
const cancelEdit = () => {
showCreateModal.value = false
editingEvent.value = null
showCreateModal.value = false;
editingEvent.value = null;
Object.assign(eventForm, {
title: '',
description: '',
content: '',
startDate: '',
endDate: '',
eventType: 'community',
location: '',
title: "",
description: "",
content: "",
startDate: "",
endDate: "",
eventType: "community",
location: "",
isOnline: false,
maxAttendees: '',
maxAttendees: "",
registrationRequired: false,
registrationDeadline: ''
})
}
registrationDeadline: "",
});
};
const deleteEvent = async (event) => {
if (confirm(`Are you sure you want to delete "${event.title}"?`)) {
try {
await $fetch(`/api/admin/events/${event._id}`, {
method: 'DELETE'
})
await refresh()
alert('Event deleted successfully!')
method: "DELETE",
});
await refresh();
alert("Event deleted successfully!");
} catch (error) {
console.error('Failed to delete event:', error)
alert('Failed to delete event')
console.error("Failed to delete event:", error);
alert("Failed to delete event");
}
}
}
</script>
};
</script>

File diff suppressed because it is too large Load diff

View file

@ -4,102 +4,188 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Event Management</h1>
<p class="text-gray-600">Create, manage, and monitor Ghost Guild events and workshops</p>
<p class="text-gray-600">
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">
<input v-model="searchQuery" placeholder="Search events..." class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<select v-model="typeFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<input
v-model="searchQuery"
placeholder="Search events..."
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<select
v-model="typeFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Types</option>
<option value="community">Community</option>
<option value="workshop">Workshop</option>
<option value="social">Social</option>
<option value="showcase">Showcase</option>
</select>
<select v-model="statusFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<select
v-model="statusFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Status</option>
<option value="upcoming">Upcoming</option>
<option value="ongoing">Ongoing</option>
<option value="past">Past</option>
</select>
<select v-model="seriesFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<select
v-model="seriesFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Events</option>
<option value="series-only">Series Events Only</option>
<option value="standalone-only">Standalone Only</option>
</select>
</div>
<NuxtLink to="/admin/events/create" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 inline-flex items-center">
<NuxtLink
to="/admin/events/create"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-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-white rounded-lg shadow overflow-hidden">
<div v-if="pending" class="p-8 text-center">
<div class="inline-flex items-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"
></div>
Loading events...
</div>
</div>
<div v-else-if="error" class="p-8 text-center text-red-600">
Error loading events: {{ error }}
</div>
<table v-else class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
<th class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Registration</th>
<th class="px-6 py-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th
class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Title
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Type
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Date
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Status
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Registration
</th>
<th
class="px-6 py-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="event in filteredEvents" :key="event._id" class="hover:bg-gray-50">
<tr
v-for="event in filteredEvents"
:key="event._id"
class="hover:bg-gray-50"
>
<!-- 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-gray-100 rounded-lg overflow-hidden">
<img
<div
v-if="
event.featureImage?.url && !event.featureImage?.publicId
"
class="flex-shrink-0 w-12 h-12 bg-gray-100 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-gray-100 rounded-lg flex items-center justify-center">
<Icon name="heroicons:calendar-days" class="w-6 h-6 text-gray-400" />
<div
v-else
class="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center"
>
<Icon
name="heroicons:calendar-days"
class="w-6 h-6 text-gray-400"
/>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-gray-900 mb-1">{{ event.title }}</div>
<div class="text-sm text-gray-500 line-clamp-2">{{ event.description.substring(0, 100) }}...</div>
<div class="text-sm font-semibold text-gray-900 mb-1">
{{ event.title }}
</div>
<div class="text-sm text-gray-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-purple-100 text-purple-700 text-xs font-medium rounded-full">
<div class="w-4 h-4 bg-purple-200 text-purple-700 rounded-full flex items-center justify-center text-xs font-bold">
<div
class="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 text-xs font-medium rounded-full"
>
<div
class="w-4 h-4 bg-purple-200 text-purple-700 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-purple-600">
<Icon name="heroicons:lock-closed" class="w-3 h-3 mr-1" />
<div
v-if="event.membersOnly"
class="flex items-center text-xs text-purple-600"
>
<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-gray-400" />
<span class="text-xs text-gray-500">{{ event.targetCircles.join(', ') }}</span>
<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-gray-400"
/>
<span class="text-xs text-gray-500">{{
event.targetCircles.join(", ")
}}</span>
</div>
<div v-if="!event.isVisible" class="flex items-center text-xs text-gray-500">
<div
v-if="!event.isVisible"
class="flex items-center text-xs text-gray-500"
>
<Icon name="heroicons:eye-slash" class="w-3 h-3 mr-1" />
Hidden
</div>
@ -107,41 +193,60 @@
</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">
<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-gray-600">
<div class="space-y-1">
<div class="font-medium">{{ formatDate(event.startDate) }}</div>
<div class="text-xs text-gray-500">{{ formatTime(event.startDate) }}</div>
<div class="font-medium">
{{ formatDate(event.startDate) }}
</div>
<div class="text-xs text-gray-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">
<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-red-100 text-red-800">
<div
v-if="event.isCancelled"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800"
>
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-blue-100 text-blue-800">
<div
v-if="event.registrationRequired"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800"
>
Required
</div>
<div v-else class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">
<div
v-else
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800"
>
Optional
</div>
<div v-if="event.maxAttendees" class="text-xs text-gray-500">
@ -149,33 +254,33 @@
</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)}`"
<NuxtLink
:to="`/events/${event.slug || String(event._id)}`"
class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title="View Event"
>
<Icon name="heroicons:eye" class="w-4 h-4" />
</NuxtLink>
<button
<button
@click="editEvent(event)"
class="p-2 text-indigo-500 hover:text-indigo-700 hover:bg-indigo-50 rounded-full transition-colors"
class="p-2 text-primary-500 hover:text-primary-700 hover:bg-primary-50 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-blue-500 hover:text-blue-700 hover:bg-blue-50 rounded-full transition-colors"
<button
@click="duplicateEvent(event)"
class="p-2 text-primary-500 hover:text-primary-700 hover:bg-primary-50 rounded-full transition-colors"
title="Duplicate Event"
>
<Icon name="heroicons:document-duplicate" class="w-4 h-4" />
</button>
<button
@click="deleteEvent(event)"
<button
@click="deleteEvent(event)"
class="p-2 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-full transition-colors"
title="Delete Event"
>
@ -186,156 +291,166 @@
</tr>
</tbody>
</table>
<div v-if="!pending && !error && filteredEvents.length === 0" class="p-8 text-center text-gray-500">
<div
v-if="!pending && !error && filteredEvents.length === 0"
class="p-8 text-center text-gray-500"
>
No events found matching your criteria
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const { data: events, pending, error, refresh } = await useFetch("/api/admin/events")
const {
data: events,
pending,
error,
refresh,
} = await useFetch("/api/admin/events");
const searchQuery = ref('')
const typeFilter = ref('')
const statusFilter = ref('')
const seriesFilter = ref('')
const searchQuery = ref("");
const typeFilter = ref("");
const statusFilter = ref("");
const seriesFilter = ref("");
const filteredEvents = computed(() => {
if (!events.value) return []
return events.value.filter(event => {
const matchesSearch = !searchQuery.value ||
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 || event.eventType === typeFilter.value
const eventStatus = getEventStatus(event)
const matchesStatus = !statusFilter.value || eventStatus.toLowerCase() === statusFilter.value
const matchesSeries = !seriesFilter.value ||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
return matchesSearch && matchesType && matchesStatus && matchesSeries
})
})
event.description.toLowerCase().includes(searchQuery.value.toLowerCase());
const matchesType =
!typeFilter.value || event.eventType === typeFilter.value;
const eventStatus = getEventStatus(event);
const matchesStatus =
!statusFilter.value || eventStatus.toLowerCase() === statusFilter.value;
const matchesSeries =
!seriesFilter.value ||
(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-blue-100 text-blue-800',
workshop: 'bg-green-100 text-green-800',
social: 'bg-purple-100 text-purple-800',
showcase: 'bg-orange-100 text-orange-800'
}
return classes[type] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
workshop: "bg-green-100 text-green-800",
social: "bg-purple-100 text-purple-800",
showcase: "bg-orange-100 text-orange-800",
};
return classes[type] || "bg-gray-100 text-gray-800";
};
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 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 status = getEventStatus(event);
const classes = {
'Upcoming': 'bg-blue-100 text-blue-800',
'Ongoing': 'bg-green-100 text-green-800',
'Past': 'bg-gray-100 text-gray-800'
}
return classes[status] || 'bg-gray-100 text-gray-800'
}
Upcoming: "bg-blue-100 text-blue-800",
Ongoing: "bg-green-100 text-green-800",
Past: "bg-gray-100 text-gray-800",
};
return classes[status] || "bg-gray-100 text-gray-800";
};
const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleString()
}
return new Date(dateString).toLocaleString();
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
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
})
}
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}`
}
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 || '',
content: event.content || "",
featureImage: event.featureImage || null,
eventType: event.eventType,
location: event.location || '',
location: event.location || "",
isOnline: event.isOnline,
isVisible: true,
isCancelled: false,
cancellationMessage: '',
cancellationMessage: "",
targetCircles: event.targetCircles || [],
maxAttendees: event.maxAttendees || '',
registrationRequired: event.registrationRequired
}
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')
}
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!')
method: "DELETE",
});
await refresh();
alert("Event deleted successfully!");
} catch (error) {
console.error('Failed to delete event:', error)
alert('Failed to delete event')
console.error("Failed to delete event:", error);
alert("Failed to delete event");
}
}
}
};
const handleImageError = (event) => {
const img = event.target
const container = img?.parentElement
const img = event.target;
const container = img?.parentElement;
if (container) {
container.style.display = 'none'
container.style.display = "none";
}
}
};
const editEvent = (event) => {
navigateTo(`/admin/events/create?edit=${String(event._id)}`)
}
</script>
navigateTo(`/admin/events/create?edit=${String(event._id)}`);
};
</script>

View file

@ -4,11 +4,13 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Admin Dashboard</h1>
<p class="text-gray-600">Manage Ghost Guild members, events, and community operations</p>
<p class="text-gray-600">
Manage Ghost Guild members, events, and community operations
</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Quick Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
@ -20,9 +22,21 @@
{{ stats.totalMembers || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"></path>
<div
class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"
></path>
</svg>
</div>
</div>
@ -36,9 +50,21 @@
{{ stats.activeEvents || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
<div
class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
></path>
</svg>
</div>
</div>
@ -52,9 +78,21 @@
${{ stats.monthlyRevenue || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"></path>
<div
class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
></path>
</svg>
</div>
</div>
@ -68,9 +106,21 @@
{{ stats.pendingSlackInvites || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
<div
class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-orange-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
></path>
</svg>
</div>
</div>
@ -81,16 +131,31 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="text-center">
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path>
<div
class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
></path>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Add New Member</h3>
<p class="text-gray-600 text-sm mb-4">
Add a new member to the Ghost Guild community
</p>
<button @click="navigateTo('/admin/members')" class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<button
@click="navigateTo('/admin/members')"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Manage Members
</button>
</div>
@ -98,16 +163,31 @@
<div class="bg-white rounded-lg shadow p-6">
<div class="text-center">
<div class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
<div
class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
></path>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Create Event</h3>
<p class="text-gray-600 text-sm mb-4">
Schedule a new community event or workshop
</p>
<button @click="navigateTo('/admin/events')" class="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
<button
@click="navigateTo('/admin/events')"
class="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
>
Manage Events
</button>
</div>
@ -115,16 +195,31 @@
<div class="bg-white rounded-lg shadow p-6">
<div class="text-center">
<div class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
<div
class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
></path>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">View Analytics</h3>
<p class="text-gray-600 text-sm mb-4">
Review member engagement and growth metrics
</p>
<button disabled class="w-full bg-gray-300 text-gray-500 py-2 px-4 rounded-lg cursor-not-allowed">
<button
disabled
class="w-full bg-gray-300 text-gray-500 py-2 px-4 rounded-lg cursor-not-allowed"
>
Coming Soon
</button>
</div>
@ -137,27 +232,41 @@
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Recent Members</h3>
<button @click="navigateTo('/admin/members')" class="text-sm text-blue-600 hover:text-blue-900">
<button
@click="navigateTo('/admin/members')"
class="text-sm text-primary-600 hover:text-primary-900"
>
View All
</button>
</div>
</div>
<div class="p-6">
<div v-if="pending" class="text-center py-4">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
></div>
</div>
<div v-else-if="recentMembers.length" class="space-y-3">
<div v-for="member in recentMembers" :key="member._id" class="flex items-center justify-between p-3 rounded-lg border border-gray-200">
<div
v-for="member in recentMembers"
:key="member._id"
class="flex items-center justify-between p-3 rounded-lg border border-gray-200"
>
<div>
<p class="font-medium">{{ member.name }}</p>
<p class="text-sm text-gray-600">{{ member.email }}</p>
</div>
<div class="text-right">
<span :class="getCircleBadgeClasses(member.circle)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1">
<span
:class="getCircleBadgeClasses(member.circle)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
>
{{ member.circle }}
</span>
<p class="text-xs text-gray-500">{{ formatDate(member.createdAt) }}</p>
<p class="text-xs text-gray-500">
{{ formatDate(member.createdAt) }}
</p>
</div>
</div>
</div>
@ -171,27 +280,43 @@
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Upcoming Events</h3>
<button @click="navigateTo('/admin/events')" class="text-sm text-blue-600 hover:text-blue-900">
<button
@click="navigateTo('/admin/events')"
class="text-sm text-primary-600 hover:text-primary-900"
>
View All
</button>
</div>
</div>
<div class="p-6">
<div v-if="pending" class="text-center py-4">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
></div>
</div>
<div v-else-if="upcomingEvents.length" class="space-y-3">
<div v-for="event in upcomingEvents" :key="event._id" class="flex items-center justify-between p-3 rounded-lg border border-gray-200">
<div
v-for="event in upcomingEvents"
:key="event._id"
class="flex items-center justify-between p-3 rounded-lg border border-gray-200"
>
<div>
<p class="font-medium">{{ event.title }}</p>
<p class="text-sm text-gray-600">{{ formatDateTime(event.startDate) }}</p>
<p class="text-sm text-gray-600">
{{ formatDateTime(event.startDate) }}
</p>
</div>
<div class="text-right">
<span :class="getEventTypeBadgeClasses(event.eventType)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1">
<span
:class="getEventTypeBadgeClasses(event.eventType)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
>
{{ event.eventType }}
</span>
<p class="text-xs text-gray-500">{{ event.location || 'Online' }}</p>
<p class="text-xs text-gray-500">
{{ event.location || "Online" }}
</p>
</div>
</div>
</div>
@ -207,44 +332,46 @@
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const { data: dashboardData, pending } = await useFetch('/api/admin/dashboard')
const { data: dashboardData, pending } = await useFetch("/api/admin/dashboard");
const stats = computed(() => dashboardData.value?.stats || {})
const recentMembers = computed(() => dashboardData.value?.recentMembers || [])
const upcomingEvents = computed(() => dashboardData.value?.upcomingEvents || [])
const stats = computed(() => dashboardData.value?.stats || {});
const recentMembers = computed(() => dashboardData.value?.recentMembers || []);
const upcomingEvents = computed(
() => dashboardData.value?.upcomingEvents || [],
);
const getCircleBadgeClasses = (circle) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
founder: 'bg-purple-100 text-purple-800',
practitioner: 'bg-green-100 text-green-800'
}
return classes[circle] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
founder: "bg-purple-100 text-purple-800",
practitioner: "bg-green-100 text-green-800",
};
return classes[circle] || "bg-gray-100 text-gray-800";
};
const getEventTypeBadgeClasses = (type) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
workshop: 'bg-green-100 text-green-800',
social: 'bg-purple-100 text-purple-800',
showcase: 'bg-orange-100 text-orange-800'
}
return classes[type] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
workshop: "bg-green-100 text-green-800",
social: "bg-purple-100 text-purple-800",
showcase: "bg-orange-100 text-orange-800",
};
return classes[type] || "bg-gray-100 text-gray-800";
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString()
}
return new Date(dateString).toLocaleDateString();
};
const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}
</script>
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
</script>

View file

@ -4,74 +4,134 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Member Management</h1>
<p class="text-gray-600">Manage Ghost Guild members, their contributions, and access levels</p>
<p class="text-gray-600">
Manage Ghost Guild members, their contributions, and access levels
</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">
<input v-model="searchQuery" placeholder="Search members..." class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<select v-model="circleFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<input
v-model="searchQuery"
placeholder="Search members..."
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<select
v-model="circleFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Circles</option>
<option value="community">Community</option>
<option value="founder">Founder</option>
<option value="practitioner">Practitioner</option>
</select>
</div>
<button @click="showCreateModal = true" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<button
@click="showCreateModal = true"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Add Member
</button>
</div>
<!-- Members Table -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div v-if="pending" class="p-8 text-center">
<div class="inline-flex items-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"
></div>
Loading members...
</div>
</div>
<div v-else-if="error" class="p-8 text-center text-red-600">
Error loading members: {{ error }}
</div>
<table v-else class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Circle</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Contribution</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Slack Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Name
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Email
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Circle
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Contribution
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Slack Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Joined
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="member in filteredMembers" :key="member._id" class="hover:bg-gray-50">
<tr
v-for="member in filteredMembers"
:key="member._id"
class="hover:bg-gray-50"
>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ member.name }}</div>
<div class="text-sm font-medium text-gray-900">
{{ member.name }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-600">{{ member.email }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="getCircleClasses(member.circle)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
<span
:class="getCircleClasses(member.circle)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ member.circle }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800"
>
${{ member.contributionTier }}/month
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="member.slackInvited ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
{{ member.slackInvited ? 'Invited' : 'Pending' }}
<span
:class="
member.slackInvited
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ member.slackInvited ? "Invited" : "Pending" }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
@ -79,50 +139,91 @@
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<div class="flex gap-2">
<button @click="sendSlackInvite(member)" class="text-blue-600 hover:text-blue-900">Slack Invite</button>
<button @click="editMember(member)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
<button
@click="sendSlackInvite(member)"
class="text-primary-600 hover:text-primary-900"
>
Slack Invite
</button>
<button
@click="editMember(member)"
class="text-primary-600 hover:text-primary-900"
>
Edit
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div v-if="!pending && !error && filteredMembers.length === 0" class="p-8 text-center text-gray-500">
<div
v-if="!pending && !error && filteredMembers.length === 0"
class="p-8 text-center text-gray-500"
>
No members found matching your criteria
</div>
</div>
</div>
<!-- Create Member Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div
v-if="showCreateModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="px-6 py-4 border-b">
<h3 class="text-lg font-semibold">Add New Member</h3>
</div>
<form @submit.prevent="createMember" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input v-model="newMember.name" placeholder="Full name" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Name</label
>
<input
v-model="newMember.name"
placeholder="Full name"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input v-model="newMember.email" type="email" placeholder="email@example.com" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Email</label
>
<input
v-model="newMember.email"
type="email"
placeholder="email@example.com"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Circle</label>
<select v-model="newMember.circle" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Circle</label
>
<select
v-model="newMember.circle"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="community">Community</option>
<option value="founder">Founder</option>
<option value="practitioner">Practitioner</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Contribution Tier</label>
<select v-model="newMember.contributionTier" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Contribution Tier</label
>
<select
v-model="newMember.contributionTier"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="0">$0/month</option>
<option value="5">$5/month</option>
<option value="15">$15/month</option>
@ -130,13 +231,21 @@
<option value="50">$50/month</option>
</select>
</div>
<div class="flex justify-end gap-3 pt-4">
<button type="button" @click="showCreateModal = false" class="px-4 py-2 text-gray-600 hover:text-gray-900">
<button
type="button"
@click="showCreateModal = false"
class="px-4 py-2 text-gray-600 hover:text-gray-900"
>
Cancel
</button>
<button type="submit" :disabled="creating" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
{{ creating ? 'Creating...' : 'Create Member' }}
<button
type="submit"
:disabled="creating"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ creating ? "Creating..." : "Create Member" }}
</button>
</div>
</form>
@ -147,83 +256,90 @@
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const { data: members, pending, error, refresh } = await useFetch("/api/admin/members")
const {
data: members,
pending,
error,
refresh,
} = await useFetch("/api/admin/members");
const searchQuery = ref('')
const circleFilter = ref('')
const showCreateModal = ref(false)
const creating = ref(false)
const searchQuery = ref("");
const circleFilter = ref("");
const showCreateModal = ref(false);
const creating = ref(false);
const newMember = reactive({
name: '',
email: '',
circle: 'community',
contributionTier: '0'
})
name: "",
email: "",
circle: "community",
contributionTier: "0",
});
const filteredMembers = computed(() => {
if (!members.value) return []
return members.value.filter(member => {
const matchesSearch = !searchQuery.value ||
if (!members.value) return [];
return members.value.filter((member) => {
const matchesSearch =
!searchQuery.value ||
member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
member.email.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesCircle = !circleFilter.value || member.circle === circleFilter.value
return matchesSearch && matchesCircle
})
})
member.email.toLowerCase().includes(searchQuery.value.toLowerCase());
const matchesCircle =
!circleFilter.value || member.circle === circleFilter.value;
return matchesSearch && matchesCircle;
});
});
const getCircleClasses = (circle) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
founder: 'bg-purple-100 text-purple-800',
practitioner: 'bg-green-100 text-green-800'
}
return classes[circle] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
founder: "bg-purple-100 text-purple-800",
practitioner: "bg-green-100 text-green-800",
};
return classes[circle] || "bg-gray-100 text-gray-800";
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString()
}
return new Date(dateString).toLocaleDateString();
};
const createMember = async () => {
creating.value = true
creating.value = true;
try {
await $fetch('/api/admin/members', {
method: 'POST',
body: newMember
})
showCreateModal.value = false
await $fetch("/api/admin/members", {
method: "POST",
body: newMember,
});
showCreateModal.value = false;
Object.assign(newMember, {
name: '',
email: '',
circle: 'community',
contributionTier: '0'
})
await refresh()
alert('Member created successfully!')
name: "",
email: "",
circle: "community",
contributionTier: "0",
});
await refresh();
alert("Member created successfully!");
} catch (error) {
console.error('Failed to create member:', error)
alert('Failed to create member')
console.error("Failed to create member:", error);
alert("Failed to create member");
} finally {
creating.value = false
creating.value = false;
}
}
};
const sendSlackInvite = (member) => {
alert(`Slack invite functionality would send invite to ${member.email}`)
console.log('Send Slack invite to:', member.email)
}
alert(`Slack invite functionality would send invite to ${member.email}`);
console.log("Send Slack invite to:", member.email);
};
const editMember = (member) => {
alert(`Edit functionality would open editor for ${member.name}`)
console.log('Edit member:', member._id)
}
</script>
alert(`Edit functionality would open editor for ${member.name}`);
console.log("Edit member:", member._id);
};
</script>

View file

@ -4,74 +4,134 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Member Management</h1>
<p class="text-gray-600">Manage Ghost Guild members, their contributions, and access levels</p>
<p class="text-gray-600">
Manage Ghost Guild members, their contributions, and access levels
</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">
<input v-model="searchQuery" placeholder="Search members..." class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<select v-model="circleFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<input
v-model="searchQuery"
placeholder="Search members..."
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<select
v-model="circleFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Circles</option>
<option value="community">Community</option>
<option value="founder">Founder</option>
<option value="practitioner">Practitioner</option>
</select>
</div>
<button @click="showCreateModal = true" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<button
@click="showCreateModal = true"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Add Member
</button>
</div>
<!-- Members Table -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div v-if="pending" class="p-8 text-center">
<div class="inline-flex items-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"
></div>
Loading members...
</div>
</div>
<div v-else-if="error" class="p-8 text-center text-red-600">
Error loading members: {{ error }}
</div>
<table v-else class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Circle</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Contribution</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Slack Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Name
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Email
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Circle
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Contribution
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Slack Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Joined
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="member in filteredMembers" :key="member._id" class="hover:bg-gray-50">
<tr
v-for="member in filteredMembers"
:key="member._id"
class="hover:bg-gray-50"
>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ member.name }}</div>
<div class="text-sm font-medium text-gray-900">
{{ member.name }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-600">{{ member.email }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="getCircleClasses(member.circle)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
<span
:class="getCircleClasses(member.circle)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ member.circle }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800"
>
${{ member.contributionTier }}/month
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="member.slackInvited ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
{{ member.slackInvited ? 'Invited' : 'Pending' }}
<span
:class="
member.slackInvited
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ member.slackInvited ? "Invited" : "Pending" }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
@ -79,50 +139,91 @@
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<div class="flex gap-2">
<button @click="sendSlackInvite(member)" class="text-blue-600 hover:text-blue-900">Slack Invite</button>
<button @click="editMember(member)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
<button
@click="sendSlackInvite(member)"
class="text-primary-600 hover:text-primary-900"
>
Slack Invite
</button>
<button
@click="editMember(member)"
class="text-primary-600 hover:text-primary-900"
>
Edit
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div v-if="!pending && !error && filteredMembers.length === 0" class="p-8 text-center text-gray-500">
<div
v-if="!pending && !error && filteredMembers.length === 0"
class="p-8 text-center text-gray-500"
>
No members found matching your criteria
</div>
</div>
</div>
<!-- Create Member Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div
v-if="showCreateModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="px-6 py-4 border-b">
<h3 class="text-lg font-semibold">Add New Member</h3>
</div>
<form @submit.prevent="createMember" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input v-model="newMember.name" placeholder="Full name" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Name</label
>
<input
v-model="newMember.name"
placeholder="Full name"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input v-model="newMember.email" type="email" placeholder="email@example.com" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Email</label
>
<input
v-model="newMember.email"
type="email"
placeholder="email@example.com"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Circle</label>
<select v-model="newMember.circle" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Circle</label
>
<select
v-model="newMember.circle"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="community">Community</option>
<option value="founder">Founder</option>
<option value="practitioner">Practitioner</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Contribution Tier</label>
<select v-model="newMember.contributionTier" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Contribution Tier</label
>
<select
v-model="newMember.contributionTier"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="0">$0/month</option>
<option value="5">$5/month</option>
<option value="15">$15/month</option>
@ -130,13 +231,21 @@
<option value="50">$50/month</option>
</select>
</div>
<div class="flex justify-end gap-3 pt-4">
<button type="button" @click="showCreateModal = false" class="px-4 py-2 text-gray-600 hover:text-gray-900">
<button
type="button"
@click="showCreateModal = false"
class="px-4 py-2 text-gray-600 hover:text-gray-900"
>
Cancel
</button>
<button type="submit" :disabled="creating" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
{{ creating ? 'Creating...' : 'Create Member' }}
<button
type="submit"
:disabled="creating"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ creating ? "Creating..." : "Create Member" }}
</button>
</div>
</form>
@ -147,83 +256,90 @@
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const { data: members, pending, error, refresh } = await useFetch("/api/admin/members")
const {
data: members,
pending,
error,
refresh,
} = await useFetch("/api/admin/members");
const searchQuery = ref('')
const circleFilter = ref('')
const showCreateModal = ref(false)
const creating = ref(false)
const searchQuery = ref("");
const circleFilter = ref("");
const showCreateModal = ref(false);
const creating = ref(false);
const newMember = reactive({
name: '',
email: '',
circle: 'community',
contributionTier: '0'
})
name: "",
email: "",
circle: "community",
contributionTier: "0",
});
const filteredMembers = computed(() => {
if (!members.value) return []
return members.value.filter(member => {
const matchesSearch = !searchQuery.value ||
if (!members.value) return [];
return members.value.filter((member) => {
const matchesSearch =
!searchQuery.value ||
member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
member.email.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesCircle = !circleFilter.value || member.circle === circleFilter.value
return matchesSearch && matchesCircle
})
})
member.email.toLowerCase().includes(searchQuery.value.toLowerCase());
const matchesCircle =
!circleFilter.value || member.circle === circleFilter.value;
return matchesSearch && matchesCircle;
});
});
const getCircleClasses = (circle) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
founder: 'bg-purple-100 text-purple-800',
practitioner: 'bg-green-100 text-green-800'
}
return classes[circle] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
founder: "bg-purple-100 text-purple-800",
practitioner: "bg-green-100 text-green-800",
};
return classes[circle] || "bg-gray-100 text-gray-800";
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString()
}
return new Date(dateString).toLocaleDateString();
};
const createMember = async () => {
creating.value = true
creating.value = true;
try {
await $fetch('/api/admin/members', {
method: 'POST',
body: newMember
})
showCreateModal.value = false
await $fetch("/api/admin/members", {
method: "POST",
body: newMember,
});
showCreateModal.value = false;
Object.assign(newMember, {
name: '',
email: '',
circle: 'community',
contributionTier: '0'
})
await refresh()
alert('Member created successfully!')
name: "",
email: "",
circle: "community",
contributionTier: "0",
});
await refresh();
alert("Member created successfully!");
} catch (error) {
console.error('Failed to create member:', error)
alert('Failed to create member')
console.error("Failed to create member:", error);
alert("Failed to create member");
} finally {
creating.value = false
creating.value = false;
}
}
};
const sendSlackInvite = (member) => {
alert(`Slack invite functionality would send invite to ${member.email}`)
console.log('Send Slack invite to:', member.email)
}
alert(`Slack invite functionality would send invite to ${member.email}`);
console.log("Send Slack invite to:", member.email);
};
const editMember = (member) => {
alert(`Edit functionality would open editor for ${member.name}`)
console.log('Edit member:', member._id)
}
</script>
alert(`Edit functionality would open editor for ${member.name}`);
console.log("Edit member:", member._id);
};
</script>

View file

@ -4,11 +4,13 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Series Management</h1>
<p class="text-gray-600">Manage event series and their relationships</p>
<p class="text-gray-600">
Manage event series and their relationships
</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Series Overview -->
<div class="mb-8">
@ -16,34 +18,51 @@
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-purple-100 rounded-full">
<Icon name="heroicons:squares-2x2" class="w-6 h-6 text-purple-600" />
<Icon
name="heroicons:squares-2x2"
class="w-6 h-6 text-purple-600"
/>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Active Series</p>
<p class="text-2xl font-semibold text-gray-900">{{ activeSeries.length }}</p>
<p class="text-2xl font-semibold text-gray-900">
{{ activeSeries.length }}
</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-blue-100 rounded-full">
<Icon name="heroicons:calendar-days" class="w-6 h-6 text-blue-600" />
<Icon
name="heroicons:calendar-days"
class="w-6 h-6 text-blue-600"
/>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Total Series Events</p>
<p class="text-2xl font-semibold text-gray-900">{{ totalSeriesEvents }}</p>
<p class="text-2xl font-semibold text-gray-900">
{{ totalSeriesEvents }}
</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-green-100 rounded-full">
<Icon name="heroicons:chart-bar" class="w-6 h-6 text-green-600" />
<Icon
name="heroicons:chart-bar"
class="w-6 h-6 text-green-600"
/>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Avg Events/Series</p>
<p class="text-2xl font-semibold text-gray-900">
{{ activeSeries.length > 0 ? Math.round(totalSeriesEvents / activeSeries.length) : 0 }}
{{
activeSeries.length > 0
? Math.round(totalSeriesEvents / activeSeries.length)
: 0
}}
</p>
</div>
</div>
@ -54,13 +73,13 @@
<!-- Actions Bar -->
<div class="mb-6 flex justify-between items-center">
<div class="flex gap-4 items-center">
<input
v-model="searchQuery"
placeholder="Search series..."
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
<input
v-model="searchQuery"
placeholder="Search series..."
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<select
v-model="statusFilter"
<select
v-model="statusFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">All Status</option>
@ -70,15 +89,15 @@
</select>
</div>
<div class="flex gap-3">
<button
<button
@click="showBulkModal = true"
class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 inline-flex items-center"
>
<Icon name="heroicons:cog-6-tooth" class="w-4 h-4 mr-2" />
Bulk Operations
</button>
<NuxtLink
to="/admin/series/create"
<NuxtLink
to="/admin/series/create"
class="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 inline-flex items-center"
>
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
@ -89,13 +108,15 @@
<!-- Series List -->
<div v-if="pending" class="text-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-4"></div>
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-4"
></div>
<p class="text-gray-600">Loading series...</p>
</div>
<div v-else-if="filteredSeries.length > 0" class="space-y-6">
<div
v-for="series in filteredSeries"
<div
v-for="series in filteredSeries"
:key="series.id"
class="bg-white rounded-lg shadow overflow-hidden"
>
@ -103,24 +124,32 @@
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div :class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type)
]">
<div
:class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type),
]"
>
{{ formatSeriesType(series.type) }}
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">{{ series.title }}</h3>
<h3 class="text-lg font-semibold text-gray-900">
{{ series.title }}
</h3>
<p class="text-sm text-gray-600">{{ series.description }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<span :class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active' ? 'bg-green-100 text-green-700' :
series.status === 'upcoming' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-700'
]">
<span
:class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active'
? 'bg-green-100 text-green-700'
: series.status === 'upcoming'
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700',
]"
>
{{ series.status }}
</span>
<span class="text-sm text-gray-500">
@ -132,44 +161,52 @@
<!-- Series Events -->
<div class="divide-y divide-gray-200">
<div
v-for="event in series.events"
<div
v-for="event in series.events"
:key="event.id"
class="px-6 py-4 hover:bg-gray-50"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-8 h-8 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center text-sm font-semibold">
{{ event.series?.position || '?' }}
<div
class="w-8 h-8 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center text-sm font-semibold"
>
{{ event.series?.position || "?" }}
</div>
<div>
<h4 class="text-sm font-medium text-gray-900">{{ event.title }}</h4>
<p class="text-xs text-gray-500">{{ formatEventDate(event.startDate) }}</p>
<h4 class="text-sm font-medium text-gray-900">
{{ event.title }}
</h4>
<p class="text-xs text-gray-500">
{{ formatEventDate(event.startDate) }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span :class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
getEventStatusClass(event)
]">
<span
:class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
getEventStatusClass(event),
]"
>
{{ getEventStatus(event) }}
</span>
<div class="flex gap-1">
<NuxtLink
<NuxtLink
:to="`/events/${event.slug || event.id}`"
class="p-1 text-gray-400 hover:text-gray-600 rounded"
title="View Event"
>
<Icon name="heroicons:eye" class="w-4 h-4" />
</NuxtLink>
<button
<button
@click="editEvent(event)"
class="p-1 text-gray-400 hover:text-purple-600 rounded"
class="p-1 text-gray-400 hover:text-primary rounded"
title="Edit Event"
>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
</button>
<button
<button
@click="removeFromSeries(event)"
class="p-1 text-gray-400 hover:text-red-600 rounded"
title="Remove from Series"
@ -189,19 +226,25 @@
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<div class="flex gap-2">
<button
<button
@click="editSeries(series)"
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Edit Series
</button>
<button
@click="addEventToSeries(series)"
class="text-sm text-purple-600 hover:text-purple-700 font-medium"
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Add Event
</button>
<button
<button
@click="duplicateSeries(series)"
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Duplicate Series
</button>
<button
<button
@click="deleteSeries(series)"
class="text-sm text-red-600 hover:text-red-700 font-medium"
>
@ -214,72 +257,201 @@
</div>
<div v-else class="text-center py-12 bg-white rounded-lg shadow">
<Icon name="heroicons:squares-2x2" class="w-12 h-12 text-gray-400 mx-auto mb-3" />
<Icon
name="heroicons:squares-2x2"
class="w-12 h-12 text-gray-400 mx-auto mb-3"
/>
<p class="text-gray-600">No event series found</p>
<p class="text-sm text-gray-500 mt-2">Create events and group them into series to get started</p>
<p class="text-sm text-gray-500 mt-2">
Create events and group them into series to get started
</p>
</div>
</div>
<!-- Bulk Operations Modal -->
<div v-if="showBulkModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<!-- Edit Series Modal -->
<div
v-if="editingSeriesId"
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"
>
<div
class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
>
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Bulk Series Operations</h3>
<button @click="showBulkModal = false" class="text-gray-400 hover:text-gray-600">
<h3 class="text-lg font-semibold text-gray-900">Edit Series</h3>
<button
@click="cancelEditSeries"
class="text-gray-400 hover:text-gray-600"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 space-y-6">
<div>
<h4 class="text-sm font-medium text-gray-900 mb-3">Series Management Tools</h4>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Series Title</label
>
<input
v-model="editingSeriesData.title"
type="text"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="e.g., Co-op Game Dev Workshop Series"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Description</label
>
<textarea
v-model="editingSeriesData.description"
rows="3"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Brief description of this series"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Series Type</label
>
<select
v-model="editingSeriesData.type"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="workshop_series">Workshop Series</option>
<option value="recurring_meetup">Recurring Meetup</option>
<option value="multi_day">Multi-Day Event</option>
<option value="course">Course</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Total Events (optional)</label
>
<input
v-model.number="editingSeriesData.totalEvents"
type="number"
min="1"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Leave empty for ongoing series"
/>
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
<button
@click="cancelEditSeries"
class="px-4 py-2 text-gray-600 hover:text-gray-700"
>
Cancel
</button>
<button
@click="saveSeriesEdit"
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Save Changes
</button>
</div>
</div>
</div>
<!-- Bulk Operations Modal -->
<div
v-if="showBulkModal"
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"
>
<div
class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
>
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">
Bulk Series Operations
</h3>
<button
@click="showBulkModal = false"
class="text-gray-400 hover:text-gray-600"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 space-y-6">
<div>
<h4 class="text-sm font-medium text-gray-900 mb-3">
Series Management Tools
</h4>
<div class="space-y-3">
<button
<button
@click="reorderAllSeries"
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<Icon name="heroicons:arrows-up-down" class="w-5 h-5 text-gray-400 mr-3" />
<Icon
name="heroicons:arrows-up-down"
class="w-5 h-5 text-gray-400 mr-3"
/>
<div>
<p class="text-sm font-medium text-gray-900">Auto-Reorder Series</p>
<p class="text-xs text-gray-500">Fix position numbers based on event dates</p>
<p class="text-sm font-medium text-gray-900">
Auto-Reorder Series
</p>
<p class="text-xs text-gray-500">
Fix position numbers based on event dates
</p>
</div>
</div>
</button>
<button
<button
@click="validateAllSeries"
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<Icon name="heroicons:check-circle" class="w-5 h-5 text-gray-400 mr-3" />
<Icon
name="heroicons:check-circle"
class="w-5 h-5 text-gray-400 mr-3"
/>
<div>
<p class="text-sm font-medium text-gray-900">Validate Series Data</p>
<p class="text-xs text-gray-500">Check for consistency issues</p>
<p class="text-sm font-medium text-gray-900">
Validate Series Data
</p>
<p class="text-xs text-gray-500">
Check for consistency issues
</p>
</div>
</div>
</button>
<button
<button
@click="exportSeriesData"
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<Icon name="heroicons:document-arrow-down" class="w-5 h-5 text-gray-400 mr-3" />
<Icon
name="heroicons:document-arrow-down"
class="w-5 h-5 text-gray-400 mr-3"
/>
<div>
<p class="text-sm font-medium text-gray-900">Export Series Data</p>
<p class="text-xs text-gray-500">Download series information as JSON</p>
<p class="text-sm font-medium text-gray-900">
Export Series Data
</p>
<p class="text-xs text-gray-500">
Download series information as JSON
</p>
</div>
</div>
</button>
</div>
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-end">
<button
<button
@click="showBulkModal = false"
class="px-4 py-2 text-gray-600 hover:text-gray-700"
>
@ -293,136 +465,152 @@
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const showBulkModal = ref(false)
const searchQuery = ref('')
const statusFilter = ref('')
const showBulkModal = ref(false);
const searchQuery = ref("");
const statusFilter = ref("");
const editingSeriesId = ref(null);
const editingSeriesData = ref({
title: "",
description: "",
type: "workshop_series",
totalEvents: null,
});
// Fetch series data
const { data: seriesData, pending, refresh } = await useFetch('/api/admin/series')
const {
data: seriesData,
pending,
refresh,
} = await useFetch("/api/admin/series");
// Computed properties
const activeSeries = computed(() => {
if (!seriesData.value) return []
return seriesData.value
})
if (!seriesData.value) return [];
return seriesData.value;
});
const totalSeriesEvents = computed(() => {
return activeSeries.value.reduce((sum, series) => sum + (series.eventCount || 0), 0)
})
return activeSeries.value.reduce(
(sum, series) => sum + (series.eventCount || 0),
0,
);
});
const filteredSeries = computed(() => {
if (!activeSeries.value) return []
return activeSeries.value.filter(series => {
const matchesSearch = !searchQuery.value ||
if (!activeSeries.value) return [];
return activeSeries.value.filter((series) => {
const matchesSearch =
!searchQuery.value ||
series.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
series.description.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesStatus = !statusFilter.value || series.status === statusFilter.value
return matchesSearch && matchesStatus
})
})
series.description
.toLowerCase()
.includes(searchQuery.value.toLowerCase());
const matchesStatus =
!statusFilter.value || series.status === statusFilter.value;
return matchesSearch && matchesStatus;
});
});
// 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
}
workshop_series: "Workshop Series",
recurring_meetup: "Recurring Meetup",
multi_day: "Multi-Day Event",
course: "Course",
};
return types[type] || type;
};
const getSeriesTypeBadgeClass = (type) => {
const classes = {
'workshop_series': 'bg-emerald-100 text-emerald-700',
'recurring_meetup': 'bg-blue-100 text-blue-700',
'multi_day': 'bg-purple-100 text-purple-700',
'course': 'bg-amber-100 text-amber-700',
'tournament': 'bg-red-100 text-red-700'
}
return classes[type] || 'bg-gray-100 text-gray-700'
}
workshop_series: "bg-emerald-100 text-emerald-700",
recurring_meetup: "bg-blue-100 text-blue-700",
multi_day: "bg-purple-100 text-purple-700",
course: "bg-amber-100 text-amber-700",
};
return classes[type] || "bg-gray-100 text-gray-700";
};
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const formatDateRange = (startDate, endDate) => {
if (!startDate || !endDate) return 'No dates'
const start = new Date(startDate)
const end = new Date(endDate)
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric'
})
return `${formatter.format(start)} - ${formatter.format(end)}`
}
if (!startDate || !endDate) return "No dates";
const start = new Date(startDate);
const end = new Date(endDate);
const formatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
return `${formatter.format(start)} - ${formatter.format(end)}`;
};
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 'Completed'
}
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 "Completed";
};
const getEventStatusClass = (event) => {
const status = getEventStatus(event)
const status = getEventStatus(event);
const classes = {
'Upcoming': 'bg-blue-100 text-blue-700',
'Ongoing': 'bg-green-100 text-green-700',
'Completed': 'bg-gray-100 text-gray-700'
}
return classes[status] || 'bg-gray-100 text-gray-700'
}
Upcoming: "bg-blue-100 text-blue-700",
Ongoing: "bg-green-100 text-green-700",
Completed: "bg-gray-100 text-gray-700",
};
return classes[status] || "bg-gray-100 text-gray-700";
};
// Actions
const editEvent = (event) => {
navigateTo(`/admin/events/create?edit=${event.id}`)
}
navigateTo(`/admin/events/create?edit=${event.id}`);
};
const removeFromSeries = async (event) => {
if (!confirm(`Remove "${event.title}" from its series?`)) return
if (!confirm(`Remove "${event.title}" from its series?`)) return;
try {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
method: "PUT",
body: {
...event,
series: {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
id: "",
title: "",
description: "",
type: "workshop_series",
position: 1,
totalEvents: null
}
}
})
await refresh()
totalEvents: null,
},
},
});
await refresh();
} catch (error) {
console.error('Failed to remove event from series:', error)
alert('Failed to remove event from series')
console.error("Failed to remove event from series:", error);
alert("Failed to remove event from series");
}
}
};
const addEventToSeries = (series) => {
// Navigate to create page with series pre-filled
@ -434,71 +622,121 @@ const addEventToSeries = (series) => {
description: series.description,
type: series.type,
position: (series.eventCount || 0) + 1,
totalEvents: series.totalEvents
}
}
sessionStorage.setItem('seriesEventData', JSON.stringify(seriesData))
navigateTo('/admin/events/create?series=true')
}
totalEvents: series.totalEvents,
},
};
sessionStorage.setItem("seriesEventData", JSON.stringify(seriesData));
navigateTo("/admin/events/create?series=true");
};
const duplicateSeries = (series) => {
// TODO: Implement series duplication
alert('Series duplication coming soon!')
}
alert("Series duplication coming soon!");
};
const editSeries = (series) => {
editingSeriesId.value = series.id;
editingSeriesData.value = {
title: series.title,
description: series.description,
type: series.type,
totalEvents: series.totalEvents,
};
};
const cancelEditSeries = () => {
editingSeriesId.value = null;
editingSeriesData.value = {
title: "",
description: "",
type: "workshop_series",
totalEvents: null,
};
};
const saveSeriesEdit = async () => {
if (!editingSeriesData.value.title) {
alert("Series title is required");
return;
}
try {
// Update the series record
await $fetch("/api/admin/series", {
method: "PUT",
body: {
id: editingSeriesId.value,
...editingSeriesData.value,
},
});
await refresh();
cancelEditSeries();
alert("Series updated successfully");
} catch (error) {
console.error("Failed to update series:", error);
alert("Failed to update series");
}
};
const deleteSeries = async (series) => {
if (!confirm(`Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`)) return
if (
!confirm(
`Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`,
)
)
return;
try {
// Update all events to remove series relationship
for (const event of series.events) {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
method: "PUT",
body: {
...event,
series: {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
id: "",
title: "",
description: "",
type: "workshop_series",
position: 1,
totalEvents: null
}
}
})
totalEvents: null,
},
},
});
}
await refresh()
alert('Series deleted and events converted to standalone events')
await refresh();
alert("Series deleted and events converted to standalone events");
} catch (error) {
console.error('Failed to delete series:', error)
alert('Failed to delete series')
console.error("Failed to delete series:", error);
alert("Failed to delete series");
}
}
};
// Bulk operations
const reorderAllSeries = async () => {
// TODO: Implement auto-reordering
alert('Auto-reorder feature coming soon!')
}
alert("Auto-reorder feature coming soon!");
};
const validateAllSeries = async () => {
// TODO: Implement validation
alert('Validation feature coming soon!')
}
alert("Validation feature coming soon!");
};
const exportSeriesData = () => {
const dataStr = JSON.stringify(activeSeries.value, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = 'event-series-data.json'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
</script>
const dataStr = JSON.stringify(activeSeries.value, null, 2);
const dataBlob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement("a");
link.href = url;
link.download = "event-series-data.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
</script>