initial commit - LeDiscord plateforme des copains
This commit is contained in:
505
frontend/src/views/Events.vue
Normal file
505
frontend/src/views/Events.vue
Normal file
@@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Événements</h1>
|
||||
<p class="text-gray-600 mt-1">Organisez et participez aux événements du groupe</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn-primary"
|
||||
>
|
||||
<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">
|
||||
<Calendar class="w-16 h-16 text-gray-400" />
|
||||
</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>
|
||||
|
||||
<!-- Map Section -->
|
||||
<div>
|
||||
<label class="label">Coordonnées géographiques (optionnel)</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600">Latitude</label>
|
||||
<input
|
||||
v-model="newEvent.latitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
class="input"
|
||||
placeholder="48.8566"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600">Longitude</label>
|
||||
<input
|
||||
v-model="newEvent.longitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
class="input"
|
||||
placeholder="2.3522"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Preview -->
|
||||
<div v-if="newEvent.latitude && newEvent.longitude" class="mt-3">
|
||||
<div class="bg-gray-100 rounded-lg p-4 h-32 flex items-center justify-center">
|
||||
<div class="text-center text-gray-600">
|
||||
<MapPin class="w-8 h-8 mx-auto mb-2 text-primary-600" />
|
||||
<p class="text-sm">Localisation sélectionnée</p>
|
||||
<p class="text-xs mt-1">{{ newEvent.latitude }}, {{ newEvent.longitude }}</p>
|
||||
<a
|
||||
:href="`https://www.openstreetmap.org/?mlat=${newEvent.latitude}&mlon=${newEvent.longitude}&zoom=15`"
|
||||
target="_blank"
|
||||
class="text-primary-600 hover:underline text-xs mt-2 inline-block"
|
||||
>
|
||||
Voir sur OpenStreetMap
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
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,
|
||||
latitude: newEvent.value.latitude,
|
||||
longitude: newEvent.value.longitude,
|
||||
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: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
end_date: null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEvents() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get(`/api/events?limit=12&offset=${offset.value}`)
|
||||
if (offset.value === 0) {
|
||||
events.value = response.data
|
||||
} else {
|
||||
events.value.push(...response.data)
|
||||
}
|
||||
|
||||
hasMoreEvents.value = response.data.length === 12
|
||||
} catch (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>
|
||||
Reference in New Issue
Block a user