469 lines
15 KiB
Vue
469 lines
15 KiB
Vue
<template>
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<!-- Header -->
|
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
|
|
<div>
|
|
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Événements</h1>
|
|
<p class="text-sm sm:text-base text-gray-600 mt-1">Organisez et participez aux événements du groupe</p>
|
|
</div>
|
|
<button
|
|
@click="showCreateModal = true"
|
|
class="w-full sm:w-auto btn-primary justify-center"
|
|
>
|
|
<Plus class="w-4 h-4 mr-2" />
|
|
Nouvel événement
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="border-b border-gray-200 mb-8">
|
|
<nav class="-mb-px flex space-x-8">
|
|
<button
|
|
@click="activeTab = 'upcoming'"
|
|
class="py-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
|
:class="activeTab === 'upcoming'
|
|
? 'border-primary-500 text-primary-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
|
>
|
|
À venir
|
|
</button>
|
|
<button
|
|
@click="activeTab = 'past'"
|
|
class="py-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
|
:class="activeTab === 'past'
|
|
? 'border-primary-500 text-primary-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
|
>
|
|
Passés
|
|
</button>
|
|
<button
|
|
@click="activeTab = 'all'"
|
|
class="py-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
|
:class="activeTab === 'all'
|
|
? 'border-primary-500 text-primary-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
|
>
|
|
Tous
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Events Grid -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
<div v-for="event in filteredEvents" :key="event.id" class="card hover:shadow-lg transition-shadow cursor-pointer" @click="openEvent(event)">
|
|
<div class="aspect-video bg-gray-100 relative overflow-hidden">
|
|
<img v-if="event.cover_image" :src="getMediaUrl(event.cover_image)" :alt="event.title" class="w-full h-full object-cover">
|
|
<div v-else class="w-full h-full flex items-center justify-center">
|
|
<img
|
|
v-if="event.creator_avatar"
|
|
:src="getMediaUrl(event.creator_avatar)"
|
|
:alt="event.creator_name"
|
|
class="w-16 h-16 rounded-full object-cover"
|
|
>
|
|
<div v-else class="w-16 h-16 rounded-full bg-primary-100 flex items-center justify-center">
|
|
<User class="w-8 h-8 text-primary-600" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Date Badge -->
|
|
<div class="absolute top-2 left-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
|
{{ formatDate(event.date) }}
|
|
</div>
|
|
|
|
<!-- Participation Badge -->
|
|
<div class="absolute top-2 right-2">
|
|
<span
|
|
v-if="getUserParticipation(event)"
|
|
class="px-2 py-1 rounded-full text-xs font-medium text-white"
|
|
:class="getParticipationBadgeClass(getUserParticipation(event))"
|
|
>
|
|
{{ getParticipationText(getUserParticipation(event)) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-4">
|
|
<h3 class="font-semibold text-gray-900 mb-2 line-clamp-2">{{ event.title }}</h3>
|
|
|
|
<!-- Description with mentions -->
|
|
<div v-if="event.description" class="mb-3">
|
|
<Mentions :content="event.description" :mentions="getMentionsFromContent(event.description)" class="text-sm text-gray-600 line-clamp-2" />
|
|
</div>
|
|
|
|
<div class="flex items-center text-gray-600 mb-3">
|
|
<MapPin class="w-4 h-4 mr-2" />
|
|
<span class="text-sm">{{ event.location || 'Lieu non spécifié' }}</span>
|
|
</div>
|
|
|
|
<div class="flex items-center text-gray-600 mb-4">
|
|
<User class="w-4 h-4 mr-2" />
|
|
<router-link
|
|
:to="`/profile/${event.creator_id}`"
|
|
class="text-sm text-gray-900 hover:text-primary-600 transition-colors cursor-pointer"
|
|
>
|
|
{{ event.creator_name }}
|
|
</router-link>
|
|
</div>
|
|
|
|
<!-- Quick Participation Buttons -->
|
|
<div class="flex gap-2 mb-3">
|
|
<button
|
|
@click.stop="quickParticipation(event.id, 'present')"
|
|
class="flex-1 py-2 px-3 rounded text-xs font-medium transition-colors"
|
|
:class="getUserParticipation(event) === 'present' ? 'bg-success-100 text-success-700' : 'bg-gray-100 text-gray-700 hover:bg-success-50'"
|
|
>
|
|
✓ Présent
|
|
</button>
|
|
<button
|
|
@click.stop="quickParticipation(event.id, 'maybe')"
|
|
class="flex-1 py-2 px-3 rounded text-xs font-medium transition-colors"
|
|
:class="getUserParticipation(event) === 'maybe' ? 'bg-warning-100 text-warning-700' : 'bg-gray-100 text-gray-700 hover:bg-warning-50'"
|
|
>
|
|
? Peut-être
|
|
</button>
|
|
<button
|
|
@click.stop="quickParticipation(event.id, 'absent')"
|
|
class="flex-1 py-2 px-3 rounded text-xs font-medium transition-colors"
|
|
:class="getUserParticipation(event) === 'absent' ? 'bg-accent-100 text-accent-700' : 'bg-gray-100 text-gray-700 hover:bg-accent-50'"
|
|
>
|
|
✗ Absent
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Participation Stats -->
|
|
<div class="flex items-center justify-between text-xs text-gray-500">
|
|
<span>{{ event.present_count || 0 }} présents</span>
|
|
<span>{{ event.maybe_count || 0 }} peut-être</span>
|
|
<span>{{ event.absent_count || 0 }} absents</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Load more -->
|
|
<div v-if="hasMoreEvents" class="text-center mt-8">
|
|
<button
|
|
@click="loadMoreEvents"
|
|
:disabled="loading"
|
|
class="btn-secondary"
|
|
>
|
|
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div v-if="filteredEvents.length === 0 && !loading" class="text-center py-12">
|
|
<Calendar class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
|
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
|
{{ activeTab === 'upcoming' ? 'Aucun événement à venir' :
|
|
activeTab === 'past' ? 'Aucun événement passé' : 'Aucun événement' }}
|
|
</h3>
|
|
<p class="text-gray-600">
|
|
{{ activeTab === 'upcoming' ? 'Créez le premier événement pour commencer !' :
|
|
activeTab === 'past' ? 'Les événements passés apparaîtront ici' :
|
|
'Créez le premier événement pour commencer !' }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Create Event Modal -->
|
|
<transition
|
|
enter-active-class="transition ease-out duration-200"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition ease-in duration-150"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<div
|
|
v-if="showCreateModal"
|
|
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
|
>
|
|
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
|
<h2 class="text-xl font-semibold mb-4">Créer un nouvel événement</h2>
|
|
|
|
<form @submit.prevent="createEvent" class="space-y-4">
|
|
<div>
|
|
<label class="label">Titre</label>
|
|
<input
|
|
v-model="newEvent.title"
|
|
type="text"
|
|
required
|
|
class="input"
|
|
placeholder="Titre de l'événement..."
|
|
>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label">Description (optionnel)</label>
|
|
<MentionInput
|
|
v-model="newEvent.description"
|
|
:users="users"
|
|
:rows="3"
|
|
placeholder="Décrivez votre événement... (utilisez @username pour mentionner)"
|
|
@mentions-changed="handleEventMentionsChanged"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label">Lieu</label>
|
|
<input
|
|
v-model="newEvent.location"
|
|
type="text"
|
|
class="input"
|
|
placeholder="Adresse ou lieu de l'événement..."
|
|
>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="label">Date et heure</label>
|
|
<input
|
|
v-model="newEvent.date"
|
|
type="datetime-local"
|
|
required
|
|
class="input"
|
|
>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label">Date de fin (optionnel)</label>
|
|
<input
|
|
v-model="newEvent.end_date"
|
|
type="datetime-local"
|
|
class="input"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3 pt-4">
|
|
<button
|
|
type="button"
|
|
@click="showCreateModal = false"
|
|
class="flex-1 btn-secondary"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
:disabled="creating"
|
|
class="flex-1 btn-primary"
|
|
>
|
|
{{ creating ? 'Création...' : 'Créer l\'événement' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useToast } from 'vue-toastification'
|
|
import { useRouter } from 'vue-router'
|
|
import axios from '@/utils/axios'
|
|
import { getMediaUrl } from '@/utils/axios'
|
|
import { formatDistanceToNow, format } from 'date-fns'
|
|
import { fr } from 'date-fns/locale'
|
|
import {
|
|
Plus,
|
|
Calendar,
|
|
User,
|
|
MapPin
|
|
} from 'lucide-vue-next'
|
|
import Mentions from '@/components/Mentions.vue'
|
|
import MentionInput from '@/components/MentionInput.vue'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
const router = useRouter()
|
|
const toast = useToast()
|
|
const authStore = useAuthStore()
|
|
|
|
const events = ref([])
|
|
const users = ref([])
|
|
const loading = ref(false)
|
|
const creating = ref(false)
|
|
const showCreateModal = ref(false)
|
|
const hasMoreEvents = ref(true)
|
|
const offset = ref(0)
|
|
const activeTab = ref('upcoming')
|
|
|
|
const newEvent = ref({
|
|
title: '',
|
|
description: '',
|
|
date: '',
|
|
location: '',
|
|
end_date: null
|
|
})
|
|
|
|
const eventMentions = ref([])
|
|
|
|
const filteredEvents = computed(() => {
|
|
if (activeTab.value === 'upcoming') {
|
|
return events.value.filter(event => new Date(event.date) >= new Date())
|
|
} else if (activeTab.value === 'past') {
|
|
return events.value.filter(event => new Date(event.date) < new Date())
|
|
}
|
|
return events.value
|
|
})
|
|
|
|
function formatRelativeDate(date) {
|
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
|
}
|
|
|
|
function formatDate(date) {
|
|
return format(new Date(date), 'dd/MM/yyyy', { locale: fr })
|
|
}
|
|
|
|
function openEvent(event) {
|
|
router.push(`/events/${event.id}`)
|
|
}
|
|
|
|
function handleEventMentionsChanged(mentions) {
|
|
eventMentions.value = mentions
|
|
}
|
|
|
|
async function fetchUsers() {
|
|
try {
|
|
const response = await axios.get('/api/users')
|
|
users.value = response.data
|
|
} catch (error) {
|
|
console.error('Error fetching users:', error)
|
|
}
|
|
}
|
|
|
|
function getMentionsFromContent(content) {
|
|
if (!content) return []
|
|
|
|
const mentions = []
|
|
const mentionRegex = /@(\w+)/g
|
|
let match
|
|
|
|
while ((match = mentionRegex.exec(content)) !== null) {
|
|
const username = match[1]
|
|
const user = users.value.find(u => u.username === username)
|
|
if (user) {
|
|
mentions.push({
|
|
id: user.id,
|
|
username: user.username,
|
|
full_name: user.full_name
|
|
})
|
|
}
|
|
}
|
|
|
|
return mentions
|
|
}
|
|
|
|
function getUserParticipation(event) {
|
|
const participation = event.participations?.find(p => p.user_id === authStore.user?.id)
|
|
return participation?.status || null
|
|
}
|
|
|
|
function getParticipationBadgeClass(status) {
|
|
switch (status) {
|
|
case 'present': return 'bg-success-600'
|
|
case 'maybe': return 'bg-warning-600'
|
|
case 'absent': return 'bg-accent-600'
|
|
default: return 'bg-gray-600'
|
|
}
|
|
}
|
|
|
|
function getParticipationText(status) {
|
|
switch (status) {
|
|
case 'present': return 'Présent'
|
|
case 'maybe': return 'Peut-être'
|
|
case 'absent': return 'Absent'
|
|
default: return 'En attente'
|
|
}
|
|
}
|
|
|
|
async function quickParticipation(eventId, status) {
|
|
try {
|
|
const response = await axios.put(`/api/events/${eventId}/participation`, {
|
|
status: status
|
|
})
|
|
|
|
// Update the event in the list
|
|
const eventIndex = events.value.findIndex(e => e.id === eventId)
|
|
if (eventIndex !== -1) {
|
|
events.value[eventIndex] = response.data
|
|
}
|
|
|
|
toast.success(`Participation mise à jour : ${getParticipationText(status)}`)
|
|
} catch (error) {
|
|
toast.error('Erreur lors de la mise à jour de la participation')
|
|
}
|
|
}
|
|
|
|
async function createEvent() {
|
|
if (!newEvent.value.title || !newEvent.value.date) return
|
|
|
|
creating.value = true
|
|
try {
|
|
const eventData = {
|
|
title: newEvent.value.title,
|
|
description: newEvent.value.description,
|
|
date: new Date(newEvent.value.date).toISOString(),
|
|
location: newEvent.value.location,
|
|
end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null
|
|
}
|
|
|
|
await axios.post('/api/events', eventData)
|
|
|
|
// Refresh events list
|
|
await fetchEvents()
|
|
|
|
showCreateModal.value = false
|
|
resetForm()
|
|
toast.success('Événement créé avec succès')
|
|
} catch (error) {
|
|
toast.error('Erreur lors de la création de l\'événement')
|
|
}
|
|
creating.value = false
|
|
}
|
|
|
|
function resetForm() {
|
|
newEvent.value = {
|
|
title: '',
|
|
description: '',
|
|
date: '',
|
|
location: '',
|
|
end_date: null
|
|
}
|
|
}
|
|
|
|
async function fetchEvents() {
|
|
loading.value = true
|
|
try {
|
|
const response = await axios.get(`/api/events?limit=12&offset=${offset.value}`)
|
|
console.log('Events response:', response.data)
|
|
if (response.data && response.data.length > 0) {
|
|
console.log('First event:', response.data[0])
|
|
console.log('Creator avatar:', response.data[0].creator_avatar)
|
|
}
|
|
if (offset.value === 0) {
|
|
events.value = response.data
|
|
} else {
|
|
events.value.push(...response.data)
|
|
}
|
|
|
|
hasMoreEvents.value = response.data.length === 12
|
|
} catch (error) {
|
|
console.error('Error fetching events:', error)
|
|
toast.error('Erreur lors du chargement des événements')
|
|
}
|
|
loading.value = false
|
|
}
|
|
|
|
async function loadMoreEvents() {
|
|
offset.value += 12
|
|
await fetchEvents()
|
|
}
|
|
|
|
onMounted(() => {
|
|
fetchEvents()
|
|
fetchUsers()
|
|
})
|
|
</script>
|