Lots of UI fixes
This commit is contained in:
parent
1f7a0f40c0
commit
e8e3b84276
24 changed files with 3652 additions and 1770 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue