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
594
app/pages/admin/events/create.vue
Normal file
594
app/pages/admin/events/create.vue
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
<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">
|
||||
<div class="flex items-center gap-4 mb-2">
|
||||
<NuxtLink to="/admin/events" class="text-gray-500 hover:text-gray-700">
|
||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||
</NuxtLink>
|
||||
<h1 class="text-2xl font-bold text-gray-900">
|
||||
{{ editingEvent ? 'Edit Event' : 'Create New Event' }}
|
||||
</h1>
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
<strong>DEBUG:</strong> Create page loaded successfully!
|
||||
<div class="text-sm mt-1">
|
||||
Route query: {{ JSON.stringify($route.query) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-600">Fill out the form below to create or update an event</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Error Summary -->
|
||||
<div v-if="formErrors.length > 0" class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div class="flex">
|
||||
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 text-red-500 mr-3 mt-0.5" />
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-red-800 mb-2">Please fix the following errors:</h3>
|
||||
<ul class="text-sm text-red-700 space-y-1">
|
||||
<li v-for="error in formErrors" :key="error">• {{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="showSuccessMessage" class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex">
|
||||
<Icon name="heroicons:check-circle" class="w-5 h-5 text-green-500 mr-3 mt-0.5" />
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-green-800">
|
||||
{{ editingEvent ? 'Event updated successfully!' : 'Event created successfully!' }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveEvent" class="bg-white rounded-lg shadow p-6">
|
||||
<!-- Basic Information -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Basic Information</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Event Title <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="eventForm.title"
|
||||
type="text"
|
||||
placeholder="Enter a clear, descriptive 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"
|
||||
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.title }"
|
||||
/>
|
||||
<p v-if="fieldErrors.title" class="mt-1 text-sm text-red-600">{{ fieldErrors.title }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Feature Image</label>
|
||||
<ImageUpload v-model="eventForm.featureImage" />
|
||||
<p class="mt-1 text-sm text-gray-500">Upload a high-quality image (1200x630px recommended) to represent your event</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Event Description <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="eventForm.description"
|
||||
placeholder="Provide a clear description of what attendees can expect from this event"
|
||||
required
|
||||
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"
|
||||
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.description }"
|
||||
></textarea>
|
||||
<p v-if="fieldErrors.description" class="mt-1 text-sm text-red-600">{{ fieldErrors.description }}</p>
|
||||
<p class="mt-1 text-sm text-gray-500">This will be displayed on the event listing and detail pages</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Additional Content</label>
|
||||
<textarea
|
||||
v-model="eventForm.content"
|
||||
placeholder="Add detailed information, agenda, requirements, or other important details"
|
||||
rows="6"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
></textarea>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: Provide additional context, agenda items, or detailed requirements</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Details -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Event Details</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Event Type <span class="text-red-500">*</span>
|
||||
</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>
|
||||
<p class="mt-1 text-sm text-gray-500">Choose the category that best describes your event</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Location <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="eventForm.location"
|
||||
type="text"
|
||||
placeholder="e.g., https://zoom.us/j/123... or #channel-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"
|
||||
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.location }"
|
||||
/>
|
||||
<p v-if="fieldErrors.location" class="mt-1 text-sm text-red-600">{{ fieldErrors.location }}</p>
|
||||
<p class="mt-1 text-sm text-gray-500">Enter a video conference link or Slack channel (starting with #)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Start Date & Time <span class="text-red-500">*</span>
|
||||
</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"
|
||||
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.startDate }"
|
||||
/>
|
||||
<p v-if="fieldErrors.startDate" class="mt-1 text-sm text-red-600">{{ fieldErrors.startDate }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
End Date & Time <span class="text-red-500">*</span>
|
||||
</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"
|
||||
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.endDate }"
|
||||
/>
|
||||
<p v-if="fieldErrors.endDate" class="mt-1 text-sm text-red-600">{{ fieldErrors.endDate }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Max Attendees</label>
|
||||
<input
|
||||
v-model="eventForm.maxAttendees"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Leave blank for unlimited"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">Set a maximum number of attendees (optional)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">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"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">When should registration close? (optional)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Audience -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Target Audience</h2>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">Target Circles</label>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="eventForm.targetCircles"
|
||||
value="community"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700">Community Circle</span>
|
||||
<p class="text-xs text-gray-500">New members and those exploring the community</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="eventForm.targetCircles"
|
||||
value="founder"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700">Founder Circle</span>
|
||||
<p class="text-xs text-gray-500">Entrepreneurs and business leaders</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="eventForm.targetCircles"
|
||||
value="practitioner"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700">Practitioner Circle</span>
|
||||
<p class="text-xs text-gray-500">Experts and professionals sharing knowledge</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">Select which circles this event is most relevant for (leave blank for all circles)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Settings -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Event Settings</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="eventForm.isOnline"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700">Online Event</span>
|
||||
<p class="text-xs text-gray-500">Event will be conducted virtually</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="eventForm.registrationRequired"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700">Registration Required</span>
|
||||
<p class="text-xs text-gray-500">Attendees must register before attending</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="eventForm.isVisible"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700">Visible on Public Calendar</span>
|
||||
<p class="text-xs text-gray-500">Event will appear on the public events page</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="eventForm.isCancelled"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-red-600 focus:ring-red-500 mt-1"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700">Event Cancelled</span>
|
||||
<p class="text-xs text-gray-500">Mark this event as cancelled</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancellation Message (conditional) -->
|
||||
<div v-if="eventForm.isCancelled" class="mb-8">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Cancellation Message</label>
|
||||
<textarea
|
||||
v-model="eventForm.cancellationMessage"
|
||||
placeholder="Explain why the event was cancelled and any next steps..."
|
||||
rows="3"
|
||||
class="w-full border border-red-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||
></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">This message will be displayed to users viewing the event page</p>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-between items-center pt-6 border-t border-gray-200">
|
||||
<NuxtLink
|
||||
to="/admin/events"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-900 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</NuxtLink>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
v-if="!editingEvent"
|
||||
type="button"
|
||||
@click="saveAndCreateAnother"
|
||||
:disabled="creating"
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{{ creating ? 'Saving...' : 'Save & Create Another' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="creating"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{{ creating ? 'Saving...' : (editingEvent ? 'Update Event' : 'Create Event') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
console.log('🚀 CREATE PAGE: SCRIPT STARTED!')
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
console.log('🔍 CREATE PAGE: Script loading...')
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
console.log('🔍 CREATE PAGE: Route object:', route)
|
||||
console.log('🔍 CREATE PAGE: Current route query:', route.query)
|
||||
|
||||
const creating = ref(false)
|
||||
const editingEvent = ref(null)
|
||||
const showSuccessMessage = ref(false)
|
||||
const formErrors = ref([])
|
||||
const fieldErrors = ref({})
|
||||
|
||||
const eventForm = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
content: '',
|
||||
featureImage: null,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
eventType: 'community',
|
||||
location: '',
|
||||
isOnline: true,
|
||||
isVisible: true,
|
||||
isCancelled: false,
|
||||
cancellationMessage: '',
|
||||
targetCircles: [],
|
||||
maxAttendees: '',
|
||||
registrationRequired: false,
|
||||
registrationDeadline: ''
|
||||
})
|
||||
|
||||
// Check if we're editing an event
|
||||
if (route.query.edit) {
|
||||
console.log('🔍 Edit mode detected')
|
||||
console.log('🔍 Edit ID from query:', route.query.edit)
|
||||
console.log('🔍 Full route query:', route.query)
|
||||
|
||||
try {
|
||||
console.log('🔍 Fetching event data from API...')
|
||||
const response = await $fetch(`/api/admin/events/${route.query.edit}`)
|
||||
console.log('🔍 API response:', response)
|
||||
|
||||
const event = response.data
|
||||
console.log('🔍 Event data:', event)
|
||||
|
||||
if (event) {
|
||||
console.log('🔍 Setting up edit form with event data')
|
||||
editingEvent.value = event
|
||||
Object.assign(eventForm, {
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
content: event.content || '',
|
||||
featureImage: event.featureImage || null,
|
||||
startDate: new Date(event.startDate).toISOString().slice(0, 16),
|
||||
endDate: new Date(event.endDate).toISOString().slice(0, 16),
|
||||
eventType: event.eventType,
|
||||
location: event.location || '',
|
||||
isOnline: event.isOnline,
|
||||
isVisible: event.isVisible !== undefined ? event.isVisible : true,
|
||||
isCancelled: event.isCancelled || false,
|
||||
cancellationMessage: event.cancellationMessage || '',
|
||||
targetCircles: event.targetCircles || [],
|
||||
maxAttendees: event.maxAttendees || '',
|
||||
registrationRequired: event.registrationRequired,
|
||||
registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : ''
|
||||
})
|
||||
console.log('🔍 Form populated with:', eventForm)
|
||||
} else {
|
||||
console.log('❌ No event data found in response')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load event for editing:', error)
|
||||
console.error('❌ Error details:', error.data)
|
||||
}
|
||||
} else {
|
||||
console.log('🔍 Create mode - no edit ID in query')
|
||||
}
|
||||
|
||||
// Check if we're duplicating an event
|
||||
if (route.query.duplicate && process.client) {
|
||||
const duplicateData = sessionStorage.getItem('duplicateEventData')
|
||||
if (duplicateData) {
|
||||
try {
|
||||
const data = JSON.parse(duplicateData)
|
||||
Object.assign(eventForm, data)
|
||||
sessionStorage.removeItem('duplicateEventData')
|
||||
} catch (error) {
|
||||
console.error('Failed to load duplicate event data:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validateForm = () => {
|
||||
formErrors.value = []
|
||||
fieldErrors.value = {}
|
||||
|
||||
// Required field validation
|
||||
if (!eventForm.title.trim()) {
|
||||
formErrors.value.push('Event title is required')
|
||||
fieldErrors.value.title = 'Please enter an event title'
|
||||
}
|
||||
|
||||
if (!eventForm.description.trim()) {
|
||||
formErrors.value.push('Event description is required')
|
||||
fieldErrors.value.description = 'Please provide a description for your event'
|
||||
}
|
||||
|
||||
if (!eventForm.startDate) {
|
||||
formErrors.value.push('Start date and time is required')
|
||||
fieldErrors.value.startDate = 'Please select when the event starts'
|
||||
}
|
||||
|
||||
if (!eventForm.endDate) {
|
||||
formErrors.value.push('End date and time is required')
|
||||
fieldErrors.value.endDate = 'Please select when the event ends'
|
||||
}
|
||||
|
||||
if (!eventForm.location.trim()) {
|
||||
formErrors.value.push('Location is required')
|
||||
fieldErrors.value.location = 'Please enter a location (URL or Slack channel)'
|
||||
}
|
||||
|
||||
// Date validation
|
||||
if (eventForm.startDate && eventForm.endDate) {
|
||||
const startDate = new Date(eventForm.startDate)
|
||||
const endDate = new Date(eventForm.endDate)
|
||||
|
||||
if (startDate >= endDate) {
|
||||
formErrors.value.push('End date must be after start date')
|
||||
fieldErrors.value.endDate = 'End date must be after the start date'
|
||||
}
|
||||
|
||||
if (startDate < new Date()) {
|
||||
formErrors.value.push('Start date cannot be in the past')
|
||||
fieldErrors.value.startDate = 'Event cannot start in the past'
|
||||
}
|
||||
}
|
||||
|
||||
// Location format validation
|
||||
if (eventForm.location.trim()) {
|
||||
const urlPattern = /^https?:\/\/.+/
|
||||
const slackPattern = /^#[a-zA-Z0-9-_]+$/
|
||||
|
||||
if (!urlPattern.test(eventForm.location) && !slackPattern.test(eventForm.location)) {
|
||||
formErrors.value.push('Location must be a valid URL or Slack channel (starting with #)')
|
||||
fieldErrors.value.location = 'Enter a video conference link (https://...) or Slack channel (#channel-name)'
|
||||
}
|
||||
}
|
||||
|
||||
// Registration deadline validation
|
||||
if (eventForm.registrationDeadline && eventForm.startDate) {
|
||||
const regDeadline = new Date(eventForm.registrationDeadline)
|
||||
const startDate = new Date(eventForm.startDate)
|
||||
|
||||
if (regDeadline >= startDate) {
|
||||
formErrors.value.push('Registration deadline must be before the event starts')
|
||||
fieldErrors.value.registrationDeadline = 'Registration must close before the event starts'
|
||||
}
|
||||
}
|
||||
|
||||
return formErrors.value.length === 0
|
||||
}
|
||||
|
||||
const saveEvent = async (redirect = true) => {
|
||||
if (!validateForm()) {
|
||||
// Scroll to top to show errors
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
return false
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
if (editingEvent.value) {
|
||||
await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
|
||||
method: 'PUT',
|
||||
body: eventForm
|
||||
})
|
||||
} else {
|
||||
await $fetch('/api/admin/events', {
|
||||
method: 'POST',
|
||||
body: eventForm
|
||||
})
|
||||
}
|
||||
|
||||
showSuccessMessage.value = true
|
||||
setTimeout(() => { showSuccessMessage.value = false }, 5000)
|
||||
|
||||
if (redirect) {
|
||||
setTimeout(() => {
|
||||
router.push('/admin/events')
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to save event:', error)
|
||||
formErrors.value = [`Failed to ${editingEvent.value ? 'update' : 'create'} event: ${error.data?.statusMessage || error.message}`]
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
return false
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveAndCreateAnother = async () => {
|
||||
const success = await saveEvent(false)
|
||||
if (success) {
|
||||
// Reset form for new event
|
||||
Object.assign(eventForm, {
|
||||
title: '',
|
||||
description: '',
|
||||
content: '',
|
||||
featureImage: null,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
eventType: 'community',
|
||||
location: '',
|
||||
isOnline: true,
|
||||
isVisible: true,
|
||||
isCancelled: false,
|
||||
cancellationMessage: '',
|
||||
targetCircles: [],
|
||||
maxAttendees: '',
|
||||
registrationRequired: false,
|
||||
registrationDeadline: ''
|
||||
})
|
||||
|
||||
// Clear any existing errors
|
||||
formErrors.value = []
|
||||
fieldErrors.value = {}
|
||||
|
||||
// Scroll to top
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
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