Enhance application structure: Add runtime configuration for environment variables, integrate new dependencies for Cloudinary and UI components, and refactor member management features including improved forms and member dashboard. Update styles and layout for better user experience.
This commit is contained in:
parent
6e7e27ac4e
commit
e4a0a9ab0f
61 changed files with 7902 additions and 950 deletions
336
app/pages/admin/events/index.vue
Normal file
336
app/pages/admin/events/index.vue
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="bg-white border-b">
|
||||
<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>
|
||||
</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">
|
||||
<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">
|
||||
<option value="">All Status</option>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="ongoing">Ongoing</option>
|
||||
<option value="past">Past</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">
|
||||
<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>
|
||||
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>
|
||||
</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">
|
||||
<!-- 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
|
||||
: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>
|
||||
<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="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" />
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Type Column -->
|
||||
<td class="px-4 py-6 whitespace-nowrap">
|
||||
<span :class="getEventTypeClasses(event.eventType)" class="inline-flex px-3 py-1 text-xs font-semibold rounded-full capitalize">
|
||||
{{ event.eventType }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Date Column -->
|
||||
<td class="px-4 py-6 whitespace-nowrap text-sm text-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>
|
||||
</td>
|
||||
|
||||
<!-- Status Column -->
|
||||
<td class="px-4 py-6 whitespace-nowrap">
|
||||
<div class="space-y-2">
|
||||
<span :class="getStatusClasses(event)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
|
||||
{{ getEventStatus(event) }}
|
||||
</span>
|
||||
<div v-if="event.isCancelled" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-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">
|
||||
Required
|
||||
</div>
|
||||
<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">
|
||||
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<td class="px-6 py-6 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<NuxtLink
|
||||
:to="`/events/${event.slug || String(event._id)}`"
|
||||
class="p-2 text-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
|
||||
@click="editEvent(event)"
|
||||
class="p-2 text-indigo-500 hover:text-indigo-700 hover:bg-indigo-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"
|
||||
title="Duplicate Event"
|
||||
>
|
||||
<Icon name="heroicons:document-duplicate" class="w-4 h-4" />
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="!pending && !error && filteredEvents.length === 0" class="p-8 text-center text-gray-500">
|
||||
No events found matching your criteria
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const { data: events, pending, error, refresh } = await useFetch("/api/admin/events")
|
||||
|
||||
const searchQuery = ref('')
|
||||
const typeFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
if (!events.value) return []
|
||||
|
||||
return events.value.filter(event => {
|
||||
const matchesSearch = !searchQuery.value ||
|
||||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
|
||||
const matchesType = !typeFilter.value || 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'
|
||||
}
|
||||
|
||||
const getEventStatus = (event) => {
|
||||
const now = new Date()
|
||||
const startDate = new Date(event.startDate)
|
||||
const endDate = new Date(event.endDate)
|
||||
|
||||
if (now < startDate) return 'Upcoming'
|
||||
if (now >= startDate && now <= endDate) return 'Ongoing'
|
||||
return 'Past'
|
||||
}
|
||||
|
||||
const getStatusClasses = (event) => {
|
||||
const status = getEventStatus(event)
|
||||
const classes = {
|
||||
'Upcoming': 'bg-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()
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
}
|
||||
|
||||
// Get optimized Cloudinary image URL
|
||||
const getOptimizedImageUrl = (publicId, transformations) => {
|
||||
if (!publicId) return ''
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`
|
||||
}
|
||||
|
||||
|
||||
|
||||
const duplicateEvent = (event) => {
|
||||
// Navigate to create page with duplicate query parameter
|
||||
const duplicateData = {
|
||||
title: `${event.title} (Copy)`,
|
||||
description: event.description,
|
||||
content: event.content || '',
|
||||
featureImage: event.featureImage || null,
|
||||
eventType: event.eventType,
|
||||
location: event.location || '',
|
||||
isOnline: event.isOnline,
|
||||
isVisible: true,
|
||||
isCancelled: false,
|
||||
cancellationMessage: '',
|
||||
targetCircles: event.targetCircles || [],
|
||||
maxAttendees: event.maxAttendees || '',
|
||||
registrationRequired: event.registrationRequired
|
||||
}
|
||||
|
||||
// Store duplicate data in session storage for the create page to use
|
||||
sessionStorage.setItem('duplicateEventData', JSON.stringify(duplicateData))
|
||||
navigateTo('/admin/events/create?duplicate=true')
|
||||
}
|
||||
|
||||
const deleteEvent = async (event) => {
|
||||
if (confirm(`Are you sure you want to delete "${event.title}"?`)) {
|
||||
try {
|
||||
await $fetch(`/api/admin/events/${String(event._id)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await refresh()
|
||||
alert('Event deleted successfully!')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete event:', error)
|
||||
alert('Failed to delete event')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = (event) => {
|
||||
const img = event.target
|
||||
const container = img?.parentElement
|
||||
if (container) {
|
||||
container.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
const editEvent = async (event) => {
|
||||
console.log('🔍 Edit button clicked for event:', event)
|
||||
console.log('🔍 Event ID:', event._id)
|
||||
console.log('🔍 Event ID as string:', String(event._id))
|
||||
const editUrl = `/admin/events/create?edit=${String(event._id)}`
|
||||
console.log('🔍 Generated URL:', editUrl)
|
||||
|
||||
try {
|
||||
console.log('🔍 Waiting 2 seconds before navigation...')
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
console.log('🔍 Now navigating with window.location...')
|
||||
window.location.href = editUrl
|
||||
} catch (error) {
|
||||
console.error('❌ Navigation failed:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue