initial commit - LeDiscord plateforme des copains
This commit is contained in:
2129
frontend/src/views/Admin.vue
Normal file
2129
frontend/src/views/Admin.vue
Normal file
File diff suppressed because it is too large
Load Diff
862
frontend/src/views/AlbumDetail.vue
Normal file
862
frontend/src/views/AlbumDetail.vue
Normal file
@@ -0,0 +1,862 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">Chargement de l'album...</p>
|
||||
</div>
|
||||
|
||||
<!-- Album not found -->
|
||||
<div v-else-if="!album" class="text-center py-12">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-4">Album non trouvé</h1>
|
||||
<p class="text-gray-600 mb-6">L'album que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||
<router-link to="/albums" class="btn-primary">
|
||||
Retour aux albums
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Album details -->
|
||||
<div v-else>
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<router-link to="/albums" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Retour aux albums
|
||||
</router-link>
|
||||
|
||||
<div v-if="canEdit" class="flex space-x-2">
|
||||
<button
|
||||
@click="showEditModal = true"
|
||||
class="btn-secondary"
|
||||
>
|
||||
<Edit class="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
@click="showUploadModal = true"
|
||||
class="btn-primary"
|
||||
>
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
Ajouter des médias
|
||||
</button>
|
||||
<button
|
||||
@click="deleteAlbum"
|
||||
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300"
|
||||
>
|
||||
<Trash2 class="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-6">
|
||||
<!-- Cover Image -->
|
||||
<div class="w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center">
|
||||
<Image v-if="!album.cover_image" class="w-16 h-16 text-white" />
|
||||
<img
|
||||
v-else
|
||||
:src="getMediaUrl(album.cover_image)"
|
||||
:alt="album.title"
|
||||
class="w-full h-full object-cover rounded-xl"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Album Info -->
|
||||
<div class="flex-1">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">{{ album.title }}</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-3">
|
||||
<div v-if="album.description" class="text-gray-700">
|
||||
{{ album.description }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-gray-600">
|
||||
<User class="w-5 h-5 mr-3" />
|
||||
<span>Créé par {{ album.creator_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-gray-600">
|
||||
<Calendar class="w-5 h-5 mr-3" />
|
||||
<span>{{ formatDate(album.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="album.event_title" class="flex items-center text-primary-600">
|
||||
<Calendar class="w-5 h-5 mr-3" />
|
||||
<router-link :to="`/events/${album.event_id}`" class="hover:underline">
|
||||
{{ album.event_title }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-3">Statistiques</h3>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary-600">{{ album.media_count || 0 }}</div>
|
||||
<div class="text-gray-600">Médias</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-success-600">{{ formatBytes(totalSize) }}</div>
|
||||
<div class="text-gray-600">Taille totale</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Media Section -->
|
||||
<div v-if="album.top_media && album.top_media.length > 0" class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Top Media</h2>
|
||||
<p class="text-gray-600 mb-4">Les médias les plus appréciés de cet album</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<div
|
||||
v-for="media in album.top_media"
|
||||
:key="media.id"
|
||||
class="group relative aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer"
|
||||
@click="openMediaViewer(media)"
|
||||
>
|
||||
<img
|
||||
v-if="media.media_type === 'image'"
|
||||
:src="getMediaUrl(media.thumbnail_path) || getMediaUrl(media.file_path)"
|
||||
:alt="media.caption || 'Image'"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
>
|
||||
<video
|
||||
v-else
|
||||
:src="getMediaUrl(media.file_path)"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
|
||||
<!-- Media Type Badge -->
|
||||
<div class="absolute top-1 left-1 bg-black bg-opacity-70 text-white text-xs px-1 py-0.5 rounded">
|
||||
{{ media.media_type === 'image' ? '📷' : '🎥' }}
|
||||
</div>
|
||||
|
||||
<!-- Likes Badge -->
|
||||
<div class="absolute top-1 right-1 bg-primary-600 text-white text-xs px-1 py-0.5 rounded flex items-center">
|
||||
<Heart class="w-2 h-2 mr-1" />
|
||||
{{ media.likes_count }}
|
||||
</div>
|
||||
|
||||
<!-- Caption -->
|
||||
<div v-if="media.caption" class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-70 text-white text-xs p-1 truncate">
|
||||
{{ media.caption }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media Gallery -->
|
||||
<div class="card p-6 mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Galerie</h2>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="viewMode = 'grid'"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
:class="viewMode === 'grid' ? 'bg-primary-100 text-primary-600' : 'text-gray-400 hover:text-gray-600'"
|
||||
>
|
||||
<Grid class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
@click="viewMode = 'list'"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
:class="viewMode === 'list' ? 'bg-primary-100 text-primary-600' : 'text-gray-400 hover:text-gray-600'"
|
||||
>
|
||||
<List class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="album.media.length === 0" class="text-center py-12 text-gray-500">
|
||||
<Image class="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<h3 class="text-lg font-medium mb-2">Aucun média</h3>
|
||||
<p>Cet album ne contient pas encore de photos ou vidéos</p>
|
||||
</div>
|
||||
|
||||
<!-- Grid View -->
|
||||
<div v-else-if="viewMode === 'grid'" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="media in album.media"
|
||||
:key="media.id"
|
||||
class="group relative aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer"
|
||||
@click="openMediaViewer(media)"
|
||||
>
|
||||
<img
|
||||
v-if="media.media_type === 'image'"
|
||||
:src="getMediaUrl(media.thumbnail_path) || getMediaUrl(media.file_path)"
|
||||
:alt="media.caption || 'Image'"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
>
|
||||
<video
|
||||
v-else
|
||||
:src="getMediaUrl(media.file_path)"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
|
||||
<!-- Media Type Badge -->
|
||||
<div class="absolute top-2 left-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||
{{ media.media_type === 'image' ? '📷' : '🎥' }}
|
||||
</div>
|
||||
|
||||
<!-- Caption -->
|
||||
<div v-if="media.caption" class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-70 text-white text-xs p-2">
|
||||
{{ media.caption }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div v-if="canEdit" class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@click.stop="deleteMedia(media.id)"
|
||||
class="bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Like Button -->
|
||||
<div class="absolute bottom-2 right-2 opacity-100 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@click.stop="toggleMediaLike(media)"
|
||||
class="flex items-center space-x-2 px-3 py-2 rounded-full text-sm transition-colors shadow-lg"
|
||||
:class="media.is_liked ? 'bg-red-500 text-white hover:bg-red-600' : 'bg-black bg-opacity-80 text-white hover:bg-opacity-90'"
|
||||
>
|
||||
<Heart :class="media.is_liked ? 'fill-current' : ''" class="w-4 h-4" />
|
||||
<span class="font-medium">{{ media.likes_count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="media in album.media"
|
||||
:key="media.id"
|
||||
class="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div class="w-16 h-16 bg-gray-200 rounded overflow-hidden">
|
||||
<img
|
||||
v-if="media.media_type === 'image'"
|
||||
:src="getMediaUrl(media.thumbnail_path) || getMediaUrl(media.file_path)"
|
||||
:alt="media.caption || 'Image'"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<video
|
||||
v-else
|
||||
:src="getMediaUrl(media.file_path)"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900">{{ media.caption || 'Sans titre' }}</p>
|
||||
<p class="text-sm text-gray-600">{{ formatBytes(media.file_size) }} • {{ media.media_type === 'image' ? 'Image' : 'Vidéo' }}</p>
|
||||
<p class="text-xs text-gray-500">{{ formatDate(media.created_at) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="openMediaViewer(media)"
|
||||
class="p-2 text-gray-400 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
<Eye class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="toggleMediaLike(media)"
|
||||
class="p-2 text-gray-400 hover:text-red-600 transition-colors"
|
||||
:class="{ 'text-red-600': media.is_liked }"
|
||||
>
|
||||
<Heart :class="media.is_liked ? 'fill-current' : ''" class="w-5 h-5" />
|
||||
<span class="ml-2 text-sm font-medium">{{ media.likes_count }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canEdit"
|
||||
@click="deleteMedia(media.id)"
|
||||
class="p-2 text-gray-400 hover:text-accent-600 transition-colors"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Album 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="showEditModal"
|
||||
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-md w-full p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Modifier l'album</h2>
|
||||
|
||||
<form @submit.prevent="updateAlbum" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Titre</label>
|
||||
<input
|
||||
v-model="editForm.title"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="3"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showEditModal = false"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="updating"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
{{ updating ? 'Mise à jour...' : 'Mettre à jour' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Upload Media 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="showUploadModal"
|
||||
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">Ajouter des médias</h2>
|
||||
|
||||
<form @submit.prevent="uploadMedia" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Photos et vidéos</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<input
|
||||
ref="mediaInput"
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleMediaChange"
|
||||
>
|
||||
|
||||
<div v-if="newMedia.length === 0" class="space-y-2">
|
||||
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
|
||||
<p class="text-gray-600">Glissez-déposez ou cliquez pour sélectionner</p>
|
||||
<p class="text-sm text-gray-500">Images et vidéos (max 100MB par fichier)</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.mediaInput.click()"
|
||||
class="btn-secondary"
|
||||
>
|
||||
Sélectionner des fichiers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="(media, index) in newMedia"
|
||||
:key="index"
|
||||
class="relative aspect-square bg-gray-100 rounded overflow-hidden"
|
||||
>
|
||||
<img
|
||||
v-if="media.type === 'image'"
|
||||
:src="media.preview"
|
||||
:alt="media.name"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<video
|
||||
v-else
|
||||
:src="media.preview"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
<button
|
||||
@click="removeMedia(index)"
|
||||
class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div class="absolute bottom-1 left-1 bg-black bg-opacity-70 text-white text-xs px-1 py-0.5 rounded">
|
||||
{{ media.type === 'image' ? '📷' : '🎥' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.mediaInput.click()"
|
||||
class="btn-secondary text-sm"
|
||||
>
|
||||
Ajouter plus de fichiers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showUploadModal = false"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="uploading || newMedia.length === 0"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
{{ uploading ? 'Upload...' : 'Ajouter les médias' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Media Viewer 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="selectedMedia"
|
||||
class="fixed inset-0 bg-black bg-opacity-90 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="relative max-w-7xl max-h-[95vh] w-full h-full">
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
@click="closeMediaViewer"
|
||||
class="absolute top-6 right-6 z-10 bg-black bg-opacity-70 text-white rounded-full w-12 h-12 flex items-center justify-center hover:bg-opacity-90 transition-colors shadow-lg"
|
||||
>
|
||||
<X class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<button
|
||||
v-if="album.media.length > 1"
|
||||
@click="previousMedia"
|
||||
class="absolute left-6 top-1/2 transform -translate-y-1/2 z-10 bg-black bg-opacity-70 text-white rounded-full w-12 h-12 flex items-center justify-center hover:bg-opacity-90 transition-colors shadow-lg"
|
||||
>
|
||||
<ChevronLeft class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="album.media.length > 1"
|
||||
@click="nextMedia"
|
||||
class="absolute right-6 top-1/2 transform -translate-y-1/2 z-10 bg-black bg-opacity-70 text-white rounded-full w-12 h-12 flex items-center justify-center hover:bg-opacity-90 transition-colors shadow-lg"
|
||||
>
|
||||
<ChevronRight class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<!-- Position Indicator -->
|
||||
<div v-if="album.media.length > 1" class="absolute top-6 left-1/2 transform -translate-x-1/2 z-10 bg-black bg-opacity-70 text-white px-4 py-2 rounded-full text-sm font-medium backdrop-blur-sm">
|
||||
{{ getCurrentMediaIndex() + 1 }} / {{ album.media.length }}
|
||||
</div>
|
||||
|
||||
<!-- Media Content -->
|
||||
<div class="flex flex-col items-center h-full">
|
||||
<!-- Image or Video -->
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
v-if="selectedMedia.media_type === 'image'"
|
||||
:src="getMediaUrl(selectedMedia.thumbnail_path) || getMediaUrl(selectedMedia.file_path)"
|
||||
:alt="selectedMedia.caption || 'Image'"
|
||||
class="w-auto h-auto max-w-none max-h-none object-contain rounded-lg shadow-2xl"
|
||||
:style="getOptimalMediaSize()"
|
||||
>
|
||||
<video
|
||||
v-else
|
||||
:src="getMediaUrl(selectedMedia.file_path)"
|
||||
controls
|
||||
class="w-auto h-auto max-w-none max-h-none rounded-lg shadow-2xl"
|
||||
:style="getOptimalMediaSize()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Media Info -->
|
||||
<div class="mt-6 text-center text-white bg-black bg-opacity-50 rounded-xl p-6 backdrop-blur-sm">
|
||||
<h3 v-if="selectedMedia.caption" class="text-xl font-semibold mb-3 text-white">
|
||||
{{ selectedMedia.caption }}
|
||||
</h3>
|
||||
<div class="flex items-center justify-center space-x-6 text-sm text-gray-200 mb-4">
|
||||
<span class="flex items-center space-x-2">
|
||||
<span class="w-2 h-2 bg-blue-400 rounded-full"></span>
|
||||
<span>{{ formatBytes(selectedMedia.file_size) }}</span>
|
||||
</span>
|
||||
<span class="flex items-center space-x-2">
|
||||
<span class="w-2 h-2 bg-green-400 rounded-full"></span>
|
||||
<span>{{ selectedMedia.media_type === 'image' ? '📷 Image' : '🎥 Vidéo' }}</span>
|
||||
</span>
|
||||
<span class="flex items-center space-x-2">
|
||||
<span class="w-2 h-2 bg-purple-400 rounded-full"></span>
|
||||
<span>{{ formatDate(selectedMedia.created_at) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Like Button in Viewer -->
|
||||
<div class="flex items-center justify-center">
|
||||
<button
|
||||
@click="toggleMediaLikeFromViewer(selectedMedia)"
|
||||
class="flex items-center space-x-3 px-6 py-3 rounded-full transition-all duration-300 transform hover:scale-105"
|
||||
:class="selectedMedia.is_liked ? 'bg-red-500 text-white shadow-lg hover:bg-red-600' : 'bg-white bg-opacity-20 text-white hover:bg-opacity-40 hover:shadow-lg'"
|
||||
>
|
||||
<Heart :class="selectedMedia.is_liked ? 'fill-current' : ''" class="w-6 h-6" />
|
||||
<span class="font-medium text-lg">{{ selectedMedia.likes_count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Image,
|
||||
User,
|
||||
Calendar,
|
||||
Edit,
|
||||
Upload,
|
||||
Trash2,
|
||||
Grid,
|
||||
List,
|
||||
X,
|
||||
Eye,
|
||||
Heart,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const album = ref(null)
|
||||
const loading = ref(true)
|
||||
const updating = ref(false)
|
||||
const uploading = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showUploadModal = ref(false)
|
||||
const viewMode = ref('grid')
|
||||
const selectedMedia = ref(null)
|
||||
|
||||
|
||||
const editForm = ref({
|
||||
title: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const newMedia = ref([])
|
||||
|
||||
const canEdit = computed(() =>
|
||||
album.value && (album.value.creator_id === authStore.user?.id || authStore.user?.is_admin)
|
||||
)
|
||||
|
||||
const totalSize = computed(() =>
|
||||
album.value?.media?.reduce((sum, media) => sum + (media.file_size || 0), 0) || 0
|
||||
)
|
||||
|
||||
function formatDate(date) {
|
||||
return format(new Date(date), 'dd MMMM yyyy', { locale: fr })
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
async function fetchAlbum() {
|
||||
try {
|
||||
const response = await axios.get(`/api/albums/${route.params.id}`)
|
||||
album.value = response.data
|
||||
|
||||
// Initialize edit form
|
||||
editForm.value = {
|
||||
title: album.value.title,
|
||||
description: album.value.description || ''
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement de l\'album')
|
||||
console.error('Error fetching album:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAlbum() {
|
||||
updating.value = true
|
||||
try {
|
||||
const response = await axios.put(`/api/albums/${album.value.id}`, editForm.value)
|
||||
album.value = response.data
|
||||
showEditModal.value = false
|
||||
toast.success('Album mis à jour')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour')
|
||||
}
|
||||
updating.value = false
|
||||
}
|
||||
|
||||
async function deleteAlbum() {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cet album ?')) return
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/albums/${album.value.id}`)
|
||||
toast.success('Album supprimé')
|
||||
router.push('/albums')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMediaChange(event) {
|
||||
const files = Array.from(event.target.files)
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
|
||||
toast.error(`${file.name} n'est pas un fichier image ou vidéo valide`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
toast.error(`${file.name} est trop volumineux (max 100MB)`)
|
||||
continue
|
||||
}
|
||||
|
||||
const media = {
|
||||
file: file,
|
||||
name: file.name,
|
||||
type: file.type.startsWith('image/') ? 'image' : 'video',
|
||||
preview: URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
newMedia.value.push(media)
|
||||
}
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function removeMedia(index) {
|
||||
const media = newMedia.value[index]
|
||||
if (media.preview && media.preview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(media.preview)
|
||||
}
|
||||
newMedia.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function uploadMedia() {
|
||||
if (newMedia.value.length === 0) return
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
newMedia.value.forEach(media => {
|
||||
formData.append('files', media.file)
|
||||
})
|
||||
|
||||
await axios.post(`/api/albums/${album.value.id}/media`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
// Refresh album data
|
||||
await fetchAlbum()
|
||||
|
||||
showUploadModal.value = false
|
||||
newMedia.value.forEach(media => {
|
||||
if (media.preview && media.preview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(media.preview)
|
||||
}
|
||||
})
|
||||
newMedia.value = []
|
||||
|
||||
toast.success('Médias ajoutés avec succès')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'upload')
|
||||
}
|
||||
uploading.value = false
|
||||
}
|
||||
|
||||
async function deleteMedia(mediaId) {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce média ?')) return
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/albums/${album.value.id}/media/${mediaId}`)
|
||||
await fetchAlbum()
|
||||
toast.success('Média supprimé')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMediaLike(media) {
|
||||
try {
|
||||
const response = await axios.post(`/api/albums/${album.value.id}/media/${media.id}/like`)
|
||||
media.is_liked = response.data.is_liked
|
||||
media.likes_count = response.data.likes_count
|
||||
toast.success('Like mis à jour')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour du like')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMediaLikeFromViewer(media) {
|
||||
try {
|
||||
const response = await axios.post(`/api/albums/${album.value.id}/media/${media.id}/like`)
|
||||
media.is_liked = response.data.is_liked
|
||||
media.likes_count = response.data.likes_count
|
||||
toast.success('Like mis à jour')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour du like')
|
||||
}
|
||||
}
|
||||
|
||||
function openMediaViewer(media) {
|
||||
selectedMedia.value = media
|
||||
|
||||
// Add keyboard event listeners
|
||||
document.addEventListener('keydown', handleKeyboardNavigation)
|
||||
}
|
||||
|
||||
function closeMediaViewer() {
|
||||
selectedMedia.value = null
|
||||
|
||||
// Remove keyboard event listeners
|
||||
document.removeEventListener('keydown', handleKeyboardNavigation)
|
||||
}
|
||||
|
||||
function handleKeyboardNavigation(event) {
|
||||
if (!selectedMedia.value) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
closeMediaViewer()
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
if (album.value.media.length > 1) {
|
||||
previousMedia()
|
||||
}
|
||||
break
|
||||
case 'ArrowRight':
|
||||
if (album.value.media.length > 1) {
|
||||
nextMedia()
|
||||
}
|
||||
break
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
function previousMedia() {
|
||||
const currentIndex = album.value.media.findIndex(media => media.id === selectedMedia.value.id)
|
||||
if (currentIndex > 0) {
|
||||
selectedMedia.value = album.value.media[currentIndex - 1]
|
||||
}
|
||||
}
|
||||
|
||||
function nextMedia() {
|
||||
const currentIndex = album.value.media.findIndex(media => media.id === selectedMedia.value.id)
|
||||
if (currentIndex < album.value.media.length - 1) {
|
||||
selectedMedia.value = album.value.media[currentIndex + 1]
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentMediaIndex() {
|
||||
if (!selectedMedia.value || !album.value) return 0
|
||||
return album.value.media.findIndex(media => media.id === selectedMedia.value.id)
|
||||
}
|
||||
|
||||
function getOptimalMediaSize() {
|
||||
if (!selectedMedia.value) return {}
|
||||
|
||||
// Utilisation MAXIMALE de l'espace disponible
|
||||
if (selectedMedia.value.media_type === 'image') {
|
||||
// Images : 100% de l'écran avec juste une petite marge
|
||||
return {
|
||||
'max-width': '98vw',
|
||||
'max-height': '96vh',
|
||||
'width': 'auto',
|
||||
'height': 'auto',
|
||||
'object-fit': 'contain'
|
||||
}
|
||||
}
|
||||
|
||||
// Vidéos : presque plein écran
|
||||
return {
|
||||
'max-width': '96vw',
|
||||
'max-height': '94vh',
|
||||
'width': 'auto',
|
||||
'height': 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAlbum()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up event listeners
|
||||
document.removeEventListener('keydown', handleKeyboardNavigation)
|
||||
})
|
||||
</script>
|
||||
786
frontend/src/views/Albums.vue
Normal file
786
frontend/src/views/Albums.vue
Normal file
@@ -0,0 +1,786 @@
|
||||
<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">Albums photos</h1>
|
||||
<p class="text-gray-600 mt-1">Partagez vos souvenirs en photos et vidéos</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn-primary"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nouvel album
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Albums Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="album in albums"
|
||||
:key="album.id"
|
||||
class="card hover:shadow-lg transition-shadow cursor-pointer"
|
||||
@click="openAlbum(album)"
|
||||
>
|
||||
<!-- Cover Image -->
|
||||
<div class="aspect-square bg-gray-100 relative overflow-hidden">
|
||||
<img
|
||||
v-if="album.cover_image"
|
||||
:src="getMediaUrl(album.cover_image)"
|
||||
:alt="album.title"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Image class="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Media Count Badge -->
|
||||
<div class="absolute top-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||
{{ album.media_count }} média{{ album.media_count > 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">{{ album.title }}</h3>
|
||||
|
||||
<div v-if="album.description" class="mb-3">
|
||||
<Mentions :content="album.description" :mentions="getMentionsFromContent(album.description)" class="text-sm text-gray-600 line-clamp-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-600 mb-3">
|
||||
<img
|
||||
v-if="album.creator_avatar"
|
||||
:src="getMediaUrl(album.creator_avatar)"
|
||||
:alt="album.creator_name"
|
||||
class="w-5 h-5 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User class="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
<router-link
|
||||
:to="`/profile/${album.creator_id}`"
|
||||
class="text-gray-900 hover:text-primary-600 transition-colors cursor-pointer"
|
||||
>
|
||||
{{ album.creator_name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{{ formatRelativeDate(album.created_at) }}</span>
|
||||
<div v-if="album.event_title" class="text-primary-600">
|
||||
📅 {{ album.event_title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load more -->
|
||||
<div v-if="hasMoreAlbums" class="text-center mt-8">
|
||||
<button
|
||||
@click="loadMoreAlbums"
|
||||
:disabled="loading"
|
||||
class="btn-secondary"
|
||||
>
|
||||
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="albums.length === 0 && !loading" class="text-center py-12">
|
||||
<Image class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun album</h3>
|
||||
<p class="text-gray-600">Créez le premier album pour partager vos photos !</p>
|
||||
</div>
|
||||
|
||||
<!-- Create Album 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 album</h2>
|
||||
|
||||
<form @submit.prevent="createAlbum" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Titre</label>
|
||||
<input
|
||||
v-model="newAlbum.title"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
placeholder="Titre de l'album..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<MentionInput
|
||||
v-model="newAlbum.description"
|
||||
:users="users"
|
||||
:rows="3"
|
||||
placeholder="Décrivez votre album... (utilisez @username pour mentionner)"
|
||||
@mentions-changed="handleAlbumMentionsChanged"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Lier à un événement (optionnel)</label>
|
||||
<select
|
||||
v-model="newAlbum.event_id"
|
||||
class="input"
|
||||
>
|
||||
<option value="">Aucun événement</option>
|
||||
<option
|
||||
v-for="event in events"
|
||||
:key="event.id"
|
||||
:value="event.id"
|
||||
>
|
||||
{{ event.title }} - {{ formatDate(event.date) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Photos et vidéos</label>
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center transition-colors"
|
||||
:class="{ 'border-primary-400 bg-primary-50': isDragOver }"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<input
|
||||
ref="mediaInput"
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleMediaChange"
|
||||
>
|
||||
|
||||
<div v-if="newAlbum.media.length === 0" class="space-y-2">
|
||||
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
|
||||
<p class="text-gray-600">Glissez-déposez ou cliquez pour sélectionner</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Images et vidéos (max {{ uploadLimits.max_image_size_mb }}MB pour images, {{ uploadLimits.max_video_size_mb }}MB pour vidéos)
|
||||
<br>
|
||||
<span class="text-xs text-gray-400">Maximum {{ uploadLimits.max_media_per_album }} fichiers par album</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.mediaInput.click()"
|
||||
class="btn-secondary"
|
||||
>
|
||||
Sélectionner des fichiers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="(media, index) in newAlbum.media"
|
||||
:key="index"
|
||||
class="relative aspect-square bg-gray-100 rounded overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
v-if="media.type === 'image'"
|
||||
:src="media.preview"
|
||||
:alt="media.name"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<video
|
||||
v-else
|
||||
:src="media.preview"
|
||||
class="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
/>
|
||||
|
||||
<!-- File Info Overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-all duration-200 flex items-end">
|
||||
<div class="w-full p-2 text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div class="font-medium truncate">{{ media.name }}</div>
|
||||
<div class="text-gray-300">
|
||||
{{ formatFileSize(media.size) }}
|
||||
<span v-if="media.originalSize && media.originalSize > media.size" class="text-green-300">
|
||||
({{ Math.round((1 - media.size / media.originalSize) * 100) }}% compression)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Caption Input -->
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-70 p-2">
|
||||
<input
|
||||
v-model="media.caption"
|
||||
type="text"
|
||||
:placeholder="`Légende pour ${media.name}`"
|
||||
class="w-full text-xs bg-transparent text-white placeholder-gray-300 border-none outline-none"
|
||||
maxlength="100"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="removeMedia(index)"
|
||||
class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div class="absolute top-1 left-1 bg-black bg-opacity-70 text-white text-xs px-1 py-0.5 rounded">
|
||||
{{ media.type === 'image' ? '📷' : '🎥' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.mediaInput.click()"
|
||||
class="btn-secondary text-sm"
|
||||
>
|
||||
Ajouter plus de fichiers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress Bar -->
|
||||
<div v-if="isUploading" class="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-blue-900">{{ uploadStatus }}</span>
|
||||
<span class="text-sm text-blue-700">{{ currentFileIndex }}/{{ totalFiles }}</span>
|
||||
</div>
|
||||
<div class="w-full bg-blue-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="`width: ${uploadProgress}%`"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-blue-600">
|
||||
{{ Math.round(uploadProgress) }}% terminé
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Results Summary -->
|
||||
<div v-if="uploadSuccess.length > 0 || uploadErrors.length > 0" class="mt-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div v-if="uploadSuccess.length > 0" class="mb-2">
|
||||
<div class="text-sm font-medium text-green-700">
|
||||
✅ {{ uploadSuccess.length }} fichier(s) uploadé(s) avec succès
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="uploadErrors.length > 0" class="mb-2">
|
||||
<div class="text-sm font-medium text-red-700">
|
||||
❌ {{ uploadErrors.length }} erreur(s) lors de l'upload
|
||||
</div>
|
||||
<div class="text-xs text-red-600 mt-1">
|
||||
<div v-for="error in uploadErrors" :key="error.file" class="mb-1">
|
||||
{{ error.file }}: {{ error.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showCreateModal = false"
|
||||
:disabled="isUploading"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="creating || newAlbum.media.length === 0 || isUploading"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
<span v-if="isUploading">
|
||||
<Upload class="w-4 h-4 mr-2 animate-spin" />
|
||||
Upload en cours...
|
||||
</span>
|
||||
<span v-else-if="creating">
|
||||
Création...
|
||||
</span>
|
||||
<span v-else>
|
||||
Créer l'album
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, 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,
|
||||
Image,
|
||||
User,
|
||||
Upload,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
import Mentions from '@/components/Mentions.vue'
|
||||
import MentionInput from '@/components/MentionInput.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const albums = ref([])
|
||||
const events = ref([])
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const hasMoreAlbums = ref(true)
|
||||
const offset = ref(0)
|
||||
const uploadLimits = ref({
|
||||
max_image_size_mb: 10,
|
||||
max_video_size_mb: 100,
|
||||
max_media_per_album: 50
|
||||
})
|
||||
|
||||
const newAlbum = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
event_id: '',
|
||||
media: []
|
||||
})
|
||||
|
||||
const albumMentions = ref([])
|
||||
|
||||
// Upload optimization states
|
||||
const uploadProgress = ref(0)
|
||||
const isUploading = ref(false)
|
||||
const uploadStatus = ref('')
|
||||
const currentFileIndex = ref(0)
|
||||
const totalFiles = ref(0)
|
||||
const uploadErrors = ref([])
|
||||
const uploadSuccess = ref([])
|
||||
const isDragOver = ref(false)
|
||||
|
||||
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 formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function openAlbum(album) {
|
||||
router.push(`/albums/${album.id}`)
|
||||
}
|
||||
|
||||
function handleAlbumMentionsChanged(mentions) {
|
||||
albumMentions.value = mentions
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const response = await axios.get('/api/users')
|
||||
users.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUploadLimits() {
|
||||
try {
|
||||
const response = await axios.get('/api/settings/upload-limits')
|
||||
uploadLimits.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching upload limits:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMediaChange(event) {
|
||||
const files = Array.from(event.target.files)
|
||||
const maxFiles = uploadLimits.value.max_media_per_album
|
||||
|
||||
if (newAlbum.value.media.length + files.length > maxFiles) {
|
||||
toast.error(`Maximum ${maxFiles} fichiers autorisés par album`)
|
||||
event.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
let validFiles = 0
|
||||
let skippedFiles = 0
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
|
||||
toast.error(`${file.name} n'est pas un fichier image ou vidéo valide`)
|
||||
skippedFiles++
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
const maxSizeMB = file.type.startsWith('image/') ? uploadLimits.value.max_image_size_mb : uploadLimits.value.max_video_size_mb
|
||||
const maxSizeBytes = maxSizeMB * 1024 * 1024
|
||||
|
||||
if (file.size > maxSizeBytes) {
|
||||
toast.error(`${file.name} est trop volumineux (max ${maxSizeMB}MB)`)
|
||||
skippedFiles++
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate file name (prevent special characters issues)
|
||||
if (file.name.length > 100) {
|
||||
toast.error(`${file.name} a un nom trop long (max 100 caractères)`)
|
||||
skippedFiles++
|
||||
continue
|
||||
}
|
||||
|
||||
// Optimize image if it's an image file
|
||||
let optimizedFile = file
|
||||
if (file.type.startsWith('image/')) {
|
||||
try {
|
||||
optimizedFile = await optimizeImage(file)
|
||||
} catch (error) {
|
||||
console.warn(`Could not optimize ${file.name}:`, error)
|
||||
optimizedFile = file // Fallback to original file
|
||||
}
|
||||
}
|
||||
|
||||
// Create media object with optimized preview
|
||||
const media = {
|
||||
file: optimizedFile,
|
||||
originalFile: file, // Keep reference to original for size display
|
||||
name: file.name,
|
||||
type: file.type.startsWith('image/') ? 'image' : 'video',
|
||||
size: optimizedFile.size,
|
||||
originalSize: file.size,
|
||||
preview: URL.createObjectURL(optimizedFile),
|
||||
caption: '' // Add caption field for user input
|
||||
}
|
||||
|
||||
newAlbum.value.media.push(media)
|
||||
validFiles++
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${file.name}:`, error)
|
||||
toast.error(`Erreur lors du traitement de ${file.name}`)
|
||||
skippedFiles++
|
||||
}
|
||||
}
|
||||
|
||||
// Show summary
|
||||
if (validFiles > 0) {
|
||||
if (skippedFiles > 0) {
|
||||
toast.info(`${validFiles} fichier(s) ajouté(s), ${skippedFiles} ignoré(s)`)
|
||||
} else {
|
||||
toast.success(`${validFiles} fichier(s) ajouté(s)`)
|
||||
}
|
||||
}
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
isDragOver.value = false
|
||||
const files = Array.from(event.dataTransfer.files)
|
||||
if (files.length > 0) {
|
||||
// Simulate file input change
|
||||
const fakeEvent = { target: { files: files } }
|
||||
handleMediaChange(fakeEvent)
|
||||
}
|
||||
}
|
||||
|
||||
async function optimizeImage(file) {
|
||||
return new Promise((resolve) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
resolve(file) // Return original file for non-images
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
// Calculate optimal dimensions (much higher for modern screens)
|
||||
const maxWidth = 3840 // 4K support
|
||||
const maxHeight = 2160 // 4K support
|
||||
let { width, height } = img
|
||||
|
||||
// Only resize if image is REALLY too large
|
||||
if (width > maxWidth || height > maxHeight) {
|
||||
const ratio = Math.min(maxWidth / width, maxHeight / height)
|
||||
width *= ratio
|
||||
height *= ratio
|
||||
} else {
|
||||
// Keep original dimensions for most images
|
||||
resolve(file)
|
||||
return
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
// Draw optimized image
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Convert to blob with HIGH quality
|
||||
canvas.toBlob((blob) => {
|
||||
const optimizedFile = new File([blob], file.name, {
|
||||
type: file.type,
|
||||
lastModified: Date.now()
|
||||
})
|
||||
resolve(optimizedFile)
|
||||
}, file.type, 0.95) // 95% quality for minimal loss
|
||||
}
|
||||
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function removeMedia(index) {
|
||||
const media = newAlbum.value.media[index]
|
||||
|
||||
// Clean up preview URLs
|
||||
if (media.preview && media.preview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(media.preview)
|
||||
}
|
||||
|
||||
// Clean up original file preview if different
|
||||
if (media.originalFile && media.originalFile !== media.file) {
|
||||
// Note: originalFile doesn't have preview, but we could add cleanup here if needed
|
||||
}
|
||||
|
||||
newAlbum.value.media.splice(index, 1)
|
||||
}
|
||||
|
||||
async function createAlbum() {
|
||||
if (!newAlbum.value.title || newAlbum.value.media.length === 0) return
|
||||
|
||||
// Check max media per album limit
|
||||
if (uploadLimits.value.max_media_per_album && newAlbum.value.media.length > uploadLimits.value.max_media_per_album) {
|
||||
toast.error(`Nombre maximum de médias par album dépassé : ${uploadLimits.value.max_media_per_album}`)
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
isUploading.value = true
|
||||
uploadProgress.value = 0
|
||||
currentFileIndex.value = 0
|
||||
totalFiles.value = newAlbum.value.media.length
|
||||
uploadErrors.value = []
|
||||
uploadSuccess.value = []
|
||||
|
||||
try {
|
||||
// First create the album
|
||||
const albumData = {
|
||||
title: newAlbum.value.title,
|
||||
description: newAlbum.value.description,
|
||||
event_id: newAlbum.value.event_id || null
|
||||
}
|
||||
|
||||
uploadStatus.value = 'Création de l\'album...'
|
||||
const albumResponse = await axios.post('/api/albums', albumData)
|
||||
const album = albumResponse.data
|
||||
|
||||
// Upload media files in batches for better performance
|
||||
const batchSize = 5 // Upload 5 files at a time
|
||||
const totalBatches = Math.ceil(newAlbum.value.media.length / batchSize)
|
||||
|
||||
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
|
||||
const startIndex = batchIndex * batchSize
|
||||
const endIndex = Math.min(startIndex + batchSize, newAlbum.value.media.length)
|
||||
const batch = newAlbum.value.media.slice(startIndex, endIndex)
|
||||
|
||||
uploadStatus.value = `Upload du lot ${batchIndex + 1}/${totalBatches}...`
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
batch.forEach((media, index) => {
|
||||
formData.append('files', media.file)
|
||||
if (media.caption) {
|
||||
formData.append('captions', media.caption)
|
||||
}
|
||||
})
|
||||
|
||||
await axios.post(`/api/albums/${album.id}/media`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
// Update progress for this batch
|
||||
const batchProgress = (progressEvent.loaded / progressEvent.total) * 100
|
||||
const overallProgress = ((batchIndex * batchSize + batch.length) / newAlbum.value.media.length) * 100
|
||||
uploadProgress.value = Math.min(overallProgress, 100)
|
||||
}
|
||||
})
|
||||
|
||||
// Mark batch as successful
|
||||
batch.forEach((media, index) => {
|
||||
const globalIndex = startIndex + index
|
||||
currentFileIndex.value = globalIndex + 1
|
||||
uploadSuccess.value.push({
|
||||
file: media.name,
|
||||
type: media.type
|
||||
})
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error uploading batch ${batchIndex + 1}:`, error)
|
||||
|
||||
// Mark batch as failed
|
||||
batch.forEach((media, index) => {
|
||||
const globalIndex = startIndex + index
|
||||
currentFileIndex.value = globalIndex + 1
|
||||
uploadErrors.value.push({
|
||||
file: media.name,
|
||||
message: error.response?.data?.detail || 'Erreur lors de l\'upload'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Small delay between batches to avoid overwhelming the server
|
||||
if (batchIndex < totalBatches - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
uploadStatus.value = 'Finalisation...'
|
||||
uploadProgress.value = 100
|
||||
|
||||
// Refresh albums list
|
||||
await fetchAlbums()
|
||||
|
||||
// Show results
|
||||
if (uploadErrors.value.length === 0) {
|
||||
toast.success(`Album créé avec succès ! ${uploadSuccess.value.length} fichier(s) uploadé(s)`)
|
||||
} else if (uploadSuccess.value.length > 0) {
|
||||
toast.warning(`Album créé avec ${uploadSuccess.value.length} fichier(s) uploadé(s) et ${uploadErrors.value.length} erreur(s)`)
|
||||
} else {
|
||||
toast.error('Erreur lors de l\'upload de tous les fichiers')
|
||||
}
|
||||
|
||||
showCreateModal.value = false
|
||||
resetForm()
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating album:', error)
|
||||
toast.error('Erreur lors de la création de l\'album')
|
||||
} finally {
|
||||
creating.value = false
|
||||
isUploading.value = false
|
||||
uploadProgress.value = 0
|
||||
currentFileIndex.value = 0
|
||||
totalFiles.value = 0
|
||||
uploadStatus.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
// Clean up media previews
|
||||
newAlbum.value.media.forEach(media => {
|
||||
if (media.preview && media.preview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(media.preview)
|
||||
}
|
||||
})
|
||||
|
||||
// Reset form data
|
||||
newAlbum.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
event_id: '',
|
||||
media: []
|
||||
}
|
||||
|
||||
// Reset upload states
|
||||
uploadProgress.value = 0
|
||||
isUploading.value = false
|
||||
uploadStatus.value = ''
|
||||
currentFileIndex.value = 0
|
||||
totalFiles.value = 0
|
||||
uploadErrors.value = []
|
||||
uploadSuccess.value = []
|
||||
}
|
||||
|
||||
async function fetchAlbums() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get(`/api/albums?limit=12&offset=${offset.value}`)
|
||||
if (offset.value === 0) {
|
||||
albums.value = response.data
|
||||
} else {
|
||||
albums.value.push(...response.data)
|
||||
}
|
||||
|
||||
hasMoreAlbums.value = response.data.length === 12
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement des albums')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function fetchEvents() {
|
||||
try {
|
||||
const response = await axios.get('/api/events')
|
||||
events.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreAlbums() {
|
||||
offset.value += 12
|
||||
await fetchAlbums()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAlbums()
|
||||
fetchEvents()
|
||||
fetchUsers()
|
||||
fetchUploadLimits()
|
||||
})
|
||||
</script>
|
||||
510
frontend/src/views/EventDetail.vue
Normal file
510
frontend/src/views/EventDetail.vue
Normal file
@@ -0,0 +1,510 @@
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">Chargement de l'événement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Event not found -->
|
||||
<div v-else-if="!event" class="text-center py-12">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-4">Événement non trouvé</h1>
|
||||
<p class="text-gray-600 mb-6">L'événement que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||
<router-link to="/events" class="btn-primary">
|
||||
Retour aux événements
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Event details -->
|
||||
<div v-else>
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<router-link to="/events" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Retour aux événements
|
||||
</router-link>
|
||||
|
||||
<div v-if="canEdit" class="flex space-x-2">
|
||||
<button
|
||||
@click="showEditModal = true"
|
||||
class="btn-secondary"
|
||||
>
|
||||
<Edit class="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
@click="deleteEvent"
|
||||
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300"
|
||||
>
|
||||
<Trash2 class="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-6">
|
||||
<!-- Cover Image -->
|
||||
<div class="w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center">
|
||||
<Calendar v-if="!event.cover_image" class="w-16 h-16 text-white" />
|
||||
<img
|
||||
v-else
|
||||
:src="getMediaUrl(event.cover_image)"
|
||||
:alt="event.title"
|
||||
class="w-full h-full object-cover rounded-xl"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Event Info -->
|
||||
<div class="flex-1">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">{{ event.title }}</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center text-gray-600">
|
||||
<Clock class="w-5 h-5 mr-3" />
|
||||
<span>{{ formatDate(event.date) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="event.end_date" class="flex items-center text-gray-600">
|
||||
<Clock class="w-5 h-5 mr-3" />
|
||||
<span>Fin : {{ formatDate(event.end_date) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="event.location" class="flex items-center text-gray-600">
|
||||
<MapPin class="w-5 h-5 mr-3" />
|
||||
<span>{{ event.location }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-gray-600">
|
||||
<User class="w-5 h-5 mr-3" />
|
||||
<span>Organisé par {{ event.creator_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participation Stats -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-3">Participation</h3>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-success-600">{{ event.present_count || 0 }}</div>
|
||||
<div class="text-gray-600">Présents</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-warning-600">{{ event.maybe_count || 0 }}</div>
|
||||
<div class="text-gray-600">Peut-être</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-accent-600">{{ event.absent_count || 0 }}</div>
|
||||
<div class="text-gray-600">Absents</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-secondary-600">{{ event.pending_count || 0 }}</div>
|
||||
<div class="text-gray-600">En attente</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div v-if="event.description" class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Description</h2>
|
||||
<p class="text-gray-700 whitespace-pre-wrap">{{ event.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Map Section -->
|
||||
<div v-if="event.latitude && event.longitude" class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Localisation</h2>
|
||||
<div class="bg-gray-100 rounded-lg p-6 h-48 flex items-center justify-center">
|
||||
<div class="text-center text-gray-600">
|
||||
<MapPin class="w-12 h-12 mx-auto mb-2 text-primary-600" />
|
||||
<p class="text-sm">Carte interactive</p>
|
||||
<p class="text-xs mt-1">{{ event.latitude }}, {{ event.longitude }}</p>
|
||||
<a
|
||||
:href="`https://www.openstreetmap.org/?mlat=${event.latitude}&mlon=${event.longitude}&zoom=15`"
|
||||
target="_blank"
|
||||
class="text-primary-600 hover:underline text-sm mt-2 inline-block"
|
||||
>
|
||||
Voir sur OpenStreetMap
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My Participation -->
|
||||
<div class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Ma participation</h2>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="updateParticipation('present')"
|
||||
class="flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="getParticipationClass('present')"
|
||||
>
|
||||
✓ Présent
|
||||
</button>
|
||||
<button
|
||||
@click="updateParticipation('maybe')"
|
||||
class="flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="getParticipationClass('maybe')"
|
||||
>
|
||||
? Peut-être
|
||||
</button>
|
||||
<button
|
||||
@click="updateParticipation('absent')"
|
||||
class="flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="getParticipationClass('absent')"
|
||||
>
|
||||
✗ Absent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participants -->
|
||||
<div class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Participants</h2>
|
||||
|
||||
<div v-if="event.participations.length === 0" class="text-center py-8 text-gray-500">
|
||||
Aucun participant pour le moment
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="participation in event.participations"
|
||||
:key="participation.user_id"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<img
|
||||
v-if="participation.avatar_url"
|
||||
:src="getMediaUrl(participation.avatar_url)"
|
||||
:alt="participation.full_name"
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User class="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{{ participation.full_name }}</p>
|
||||
<p class="text-sm text-gray-600">@{{ participation.username }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="px-3 py-1 rounded-full text-xs font-medium"
|
||||
:class="getStatusClass(participation.status)"
|
||||
>
|
||||
{{ getStatusText(participation.status) }}
|
||||
</span>
|
||||
<span v-if="participation.response_date" class="text-xs text-gray-500">
|
||||
{{ formatRelativeDate(participation.response_date) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Albums -->
|
||||
<div class="card p-6 mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Albums liés</h2>
|
||||
<router-link
|
||||
:to="`/albums?event_id=${event.id}`"
|
||||
class="text-primary-600 hover:text-primary-700 text-sm"
|
||||
>
|
||||
Voir tous les albums →
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="relatedAlbums.length === 0" class="text-center py-8 text-gray-500">
|
||||
Aucun album lié à cet événement
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<router-link
|
||||
v-for="album in relatedAlbums"
|
||||
:key="album.id"
|
||||
:to="`/albums/${album.id}`"
|
||||
class="block hover:shadow-lg transition-all duration-300 rounded-xl overflow-hidden bg-white border border-gray-200 hover:border-primary-300 hover:scale-105 transform"
|
||||
>
|
||||
<div class="aspect-[4/3] bg-gray-100 relative overflow-hidden">
|
||||
<img
|
||||
v-if="album.cover_image"
|
||||
:src="getMediaUrl(album.cover_image)"
|
||||
:alt="album.title"
|
||||
class="w-full h-full object-cover hover:scale-110 transition-transform duration-300"
|
||||
>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Image class="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Media Count Badge -->
|
||||
<div class="absolute top-3 right-3 bg-black bg-opacity-80 text-white text-sm px-3 py-1.5 rounded-full font-medium">
|
||||
{{ album.media_count }} média{{ album.media_count > 1 ? 's' : '' }}
|
||||
</div>
|
||||
|
||||
<!-- Hover Overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-0 hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center">
|
||||
<div class="opacity-0 hover:opacity-100 transition-opacity duration-300">
|
||||
<div class="bg-white bg-opacity-90 text-gray-900 px-4 py-2 rounded-full font-medium">
|
||||
Voir l'album →
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-gray-900 text-lg mb-2 line-clamp-2">{{ album.title }}</h3>
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-600 mb-2">
|
||||
<User class="w-4 h-4" />
|
||||
<span>{{ album.creator_name }}</span>
|
||||
</div>
|
||||
<p v-if="album.description" class="text-sm text-gray-500 line-clamp-2">{{ album.description }}</p>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit 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="showEditModal"
|
||||
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-md w-full p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Modifier l'événement</h2>
|
||||
|
||||
<form @submit.prevent="updateEvent" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Titre</label>
|
||||
<input
|
||||
v-model="editForm.title"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="3"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Date et heure</label>
|
||||
<input
|
||||
v-model="editForm.date"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Lieu</label>
|
||||
<input
|
||||
v-model="editForm.location"
|
||||
type="text"
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showEditModal = false"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="updating"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
{{ updating ? 'Mise à jour...' : 'Mettre à jour' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Clock,
|
||||
MapPin,
|
||||
User,
|
||||
Edit,
|
||||
Trash2,
|
||||
Image
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const event = ref(null)
|
||||
const relatedAlbums = ref([])
|
||||
const loading = ref(true)
|
||||
const updating = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
|
||||
const editForm = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
location: ''
|
||||
})
|
||||
|
||||
const canEdit = computed(() =>
|
||||
event.value && (event.value.creator_id === authStore.user?.id || authStore.user?.is_admin)
|
||||
)
|
||||
|
||||
function formatDate(date) {
|
||||
return format(new Date(date), 'EEEE d MMMM yyyy à HH:mm', { locale: fr })
|
||||
}
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function getParticipationClass(status) {
|
||||
const participation = event.value?.participations.find(p => p.user_id === authStore.user?.id)
|
||||
const isSelected = participation?.status === status
|
||||
|
||||
if (status === 'present') {
|
||||
return isSelected
|
||||
? 'bg-success-100 text-success-700 border-success-300'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-success-50'
|
||||
} else if (status === 'maybe') {
|
||||
return isSelected
|
||||
? 'bg-warning-100 text-warning-700 border-warning-300'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-warning-50'
|
||||
} else {
|
||||
return isSelected
|
||||
? 'bg-accent-100 text-accent-700 border-accent-300'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-accent-50'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
switch (status) {
|
||||
case 'present':
|
||||
return 'bg-success-100 text-success-700'
|
||||
case 'maybe':
|
||||
return 'bg-warning-100 text-warning-700'
|
||||
case 'absent':
|
||||
return 'bg-accent-100 text-accent-700'
|
||||
default:
|
||||
return 'bg-secondary-100 text-secondary-700'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(status) {
|
||||
switch (status) {
|
||||
case 'present':
|
||||
return 'Présent'
|
||||
case 'maybe':
|
||||
return 'Peut-être'
|
||||
case 'absent':
|
||||
return 'Absent'
|
||||
default:
|
||||
return 'En attente'
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEvent() {
|
||||
try {
|
||||
const response = await axios.get(`/api/events/${route.params.id}`)
|
||||
event.value = response.data
|
||||
|
||||
// Fetch related albums
|
||||
const albumsResponse = await axios.get(`/api/albums?event_id=${event.value.id}`)
|
||||
relatedAlbums.value = albumsResponse.data
|
||||
|
||||
// Initialize edit form
|
||||
editForm.value = {
|
||||
title: event.value.title,
|
||||
description: event.value.description || '',
|
||||
date: format(new Date(event.value.date), "yyyy-MM-dd'T'HH:mm", { locale: fr }),
|
||||
location: event.value.location || ''
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement de l\'événement')
|
||||
console.error('Error fetching event:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateParticipation(status) {
|
||||
try {
|
||||
const response = await axios.put(`/api/events/${event.value.id}/participation`, { status })
|
||||
event.value = response.data
|
||||
toast.success('Participation mise à jour')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour')
|
||||
}
|
||||
}
|
||||
|
||||
async function updateEvent() {
|
||||
updating.value = true
|
||||
try {
|
||||
const response = await axios.put(`/api/events/${event.value.id}`, {
|
||||
...editForm.value,
|
||||
date: new Date(editForm.value.date).toISOString()
|
||||
})
|
||||
event.value = response.data
|
||||
showEditModal.value = false
|
||||
toast.success('Événement mis à jour')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour')
|
||||
}
|
||||
updating.value = false
|
||||
}
|
||||
|
||||
async function deleteEvent() {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')) return
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/events/${event.value.id}`)
|
||||
toast.success('Événement supprimé')
|
||||
router.push('/events')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchEvent()
|
||||
})
|
||||
</script>
|
||||
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>
|
||||
304
frontend/src/views/Home.vue
Normal file
304
frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Welcome Section -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">
|
||||
Salut {{ user?.full_name }} ! 👋
|
||||
</h1>
|
||||
<p class="text-gray-600">Voici ce qui se passe dans le groupe</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Prochain événement</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ nextEvent?.title || 'Aucun' }}</p>
|
||||
<p v-if="nextEvent" class="text-sm text-gray-500 mt-1">
|
||||
{{ formatDate(nextEvent.date) }}
|
||||
</p>
|
||||
</div>
|
||||
<Calendar class="w-8 h-8 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Taux de présence</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ Math.round(user?.attendance_rate || 0) }}%</p>
|
||||
</div>
|
||||
<TrendingUp class="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Nouveaux posts</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ recentPosts }}</p>
|
||||
<p class="text-sm text-gray-500 mt-1">Cette semaine</p>
|
||||
</div>
|
||||
<MessageSquare class="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Membres actifs</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ activeMembers }}</p>
|
||||
</div>
|
||||
<Users class="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Recent Posts -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="card">
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Publications récentes</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div v-if="posts.length === 0" class="p-6 text-center text-gray-500">
|
||||
Aucune publication récente
|
||||
</div>
|
||||
<div
|
||||
v-for="post in posts"
|
||||
:key="post.id"
|
||||
class="p-6 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<img
|
||||
v-if="post.author_avatar"
|
||||
:src="getMediaUrl(post.author_avatar)"
|
||||
:alt="post.author_name"
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User class="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="font-medium text-gray-900">{{ post.author_name }}</p>
|
||||
<span class="text-xs text-gray-500">{{ formatRelativeDate(post.created_at) }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-gray-700">
|
||||
<Mentions :content="post.content" :mentions="post.mentioned_users || []" />
|
||||
</div>
|
||||
|
||||
<!-- Post Image -->
|
||||
<img
|
||||
v-if="post.image_url"
|
||||
:src="getMediaUrl(post.image_url)"
|
||||
:alt="post.content"
|
||||
class="mt-3 rounded-lg max-w-full max-h-48 object-cover"
|
||||
>
|
||||
|
||||
<!-- Post Actions -->
|
||||
<div class="flex items-center space-x-4 mt-3 text-sm text-gray-500">
|
||||
<button
|
||||
@click="togglePostLike(post)"
|
||||
class="flex items-center space-x-1 hover:text-primary-600 transition-colors"
|
||||
:class="{ 'text-primary-600': post.is_liked }"
|
||||
>
|
||||
<Heart :class="post.is_liked ? 'fill-current' : ''" class="w-4 h-4" />
|
||||
<span>{{ post.likes_count || 0 }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="toggleComments(post.id)"
|
||||
class="flex items-center space-x-1 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
<MessageCircle class="w-4 h-4" />
|
||||
<span>{{ post.comments_count || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
to="/posts"
|
||||
class="block p-4 text-center text-primary-600 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
Voir toutes les publications →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Events -->
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Événements à venir</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div v-if="upcomingEvents.length === 0" class="p-6 text-center text-gray-500">
|
||||
Aucun événement prévu
|
||||
</div>
|
||||
<router-link
|
||||
v-for="event in upcomingEvents"
|
||||
:key="event.id"
|
||||
:to="`/events/${event.id}`"
|
||||
class="block p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<h3 class="font-medium text-gray-900">{{ event.title }}</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">{{ formatDate(event.date) }}</p>
|
||||
<p v-if="event.location" class="text-sm text-gray-500 mt-1">
|
||||
📍 {{ event.location }}
|
||||
</p>
|
||||
<div class="mt-3 flex items-center space-x-4 text-xs">
|
||||
<span class="text-green-600">
|
||||
✓ {{ event.present_count }} présents
|
||||
</span>
|
||||
<span class="text-yellow-600">
|
||||
? {{ event.maybe_count }} peut-être
|
||||
</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<router-link
|
||||
to="/events"
|
||||
class="block p-4 text-center text-primary-600 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
Voir tous les événements →
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Recent Vlogs -->
|
||||
<div class="card mt-6">
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Derniers vlogs</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div v-if="recentVlogs.length === 0" class="p-6 text-center text-gray-500">
|
||||
Aucun vlog récent
|
||||
</div>
|
||||
<router-link
|
||||
v-for="vlog in recentVlogs"
|
||||
:key="vlog.id"
|
||||
:to="`/vlogs/${vlog.id}`"
|
||||
class="block p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div class="aspect-video bg-gray-100 rounded-lg mb-3 relative overflow-hidden">
|
||||
<img
|
||||
v-if="vlog.thumbnail_url"
|
||||
:src="getMediaUrl(vlog.thumbnail_url)"
|
||||
:alt="vlog.title"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Film class="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
||||
<Play class="w-12 h-12 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="font-medium text-gray-900">{{ vlog.title }}</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Par {{ vlog.author_name }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ vlog.views_count }} vues</p>
|
||||
</router-link>
|
||||
</div>
|
||||
<router-link
|
||||
to="/vlogs"
|
||||
class="block p-4 text-center text-primary-600 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
Voir tous les vlogs →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
MessageSquare,
|
||||
Users,
|
||||
User,
|
||||
Film,
|
||||
Play,
|
||||
Heart,
|
||||
MessageCircle
|
||||
} from 'lucide-vue-next'
|
||||
import Mentions from '@/components/Mentions.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const posts = ref([])
|
||||
const upcomingEvents = ref([])
|
||||
const recentVlogs = ref([])
|
||||
const stats = ref({})
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const nextEvent = computed(() => upcomingEvents.value[0])
|
||||
const recentPosts = computed(() => stats.value.recent_posts || 0)
|
||||
const activeMembers = computed(() => stats.value.active_members || 0)
|
||||
|
||||
function formatDate(date) {
|
||||
return format(new Date(date), 'EEEE d MMMM à HH:mm', { locale: fr })
|
||||
}
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
async function fetchDashboardData() {
|
||||
try {
|
||||
// Fetch recent posts
|
||||
const postsResponse = await axios.get('/api/posts?limit=5')
|
||||
posts.value = postsResponse.data
|
||||
|
||||
// Fetch upcoming events
|
||||
const eventsResponse = await axios.get('/api/events?upcoming=true')
|
||||
upcomingEvents.value = eventsResponse.data.slice(0, 3)
|
||||
|
||||
// Fetch recent vlogs
|
||||
const vlogsResponse = await axios.get('/api/vlogs?limit=2')
|
||||
recentVlogs.value = vlogsResponse.data
|
||||
|
||||
// Fetch stats
|
||||
const statsResponse = await axios.get('/api/stats/overview')
|
||||
stats.value = statsResponse.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePostLike(post) {
|
||||
try {
|
||||
const response = await axios.post(`/api/posts/${post.id}/like`)
|
||||
post.is_liked = response.data.is_liked
|
||||
post.likes_count = response.data.likes_count
|
||||
} catch (error) {
|
||||
console.error('Error toggling like:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleComments(postId) {
|
||||
const post = posts.value.find(p => p.id === postId)
|
||||
if (post) {
|
||||
post.showComments = !post.showComments
|
||||
if (post.showComments && !post.comments) {
|
||||
post.comments = []
|
||||
post.newComment = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDashboardData()
|
||||
})
|
||||
</script>
|
||||
177
frontend/src/views/Information.vue
Normal file
177
frontend/src/views/Information.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">Informations</h1>
|
||||
<p class="text-lg text-gray-600">Restez informés des dernières nouvelles de LeDiscord</p>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<div class="flex flex-wrap gap-2 justify-center mb-8">
|
||||
<button
|
||||
@click="selectedCategory = null"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-full text-sm font-medium transition-colors',
|
||||
selectedCategory === null
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
]"
|
||||
>
|
||||
Toutes
|
||||
</button>
|
||||
<button
|
||||
v-for="category in availableCategories"
|
||||
:key="category"
|
||||
@click="selectedCategory = category"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-full text-sm font-medium transition-colors',
|
||||
selectedCategory === category
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
]"
|
||||
>
|
||||
{{ getCategoryLabel(category) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p class="text-gray-600">Chargement des informations...</p>
|
||||
</div>
|
||||
|
||||
<!-- No Information -->
|
||||
<div v-else-if="filteredInformations.length === 0" class="text-center py-12">
|
||||
<div class="text-gray-400 mb-4">
|
||||
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucune information</h3>
|
||||
<p class="text-gray-600">
|
||||
{{ selectedCategory ? `Aucune information dans la catégorie "${getCategoryLabel(selectedCategory)}"` : 'Aucune information disponible pour le moment' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Information List -->
|
||||
<div v-else class="space-y-6">
|
||||
<div
|
||||
v-for="info in filteredInformations"
|
||||
:key="info.id"
|
||||
class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 rounded-full text-xs font-medium',
|
||||
getCategoryBadgeClass(info.category)
|
||||
]"
|
||||
>
|
||||
{{ getCategoryLabel(info.category) }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ formatDate(info.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">{{ info.title }}</h2>
|
||||
</div>
|
||||
<div v-if="info.priority > 0" class="flex items-center gap-1 text-amber-600">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Important</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="prose prose-gray max-w-none">
|
||||
<div class="whitespace-pre-wrap text-gray-700 leading-relaxed">{{ info.content }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm text-gray-500">
|
||||
<span>Mis à jour le {{ formatDate(info.updated_at) }}</span>
|
||||
<span v-if="!info.is_published" class="text-amber-600 font-medium">Brouillon</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import axios from '@/utils/axios'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// State
|
||||
const informations = ref([])
|
||||
const loading = ref(true)
|
||||
const selectedCategory = ref(null)
|
||||
|
||||
// Computed
|
||||
const availableCategories = computed(() => {
|
||||
const categories = [...new Set(informations.value.map(info => info.category))]
|
||||
return categories.sort()
|
||||
})
|
||||
|
||||
const filteredInformations = computed(() => {
|
||||
if (!selectedCategory.value) {
|
||||
return informations.value
|
||||
}
|
||||
return informations.value.filter(info => info.category === selectedCategory.value)
|
||||
})
|
||||
|
||||
// Methods
|
||||
function getCategoryLabel(category) {
|
||||
const labels = {
|
||||
'general': 'Général',
|
||||
'release': 'Nouvelle version',
|
||||
'upcoming': 'À venir',
|
||||
'maintenance': 'Maintenance',
|
||||
'feature': 'Nouvelle fonctionnalité',
|
||||
'bugfix': 'Correction de bug'
|
||||
}
|
||||
return labels[category] || category
|
||||
}
|
||||
|
||||
function getCategoryBadgeClass(category) {
|
||||
const classes = {
|
||||
'general': 'bg-gray-100 text-gray-800',
|
||||
'release': 'bg-green-100 text-green-800',
|
||||
'upcoming': 'bg-blue-100 text-blue-800',
|
||||
'maintenance': 'bg-yellow-100 text-yellow-800',
|
||||
'feature': 'bg-purple-100 text-purple-800',
|
||||
'bugfix': 'bg-red-100 text-red-800'
|
||||
}
|
||||
return classes[category] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
async function fetchInformations() {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await axios.get('/api/information/public')
|
||||
informations.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching informations:', error)
|
||||
toast.error('Erreur lors du chargement des informations')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchInformations()
|
||||
})
|
||||
</script>
|
||||
89
frontend/src/views/Login.vue
Normal file
89
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Connexion</h2>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
class="input"
|
||||
placeholder="ton.email@example.com"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="label">Mot de passe</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full btn-primary"
|
||||
>
|
||||
{{ loading ? 'Connexion...' : 'Se connecter' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p v-if="registrationEnabled" class="text-sm text-gray-600">
|
||||
Pas encore de compte ?
|
||||
<router-link to="/register" class="font-medium text-primary-600 hover:text-primary-500">
|
||||
S'inscrire
|
||||
</router-link>
|
||||
</p>
|
||||
<p v-else class="text-sm text-gray-500">
|
||||
Les nouvelles inscriptions sont temporairement désactivées
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import axios from '@/utils/axios'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = ref({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const registrationEnabled = ref(true)
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
await authStore.login(form.value.email, form.value.password)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function checkRegistrationStatus() {
|
||||
try {
|
||||
const response = await axios.get('/api/settings/public/registration-status')
|
||||
const status = response.data
|
||||
registrationEnabled.value = status.can_register
|
||||
} catch (error) {
|
||||
console.error('Error checking registration status:', error)
|
||||
// En cas d'erreur, on active l'inscription par défaut
|
||||
registrationEnabled.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkRegistrationStatus()
|
||||
})
|
||||
</script>
|
||||
533
frontend/src/views/MyTickets.vue
Normal file
533
frontend/src/views/MyTickets.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">Mes tickets</h1>
|
||||
<p class="text-lg text-gray-600">Suivez vos demandes et signalements</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ ticketStats.open }}</div>
|
||||
<div class="text-sm text-gray-600">Ouverts</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-2xl font-bold text-yellow-600">{{ ticketStats.in_progress }}</div>
|
||||
<div class="text-sm text-gray-600">En cours</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-2xl font-bold text-green-600">{{ ticketStats.resolved }}</div>
|
||||
<div class="text-sm text-gray-600">Résolus</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-2xl font-bold text-gray-600">{{ ticketStats.total }}</div>
|
||||
<div class="text-sm text-gray-600">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-2 justify-center mb-6">
|
||||
<button
|
||||
@click="selectedStatus = null"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-full text-sm font-medium transition-colors',
|
||||
selectedStatus === null
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
]"
|
||||
>
|
||||
Tous
|
||||
</button>
|
||||
<button
|
||||
v-for="status in availableStatuses"
|
||||
:key="status"
|
||||
@click="selectedStatus = status"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-full text-sm font-medium transition-colors',
|
||||
selectedStatus === status
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
]"
|
||||
>
|
||||
{{ getStatusLabel(status) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p class="text-gray-600">Chargement de vos tickets...</p>
|
||||
</div>
|
||||
|
||||
<!-- No Tickets -->
|
||||
<div v-else-if="filteredTickets.length === 0" class="text-center py-12">
|
||||
<div class="text-gray-400 mb-4">
|
||||
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun ticket</h3>
|
||||
<p class="text-gray-600">
|
||||
{{ selectedStatus ? `Aucun ticket avec le statut "${getStatusLabel(selectedStatus)}"` : 'Vous n\'avez pas encore créé de ticket' }}
|
||||
</p>
|
||||
<button
|
||||
@click="showTicketModal = true"
|
||||
class="mt-4 btn-primary"
|
||||
>
|
||||
Créer mon premier ticket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tickets List -->
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 rounded-full text-xs font-medium',
|
||||
getTypeBadgeClass(ticket.ticket_type)
|
||||
]"
|
||||
>
|
||||
{{ getTypeLabel(ticket.ticket_type) }}
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 rounded-full text-xs font-medium',
|
||||
getStatusBadgeClass(ticket.status)
|
||||
]"
|
||||
>
|
||||
{{ getStatusLabel(ticket.status) }}
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 rounded-full text-xs font-medium',
|
||||
getPriorityBadgeClass(ticket.priority)
|
||||
]"
|
||||
>
|
||||
{{ getPriorityLabel(ticket.priority) }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ formatDate(ticket.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">{{ ticket.title }}</h2>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="editTicket(ticket)"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
@click="deleteTicket(ticket.id)"
|
||||
class="text-red-600 hover:text-red-800 text-sm font-medium"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="prose prose-gray max-w-none mb-4">
|
||||
<div class="whitespace-pre-wrap text-gray-700 leading-relaxed">{{ ticket.description }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot -->
|
||||
<div v-if="ticket.screenshot_path" class="mb-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Screenshot :</h4>
|
||||
<img
|
||||
:src="getMediaUrl(ticket.screenshot_path)"
|
||||
:alt="ticket.title"
|
||||
class="max-w-md rounded-lg border border-gray-200 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
@click="viewScreenshot(ticket.screenshot_path)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Admin Notes -->
|
||||
<div v-if="ticket.admin_notes" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 class="text-sm font-medium text-blue-800 mb-2">Réponse de l'équipe :</h4>
|
||||
<div class="text-sm text-blue-700 whitespace-pre-wrap">{{ ticket.admin_notes }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm text-gray-500">
|
||||
<span>Mis à jour le {{ formatDate(ticket.updated_at) }}</span>
|
||||
<span v-if="ticket.assigned_admin_name" class="text-blue-600">
|
||||
Assigné à {{ ticket.assigned_admin_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket 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="showTicketModal"
|
||||
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">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold">
|
||||
{{ editingTicket ? 'Modifier le ticket' : 'Nouveau ticket' }}
|
||||
</h2>
|
||||
<button
|
||||
@click="showTicketModal = false"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveTicket" class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="label">Titre *</label>
|
||||
<input
|
||||
v-model="ticketForm.title"
|
||||
type="text"
|
||||
class="input"
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="Titre de votre ticket"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label class="label">Type</label>
|
||||
<select v-model="ticketForm.ticket_type" class="input">
|
||||
<option value="bug">🐛 Bug</option>
|
||||
<option value="feature_request">💡 Demande de fonctionnalité</option>
|
||||
<option value="improvement">✨ Amélioration</option>
|
||||
<option value="support">❓ Support</option>
|
||||
<option value="other">📝 Autre</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<label class="label">Priorité</label>
|
||||
<select v-model="ticketForm.priority" class="input">
|
||||
<option value="low">🟢 Faible</option>
|
||||
<option value="medium">🟡 Moyenne</option>
|
||||
<option value="high">🟠 Élevée</option>
|
||||
<option value="urgent">🔴 Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="label">Description *</label>
|
||||
<textarea
|
||||
v-model="ticketForm.description"
|
||||
class="input resize-none"
|
||||
rows="6"
|
||||
required
|
||||
placeholder="Décrivez votre problème ou votre demande en détail..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot -->
|
||||
<div>
|
||||
<label class="label">Screenshot (optionnel)</label>
|
||||
<input
|
||||
ref="screenshotInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="input"
|
||||
@change="handleScreenshotChange"
|
||||
>
|
||||
<div class="mt-1 text-sm text-gray-600">
|
||||
Formats acceptés : JPG, PNG, GIF, WebP (max 5MB)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showTicketModal = false"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 btn-primary"
|
||||
:disabled="savingTicket"
|
||||
>
|
||||
<Save class="w-4 h-4 mr-2" />
|
||||
{{ savingTicket ? 'Sauvegarde...' : 'Sauvegarder' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Screenshot Viewer 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="showScreenshotModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4"
|
||||
@click="showScreenshotModal = false"
|
||||
>
|
||||
<div class="max-w-4xl max-h-[90vh] overflow-auto">
|
||||
<img
|
||||
:src="selectedScreenshot"
|
||||
alt="Screenshot"
|
||||
class="max-w-full h-auto rounded-lg"
|
||||
@click.stop
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { Save } from 'lucide-vue-next'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// State
|
||||
const tickets = ref([])
|
||||
const loading = ref(true)
|
||||
const selectedStatus = ref(null)
|
||||
const showTicketModal = ref(false)
|
||||
const editingTicket = ref(null)
|
||||
const savingTicket = ref(false)
|
||||
const showScreenshotModal = ref(false)
|
||||
const selectedScreenshot = ref('')
|
||||
const screenshotInput = ref(null)
|
||||
|
||||
const ticketForm = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
ticket_type: 'other',
|
||||
priority: 'medium'
|
||||
})
|
||||
|
||||
// Computed
|
||||
const availableStatuses = computed(() => {
|
||||
const statuses = [...new Set(tickets.value.map(ticket => ticket.status))]
|
||||
return statuses.sort()
|
||||
})
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
if (!selectedStatus.value) {
|
||||
return tickets.value
|
||||
}
|
||||
return tickets.value.filter(ticket => ticket.status === selectedStatus.value)
|
||||
})
|
||||
|
||||
const ticketStats = computed(() => {
|
||||
const stats = {
|
||||
open: tickets.value.filter(t => t.status === 'open').length,
|
||||
in_progress: tickets.value.filter(t => t.status === 'in_progress').length,
|
||||
resolved: tickets.value.filter(t => t.status === 'resolved').length,
|
||||
total: tickets.value.length
|
||||
}
|
||||
return stats
|
||||
})
|
||||
|
||||
// Methods
|
||||
function getTypeLabel(type) {
|
||||
const labels = {
|
||||
'bug': 'Bug',
|
||||
'feature_request': 'Fonctionnalité',
|
||||
'improvement': 'Amélioration',
|
||||
'support': 'Support',
|
||||
'other': 'Autre'
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
function getStatusLabel(status) {
|
||||
const labels = {
|
||||
'open': 'Ouvert',
|
||||
'in_progress': 'En cours',
|
||||
'resolved': 'Résolu',
|
||||
'closed': 'Fermé'
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
function getPriorityLabel(priority) {
|
||||
const labels = {
|
||||
'low': 'Faible',
|
||||
'medium': 'Moyenne',
|
||||
'high': 'Élevée',
|
||||
'urgent': 'Urgente'
|
||||
}
|
||||
return labels[priority] || priority
|
||||
}
|
||||
|
||||
function getTypeBadgeClass(type) {
|
||||
const classes = {
|
||||
'bug': 'bg-red-100 text-red-800',
|
||||
'feature_request': 'bg-blue-100 text-blue-800',
|
||||
'improvement': 'bg-green-100 text-green-800',
|
||||
'support': 'bg-yellow-100 text-yellow-800',
|
||||
'other': 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
return classes[type] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status) {
|
||||
const classes = {
|
||||
'open': 'bg-blue-100 text-blue-800',
|
||||
'in_progress': 'bg-yellow-100 text-yellow-800',
|
||||
'resolved': 'bg-green-100 text-green-800',
|
||||
'closed': 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
return classes[status] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
function getPriorityBadgeClass(priority) {
|
||||
const classes = {
|
||||
'low': 'bg-green-100 text-green-800',
|
||||
'medium': 'bg-yellow-100 text-yellow-800',
|
||||
'high': 'bg-orange-100 text-orange-800',
|
||||
'urgent': 'bg-red-100 text-red-800'
|
||||
}
|
||||
return classes[priority] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function resetTicketForm() {
|
||||
ticketForm.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
ticket_type: 'other',
|
||||
priority: 'medium'
|
||||
}
|
||||
editingTicket.value = null
|
||||
if (screenshotInput.value) {
|
||||
screenshotInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function editTicket(ticket) {
|
||||
editingTicket.value = ticket
|
||||
ticketForm.value = {
|
||||
title: ticket.title,
|
||||
description: ticket.description,
|
||||
ticket_type: ticket.ticket_type,
|
||||
priority: ticket.priority
|
||||
}
|
||||
showTicketModal.value = true
|
||||
}
|
||||
|
||||
function handleScreenshotChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (file && file.size > 5 * 1024 * 1024) {
|
||||
toast.error('Le fichier est trop volumineux (max 5MB)')
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function viewScreenshot(screenshotPath) {
|
||||
selectedScreenshot.value = getMediaUrl(screenshotPath)
|
||||
showScreenshotModal.value = true
|
||||
}
|
||||
|
||||
async function saveTicket() {
|
||||
savingTicket.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('title', ticketForm.value.title)
|
||||
formData.append('description', ticketForm.value.description)
|
||||
formData.append('ticket_type', ticketForm.value.ticket_type)
|
||||
formData.append('priority', ticketForm.value.priority)
|
||||
|
||||
if (screenshotInput.value && screenshotInput.value.files[0]) {
|
||||
formData.append('screenshot', screenshotInput.value.files[0])
|
||||
}
|
||||
|
||||
if (editingTicket.value) {
|
||||
// Update existing ticket
|
||||
await axios.put(`/api/tickets/${editingTicket.value.id}`, ticketForm.value)
|
||||
toast.success('Ticket mis à jour')
|
||||
} else {
|
||||
// Create new ticket
|
||||
await axios.post('/api/tickets/', formData)
|
||||
toast.success('Ticket créé avec succès')
|
||||
}
|
||||
|
||||
await fetchTickets()
|
||||
showTicketModal.value = false
|
||||
resetTicketForm()
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la sauvegarde')
|
||||
console.error('Error saving ticket:', error)
|
||||
} finally {
|
||||
savingTicket.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTicket(ticketId) {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.')) return
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/tickets/${ticketId}`)
|
||||
await fetchTickets()
|
||||
toast.success('Ticket supprimé')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression')
|
||||
console.error('Error deleting ticket:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTickets() {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await axios.get('/api/tickets/')
|
||||
tickets.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching tickets:', error)
|
||||
toast.error('Erreur lors du chargement des tickets')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchTickets()
|
||||
})
|
||||
</script>
|
||||
521
frontend/src/views/Posts.vue
Normal file
521
frontend/src/views/Posts.vue
Normal file
@@ -0,0 +1,521 @@
|
||||
<template>
|
||||
<div class="max-w-4xl 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">Publications</h1>
|
||||
<p class="text-gray-600 mt-1">Partagez vos moments avec le groupe</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn-primary"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nouvelle publication
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Post Form -->
|
||||
<div class="card p-6 mb-8">
|
||||
<div class="flex items-start space-x-3">
|
||||
<img
|
||||
v-if="user?.avatar_url"
|
||||
:src="getMediaUrl(user.avatar_url)"
|
||||
:alt="user?.full_name"
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User class="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<MentionInput
|
||||
v-model="newPost.content"
|
||||
:users="users"
|
||||
:rows="3"
|
||||
placeholder="Quoi de neuf ? Mentionnez des amis avec @..."
|
||||
@mentions-changed="handleMentionsChanged"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="showImageUpload = !showImageUpload"
|
||||
class="text-gray-500 hover:text-primary-600 transition-colors"
|
||||
title="Ajouter une image"
|
||||
>
|
||||
<Image class="w-5 h-5" />
|
||||
</button>
|
||||
<span class="text-xs text-gray-500">{{ newPost.content.length }}/5000</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="createPost"
|
||||
:disabled="!newPost.content.trim() || creating"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ creating ? 'Publication...' : 'Publier' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Image upload -->
|
||||
<div v-if="showImageUpload" class="mt-3">
|
||||
<input
|
||||
ref="imageInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleImageChange"
|
||||
>
|
||||
<button
|
||||
@click="$refs.imageInput.click()"
|
||||
class="btn-secondary text-sm"
|
||||
>
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
Sélectionner une image
|
||||
</button>
|
||||
<div v-if="newPost.image_url" class="mt-2 relative inline-block">
|
||||
<img
|
||||
:src="getMediaUrl(newPost.image_url)"
|
||||
:alt="newPost.content"
|
||||
class="max-w-xs max-h-32 rounded-lg object-cover"
|
||||
>
|
||||
<button
|
||||
@click="removeImage"
|
||||
class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts List -->
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
v-for="post in posts"
|
||||
:key="post.id"
|
||||
class="card p-6"
|
||||
>
|
||||
<!-- Post Header -->
|
||||
<div class="flex items-start space-x-3 mb-4">
|
||||
<img
|
||||
v-if="post.author_avatar"
|
||||
:src="getMediaUrl(post.author_avatar)"
|
||||
:alt="post.author_name"
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User class="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<router-link
|
||||
:to="`/profile/${post.author_id}`"
|
||||
class="font-medium text-gray-900 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
{{ post.author_name }}
|
||||
</router-link>
|
||||
<span class="text-sm text-gray-500">{{ formatRelativeDate(post.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Mentions -->
|
||||
<div v-if="post.mentioned_users && post.mentioned_users.length > 0" class="mt-1">
|
||||
<span class="text-xs text-gray-500">Mentionne : </span>
|
||||
<span
|
||||
v-for="mentionedUser in post.mentioned_users"
|
||||
:key="mentionedUser.id"
|
||||
class="text-xs text-primary-600 hover:underline cursor-pointer"
|
||||
>
|
||||
@{{ mentionedUser.username }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete button for author or admin -->
|
||||
<button
|
||||
v-if="canDeletePost(post)"
|
||||
@click="deletePost(post.id)"
|
||||
class="text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="mb-4">
|
||||
<Mentions :content="post.content" :mentions="post.mentioned_users" />
|
||||
|
||||
<!-- Post Image -->
|
||||
<img
|
||||
v-if="post.image_url"
|
||||
:src="getMediaUrl(post.image_url)"
|
||||
:alt="post.content"
|
||||
class="mt-3 rounded-lg max-w-full max-h-96 object-cover"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Post Actions -->
|
||||
<div class="flex items-center space-x-6 text-sm text-gray-500">
|
||||
<button
|
||||
@click="togglePostLike(post)"
|
||||
class="flex items-center space-x-2 hover:text-primary-600 transition-colors"
|
||||
:class="{ 'text-primary-600': post.is_liked }"
|
||||
>
|
||||
<Heart :class="post.is_liked ? 'fill-current' : ''" class="w-4 h-4" />
|
||||
<span>{{ post.likes_count || 0 }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="toggleComments(post.id)"
|
||||
class="flex items-center space-x-2 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
<MessageCircle class="w-4 h-4" />
|
||||
<span>{{ post.comments_count || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div v-if="post.showComments" class="mt-4 pt-4 border-t border-gray-100">
|
||||
<!-- Add Comment -->
|
||||
<div class="flex items-start space-x-3 mb-4">
|
||||
<img
|
||||
v-if="user?.avatar_url"
|
||||
:src="getMediaUrl(user.avatar_url)"
|
||||
:alt="user?.full_name"
|
||||
class="w-8 h-8 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User class="w-4 h-4 text-primary-600" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<MentionInput
|
||||
v-model="post.newComment"
|
||||
:users="users"
|
||||
:rows="2"
|
||||
placeholder="Ajouter un commentaire... (utilisez @username pour mentionner)"
|
||||
@mentions-changed="(mentions) => handleCommentMentionsChanged(post.id, mentions)"
|
||||
/>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<span class="text-xs text-gray-500">{{ (post.newComment || '').length }}/500</span>
|
||||
<button
|
||||
@click="addComment(post)"
|
||||
:disabled="!post.newComment?.trim()"
|
||||
class="btn-primary text-sm px-3 py-1"
|
||||
>
|
||||
Commenter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comments List -->
|
||||
<div v-if="post.comments && post.comments.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="comment in post.comments"
|
||||
:key="comment.id"
|
||||
class="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<img
|
||||
v-if="comment.author_avatar"
|
||||
:src="getMediaUrl(comment.author_avatar)"
|
||||
:alt="comment.author_name"
|
||||
class="w-6 h-6 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User class="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<router-link
|
||||
:to="`/profile/${comment.author_id}`"
|
||||
class="font-medium text-sm text-gray-900 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
{{ comment.author_name }}
|
||||
</router-link>
|
||||
<span class="text-xs text-gray-500">{{ formatRelativeDate(comment.created_at) }}</span>
|
||||
</div>
|
||||
<Mentions :content="comment.content" :mentions="comment.mentioned_users || []" class="text-sm text-gray-700 mt-1" />
|
||||
</div>
|
||||
|
||||
<!-- Delete comment button -->
|
||||
<button
|
||||
v-if="canDeleteComment(comment)"
|
||||
@click="deleteComment(post.id, comment.id)"
|
||||
class="text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load more -->
|
||||
<div v-if="hasMorePosts" class="text-center mt-8">
|
||||
<button
|
||||
@click="loadMorePosts"
|
||||
:disabled="loading"
|
||||
class="btn-secondary"
|
||||
>
|
||||
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="posts.length === 0 && !loading" class="text-center py-12">
|
||||
<MessageSquare class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucune publication</h3>
|
||||
<p class="text-gray-600">Soyez le premier à partager quelque chose !</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
Plus,
|
||||
User,
|
||||
Image,
|
||||
Upload,
|
||||
X,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
Trash2,
|
||||
MessageSquare
|
||||
} from 'lucide-vue-next'
|
||||
import Mentions from '@/components/Mentions.vue'
|
||||
import MentionInput from '@/components/MentionInput.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const posts = ref([])
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const showImageUpload = ref(false)
|
||||
|
||||
const offset = ref(0)
|
||||
const hasMorePosts = ref(true)
|
||||
|
||||
const newPost = ref({
|
||||
content: '',
|
||||
image_url: '',
|
||||
mentioned_user_ids: []
|
||||
})
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function handleMentionsChanged(mentions) {
|
||||
newPost.value.mentioned_user_ids = mentions.map(m => m.id)
|
||||
}
|
||||
|
||||
function handleCommentMentionsChanged(postId, mentions) {
|
||||
const post = posts.value.find(p => p.id === postId)
|
||||
if (post) {
|
||||
post.commentMentions = mentions
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function createPost() {
|
||||
if (!newPost.value.content.trim()) return
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const response = await axios.post('/api/posts', {
|
||||
content: newPost.value.content,
|
||||
image_url: newPost.value.image_url,
|
||||
mentioned_user_ids: newPost.value.mentioned_user_ids
|
||||
})
|
||||
|
||||
// Add new post to the beginning of the list
|
||||
posts.value.unshift(response.data)
|
||||
|
||||
// Reset form
|
||||
newPost.value = {
|
||||
content: '',
|
||||
image_url: '',
|
||||
mentioned_user_ids: []
|
||||
}
|
||||
showImageUpload.value = false
|
||||
|
||||
toast.success('Publication créée avec succès')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la création de la publication')
|
||||
}
|
||||
creating.value = false
|
||||
}
|
||||
|
||||
async function togglePostLike(post) {
|
||||
try {
|
||||
const response = await axios.post(`/api/posts/${post.id}/like`)
|
||||
post.is_liked = response.data.is_liked
|
||||
post.likes_count = response.data.likes_count
|
||||
toast.success(post.is_liked ? 'Post liké' : 'Like retiré')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour du like')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleComments(postId) {
|
||||
const post = posts.value.find(p => p.id === postId)
|
||||
if (post) {
|
||||
post.showComments = !post.showComments
|
||||
if (post.showComments && !post.comments) {
|
||||
post.comments = []
|
||||
post.newComment = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addComment(post) {
|
||||
if (!post.newComment?.trim()) return
|
||||
|
||||
try {
|
||||
const response = await axios.post(`/api/posts/${post.id}/comment`, {
|
||||
content: post.newComment.trim()
|
||||
})
|
||||
|
||||
// Add new comment to the post
|
||||
if (!post.comments) post.comments = []
|
||||
post.comments.push(response.data.comment)
|
||||
post.comments_count = (post.comments_count || 0) + 1
|
||||
|
||||
// Reset comment input
|
||||
post.newComment = ''
|
||||
|
||||
toast.success('Commentaire ajouté')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'ajout du commentaire')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(postId, commentId) {
|
||||
try {
|
||||
await axios.delete(`/api/posts/${postId}/comment/${commentId}`)
|
||||
|
||||
const post = posts.value.find(p => p.id === postId)
|
||||
if (post && post.comments) {
|
||||
post.comments = post.comments.filter(c => c.id !== commentId)
|
||||
post.comments_count = Math.max(0, (post.comments_count || 1) - 1)
|
||||
}
|
||||
|
||||
toast.success('Commentaire supprimé')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression du commentaire')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImageChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Veuillez sélectionner une image')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('L\'image est trop volumineuse (max 5MB)')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await axios.post('/api/posts/upload-image', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
newPost.value.image_url = response.data.image_url
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'upload de l\'image')
|
||||
}
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
newPost.value.image_url = ''
|
||||
}
|
||||
|
||||
async function fetchPosts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get(`/api/posts?limit=10&offset=${offset.value}`)
|
||||
if (offset.value === 0) {
|
||||
posts.value = response.data
|
||||
} else {
|
||||
posts.value.push(...response.data)
|
||||
}
|
||||
|
||||
hasMorePosts.value = response.data.length === 10
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement des publications')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const response = await axios.get('/api/users')
|
||||
users.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMorePosts() {
|
||||
offset.value += 10
|
||||
await fetchPosts()
|
||||
}
|
||||
|
||||
async function deletePost(postId) {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cette publication ?')) return
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/posts/${postId}`)
|
||||
posts.value = posts.value.filter(p => p.id !== postId)
|
||||
toast.success('Publication supprimée')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression')
|
||||
}
|
||||
}
|
||||
|
||||
function canDeletePost(post) {
|
||||
return user.value && (post.author_id === user.value.id || user.value.is_admin)
|
||||
}
|
||||
|
||||
function canDeleteComment(comment) {
|
||||
return user.value && (comment.author_id === user.value.id || user.value.is_admin)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPosts()
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
253
frontend/src/views/Profile.vue
Normal file
253
frontend/src/views/Profile.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Mon profil</h1>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Avatar Section -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="card p-6">
|
||||
<div class="text-center">
|
||||
<div class="relative inline-block">
|
||||
<img
|
||||
v-if="user?.avatar_url"
|
||||
:src="getMediaUrl(user.avatar_url)"
|
||||
:alt="user?.full_name"
|
||||
class="w-32 h-32 rounded-full object-cover border-4 border-white shadow-lg"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-32 h-32 rounded-full bg-primary-100 flex items-center justify-center border-4 border-white shadow-lg"
|
||||
>
|
||||
<User class="w-16 h-16 text-primary-600" />
|
||||
</div>
|
||||
|
||||
<!-- Upload Button Overlay -->
|
||||
<button
|
||||
@click="$refs.avatarInput.click()"
|
||||
class="absolute bottom-0 right-0 bg-primary-600 text-white p-2 rounded-full shadow-lg hover:bg-primary-700 transition-colors"
|
||||
title="Changer l'avatar"
|
||||
>
|
||||
<Camera class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="avatarInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleAvatarChange"
|
||||
>
|
||||
|
||||
<h2 class="text-xl font-semibold text-gray-900 mt-4">{{ user?.full_name }}</h2>
|
||||
<p class="text-gray-600">@{{ user?.username }}</p>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<p>Membre depuis {{ formatDate(user?.created_at) }}</p>
|
||||
<p>Taux de présence : {{ Math.round(user?.attendance_rate || 0) }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Form -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="card p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations personnelles</h3>
|
||||
|
||||
<form @submit.prevent="updateProfile" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="label">Nom complet</label>
|
||||
<input
|
||||
v-model="form.full_name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
placeholder="Prénom Nom"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Nom d'utilisateur</label>
|
||||
<input
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
disabled
|
||||
class="input bg-gray-50"
|
||||
title="Le nom d'utilisateur ne peut pas être modifié"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Email</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
disabled
|
||||
class="input bg-gray-50"
|
||||
title="L'email ne peut pas être modifié"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Bio</label>
|
||||
<textarea
|
||||
v-model="form.bio"
|
||||
rows="4"
|
||||
class="input"
|
||||
placeholder="Parlez-nous un peu de vous..."
|
||||
maxlength="500"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ (form.bio || '').length }}/500 caractères</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="updating"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ updating ? 'Mise à jour...' : 'Mettre à jour' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="resetForm"
|
||||
class="btn-secondary"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<div class="card p-6 mt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Mes statistiques</h3>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-primary-600">{{ stats.posts_count || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Publications</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-green-600">{{ stats.vlogs_count || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Vlogs</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ stats.albums_count || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Albums</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-purple-600">{{ stats.events_created || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Événements créés</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { User, Camera } from 'lucide-vue-next'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const updating = ref(false)
|
||||
const stats = ref({})
|
||||
|
||||
const form = ref({
|
||||
full_name: '',
|
||||
username: '',
|
||||
email: '',
|
||||
bio: ''
|
||||
})
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return ''
|
||||
return format(new Date(date), 'MMMM yyyy', { locale: fr })
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
full_name: user.value?.full_name || '',
|
||||
username: user.value?.username || '',
|
||||
email: user.value?.email || '',
|
||||
bio: user.value?.bio || ''
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile() {
|
||||
updating.value = true
|
||||
try {
|
||||
const result = await authStore.updateProfile({
|
||||
full_name: form.value.full_name,
|
||||
bio: form.value.bio
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Profil mis à jour avec succès')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour du profil')
|
||||
}
|
||||
updating.value = false
|
||||
}
|
||||
|
||||
async function handleAvatarChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Veuillez sélectionner une image')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('L\'image est trop volumineuse (max 5MB)')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authStore.uploadAvatar(file)
|
||||
if (result.success) {
|
||||
toast.success('Avatar mis à jour avec succès')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'upload de l\'avatar')
|
||||
}
|
||||
|
||||
// Reset input
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
async function fetchUserStats() {
|
||||
try {
|
||||
const response = await axios.get(`/api/stats/user/${user.value.id}`)
|
||||
stats.value = response.data.content_stats
|
||||
} catch (error) {
|
||||
console.error('Error fetching user stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resetForm()
|
||||
fetchUserStats()
|
||||
})
|
||||
</script>
|
||||
190
frontend/src/views/Register.vue
Normal file
190
frontend/src/views/Register.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Inscription</h2>
|
||||
|
||||
<form @submit.prevent="handleRegister" class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
:disabled="!registrationEnabled"
|
||||
class="input"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||
placeholder="ton.email@example.com"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="username" class="label">Nom d'utilisateur</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="50"
|
||||
:disabled="!registrationEnabled"
|
||||
class="input"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||
placeholder="tonpseudo"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="full_name" class="label">Nom complet</label>
|
||||
<input
|
||||
id="full_name"
|
||||
v-model="form.full_name"
|
||||
type="text"
|
||||
required
|
||||
:disabled="!registrationEnabled"
|
||||
class="input"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||
placeholder="Prénom Nom"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="label">Mot de passe</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
minlength="6"
|
||||
:disabled="!registrationEnabled"
|
||||
class="input"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password_confirm" class="label">Confirmer le mot de passe</label>
|
||||
<input
|
||||
id="password_confirm"
|
||||
v-model="form.password_confirm"
|
||||
type="password"
|
||||
required
|
||||
minlength="6"
|
||||
:disabled="!registrationEnabled"
|
||||
class="input"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-red-600 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading || !registrationEnabled"
|
||||
class="w-full btn-primary"
|
||||
>
|
||||
{{ loading ? 'Inscription...' : !registrationEnabled ? 'Inscriptions désactivées' : 'S\'inscrire' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
Déjà un compte ?
|
||||
<router-link to="/login" class="font-medium text-primary-600 hover:text-primary-500">
|
||||
Se connecter
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Vérification du statut d'inscription -->
|
||||
<div v-if="!registrationEnabled" class="mt-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Inscriptions désactivées</h3>
|
||||
<p class="text-sm text-yellow-700 mt-1">
|
||||
Les nouvelles inscriptions sont temporairement désactivées. Veuillez contacter l'administrateur.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import axios from '@/utils/axios'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = ref({
|
||||
email: '',
|
||||
username: '',
|
||||
full_name: '',
|
||||
password: '',
|
||||
password_confirm: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const registrationEnabled = ref(true)
|
||||
|
||||
async function handleRegister() {
|
||||
error.value = ''
|
||||
|
||||
if (!registrationEnabled.value) {
|
||||
error.value = 'Les inscriptions sont actuellement désactivées'
|
||||
return
|
||||
}
|
||||
|
||||
if (form.value.password !== form.value.password_confirm) {
|
||||
error.value = 'Les mots de passe ne correspondent pas'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
const result = await authStore.register({
|
||||
email: form.value.email,
|
||||
username: form.value.username,
|
||||
full_name: form.value.full_name,
|
||||
password: form.value.password
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
error.value = result.error
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function checkRegistrationStatus() {
|
||||
try {
|
||||
const response = await axios.get('/api/settings/public/registration-status')
|
||||
const status = response.data
|
||||
registrationEnabled.value = status.can_register
|
||||
|
||||
// Afficher des informations supplémentaires si l'inscription est désactivée
|
||||
if (!status.registration_enabled) {
|
||||
console.log('Registration disabled by admin')
|
||||
} else if (status.current_users_count >= status.max_users) {
|
||||
console.log(`Maximum users reached: ${status.current_users_count}/${status.max_users}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking registration status:', error)
|
||||
// En cas d'erreur, on désactive l'inscription pour éviter les problèmes de sécurité
|
||||
registrationEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkRegistrationStatus()
|
||||
})
|
||||
</script>
|
||||
307
frontend/src/views/Stats.vue
Normal file
307
frontend/src/views/Stats.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Statistiques du groupe</h1>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">Chargement des statistiques...</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats content -->
|
||||
<div v-else>
|
||||
<!-- Overview Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Membres actifs</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ overview.total_users }}</p>
|
||||
</div>
|
||||
<Users class="w-8 h-8 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Événements</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ overview.total_events }}</p>
|
||||
</div>
|
||||
<Calendar class="w-8 h-8 text-success-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Albums</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ overview.total_albums }}</p>
|
||||
</div>
|
||||
<Image class="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Vlogs</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ overview.total_vlogs }}</p>
|
||||
</div>
|
||||
<Film class="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Attendance Stats -->
|
||||
<div class="card p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Taux de présence</h2>
|
||||
|
||||
<div v-if="attendanceStats.attendance_stats.length === 0" class="text-center py-8 text-gray-500">
|
||||
Aucune donnée de présence disponible
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="(user, index) in attendanceStats.attendance_stats.slice(0, 5)"
|
||||
:key="user.user_id"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center text-sm font-medium text-primary-600">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<UserAvatar :user="user" size="md" :show-user-info="true" />
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold text-primary-600">{{ Math.round(user.attendance_rate) }}%</div>
|
||||
<div class="text-xs text-gray-500">{{ user.present_count }}/{{ user.total_past_events }} événements</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="attendanceStats.best_attendee" class="mt-4 p-3 bg-success-50 rounded-lg border border-success-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Trophy class="w-5 h-5 text-success-600" />
|
||||
<span class="text-sm font-medium text-success-800">
|
||||
🏆 {{ attendanceStats.best_attendee.full_name }} est le plus assidu avec {{ Math.round(attendanceStats.best_attendee.attendance_rate) }}% de présence !
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fun Stats -->
|
||||
<div class="card p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Statistiques fun</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Most Active Poster -->
|
||||
<div v-if="funStats.most_active_poster" class="p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<MessageSquare class="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800">Posteur le plus actif</p>
|
||||
<p class="text-xs text-blue-600">{{ funStats.most_active_poster.full_name }} ({{ funStats.most_active_poster.post_count }} posts)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Most Mentioned -->
|
||||
<div v-if="funStats.most_mentioned" class="p-3 bg-green-50 rounded-lg border border-green-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<AtSign class="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-800">Le plus mentionné</p>
|
||||
<p class="text-xs text-green-600">{{ funStats.most_mentioned.full_name }} ({{ funStats.most_mentioned.mention_count }} mentions)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Biggest Vlogger -->
|
||||
<div v-if="funStats.biggest_vlogger" class="p-3 bg-purple-50 rounded-lg border border-purple-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Film class="w-5 h-5 text-purple-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-purple-800">Vlogger le plus prolifique</p>
|
||||
<p class="text-xs text-purple-600">{{ funStats.biggest_vlogger.full_name }} ({{ funStats.biggest_vlogger.vlog_count }} vlogs)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Addict -->
|
||||
<div v-if="funStats.photo_addict" class="p-3 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Image class="w-5 h-5 text-yellow-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-800">Accro aux photos</p>
|
||||
<p class="text-xs text-yellow-600">{{ funStats.photo_addict.full_name }} ({{ funStats.photo_addict.album_count }} albums)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Organizer -->
|
||||
<div v-if="funStats.event_organizer" class="p-3 bg-red-50 rounded-lg border border-red-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-red-800">Organisateur d'événements</p>
|
||||
<p class="text-xs text-red-600">{{ funStats.event_organizer.full_name }} ({{ funStats.event_organizer.event_count }} événements)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Most Viewed Vlog -->
|
||||
<div v-if="funStats.most_viewed_vlog" class="p-3 bg-indigo-50 rounded-lg border border-indigo-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Eye class="w-5 h-5 text-indigo-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-indigo-800">Vlog le plus vu</p>
|
||||
<p class="text-xs text-indigo-600">{{ funStats.most_viewed_vlog.title }} ({{ funStats.most_viewed_vlog.views_count }} vues)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card p-6 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Activité récente (30 derniers jours)</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-primary-600">{{ overview.recent_events || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Nouveaux événements</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-success-600">{{ overview.recent_posts || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Nouvelles publications</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-purple-600">{{ overview.recent_vlogs || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Nouveaux vlogs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Stats -->
|
||||
<div class="card p-6 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Statistiques par utilisateur</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Utilisateur</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Taux de présence</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Publications</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Vlogs</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Albums</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Événements créés</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr
|
||||
v-for="user in userStats"
|
||||
:key="user.user.id"
|
||||
class="hover:bg-gray-50"
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<UserAvatar :user="user.user" size="md" :show-user-info="true" />
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm font-medium text-gray-900">{{ Math.round(user.user.attendance_rate) }}%</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ user.content_stats.posts_count }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ user.content_stats.vlogs_count }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ user.content_stats.albums_count }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ user.content_stats.events_created }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import {
|
||||
Users,
|
||||
Calendar,
|
||||
Image,
|
||||
Film,
|
||||
Trophy,
|
||||
MessageSquare,
|
||||
AtSign,
|
||||
Eye
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const overview = ref({})
|
||||
const attendanceStats = ref({})
|
||||
const funStats = ref({})
|
||||
const userStats = ref([])
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
// Fetch overview stats
|
||||
const overviewResponse = await axios.get('/api/stats/overview')
|
||||
overview.value = overviewResponse.data
|
||||
|
||||
// Fetch attendance stats
|
||||
const attendanceResponse = await axios.get('/api/stats/attendance')
|
||||
attendanceStats.value = attendanceResponse.data
|
||||
|
||||
// Fetch fun stats
|
||||
const funResponse = await axios.get('/api/stats/fun')
|
||||
funStats.value = funResponse.data
|
||||
|
||||
// Fetch user stats for all users
|
||||
const usersResponse = await axios.get('/api/users')
|
||||
const userStatsPromises = usersResponse.data.map(async (user) => {
|
||||
try {
|
||||
const userStatsResponse = await axios.get(`/api/stats/user/${user.id}`)
|
||||
return userStatsResponse.data
|
||||
} catch (error) {
|
||||
console.error(`Error fetching stats for user ${user.id}:`, error)
|
||||
return {
|
||||
user: user,
|
||||
content_stats: { posts_count: 0, vlogs_count: 0, albums_count: 0, events_created: 0 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const userStatsResults = await Promise.all(userStatsPromises)
|
||||
userStats.value = userStatsResults.sort((a, b) => b.user.attendance_rate - a.user.attendance_rate)
|
||||
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement des statistiques')
|
||||
console.error('Error fetching stats:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
})
|
||||
</script>
|
||||
223
frontend/src/views/UserProfile.vue
Normal file
223
frontend/src/views/UserProfile.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">Chargement du profil...</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile not found -->
|
||||
<div v-else-if="!profileUser" class="text-center py-12">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-4">Profil non trouvé</h1>
|
||||
<p class="text-gray-600 mb-6">L'utilisateur que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||
<router-link to="/" class="btn-primary">
|
||||
Retour à l'accueil
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Profile content -->
|
||||
<div v-else>
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<router-link to="/" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Retour à l'accueil
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Profile Info -->
|
||||
<div class="card p-8 text-center">
|
||||
<!-- Avatar -->
|
||||
<div class="mb-6">
|
||||
<div class="relative inline-block">
|
||||
<img
|
||||
v-if="profileUser.avatar_url"
|
||||
:src="getMediaUrl(profileUser.avatar_url)"
|
||||
:alt="profileUser.full_name"
|
||||
class="w-32 h-32 rounded-full object-cover mx-auto border-4 border-white shadow-lg"
|
||||
>
|
||||
<div v-else class="w-32 h-32 rounded-full bg-primary-100 flex items-center justify-center mx-auto border-4 border-white shadow-lg">
|
||||
<User class="w-16 h-16 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">{{ profileUser.full_name }}</h1>
|
||||
<p class="text-xl text-gray-600 mb-4">@{{ profileUser.username }}</p>
|
||||
|
||||
<div v-if="profileUser.bio" class="text-gray-700 mb-6 max-w-2xl mx-auto">
|
||||
{{ profileUser.bio }}
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 mt-8">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary-600">{{ userStats.posts_count || 0 }}</div>
|
||||
<div class="text-gray-600">Publications</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-success-600">{{ userStats.vlogs_count || 0 }}</div>
|
||||
<div class="text-gray-600">Vlogs</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ userStats.albums_count || 0 }}</div>
|
||||
<div class="text-gray-600">Albums</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-purple-600">{{ userStats.events_created || 0 }}</div>
|
||||
<div class="text-gray-600">Événements</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attendance Rate -->
|
||||
<div v-if="profileUser.attendance_rate !== undefined" class="mt-6">
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-semibold text-gray-900 mb-2">Taux de présence</div>
|
||||
<div class="text-3xl font-bold text-success-600">{{ profileUser.attendance_rate.toFixed(1) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Activité récente</h2>
|
||||
|
||||
<div v-if="recentActivity.length === 0" class="text-center py-8 text-gray-500">
|
||||
Aucune activité récente
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="activity in recentActivity"
|
||||
:key="activity.id"
|
||||
class="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
@click="navigateToActivity(activity)"
|
||||
>
|
||||
<div class="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<component :is="getActivityIcon(activity.type)" class="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-900">{{ activity.description }}</p>
|
||||
<p class="text-xs text-gray-500">{{ formatRelativeDate(activity.created_at) }}</p>
|
||||
</div>
|
||||
<ArrowRight class="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Member Since -->
|
||||
<div class="card p-6 text-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Membre depuis</h3>
|
||||
<p class="text-2xl text-primary-600">{{ formatDate(profileUser.created_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { User, ArrowLeft, ArrowRight, MessageSquare, Video, Image, Calendar, Activity } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const profileUser = ref(null)
|
||||
const userStats = ref({})
|
||||
const recentActivity = ref([])
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return ''
|
||||
return format(new Date(date), 'MMMM yyyy', { locale: fr })
|
||||
}
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
if (!date) return ''
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function getActivityIcon(type) {
|
||||
switch (type) {
|
||||
case 'post':
|
||||
return MessageSquare
|
||||
case 'vlog':
|
||||
return Video
|
||||
case 'album':
|
||||
return Image
|
||||
case 'event':
|
||||
return Calendar
|
||||
default:
|
||||
return Activity
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToActivity(activity) {
|
||||
if (activity.link) {
|
||||
router.push(activity.link)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProfile(userId) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get(`/api/users/${userId}`)
|
||||
profileUser.value = response.data
|
||||
await fetchUserStats(userId)
|
||||
await fetchRecentActivity(userId)
|
||||
} catch (error) {
|
||||
console.error('Error fetching profile:', error)
|
||||
profileUser.value = null
|
||||
toast.error('Erreur lors du chargement du profil')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserStats(userId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/stats/user/${userId}`)
|
||||
userStats.value = response.data.content_stats
|
||||
} catch (error) {
|
||||
console.error('Error fetching user stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecentActivity(userId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/stats/activity/user/${userId}`)
|
||||
recentActivity.value = response.data.activity
|
||||
} catch (error) {
|
||||
console.error('Error fetching recent activity:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const userId = route.params.id
|
||||
|
||||
if (!userId) {
|
||||
toast.error('ID utilisateur manquant')
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
// Empêcher de voir son propre profil ici
|
||||
if (parseInt(userId) === authStore.user?.id) {
|
||||
router.push('/profile')
|
||||
return
|
||||
}
|
||||
|
||||
await fetchProfile(userId)
|
||||
})
|
||||
</script>
|
||||
370
frontend/src/views/VlogDetail.vue
Normal file
370
frontend/src/views/VlogDetail.vue
Normal file
@@ -0,0 +1,370 @@
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">Chargement du vlog...</p>
|
||||
</div>
|
||||
|
||||
<!-- Vlog not found -->
|
||||
<div v-else-if="!vlog" class="text-center py-12">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-4">Vlog non trouvé</h1>
|
||||
<p class="text-gray-600 mb-6">Le vlog que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||
<router-link to="/vlogs" class="btn-primary">
|
||||
Retour aux vlogs
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Vlog details -->
|
||||
<div v-else>
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<router-link to="/vlogs" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Retour aux vlogs
|
||||
</router-link>
|
||||
|
||||
<div v-if="canEdit" class="flex space-x-2">
|
||||
<button
|
||||
@click="showEditModal = true"
|
||||
class="btn-secondary"
|
||||
>
|
||||
<Edit class="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
@click="deleteVlog"
|
||||
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300"
|
||||
>
|
||||
<Trash2 class="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">{{ vlog.title }}</h1>
|
||||
|
||||
<div class="flex items-center space-x-6 text-gray-600 mb-6">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
v-if="vlog.author_avatar"
|
||||
:src="vlog.author_avatar"
|
||||
:alt="vlog.author_name"
|
||||
class="w-8 h-8 rounded-full object-cover mr-3"
|
||||
>
|
||||
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center mr-3">
|
||||
<User class="w-4 h-4 text-primary-600" />
|
||||
</div>
|
||||
<span>Par {{ vlog.author_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Calendar class="w-4 h-4 mr-2" />
|
||||
<span>{{ formatDate(vlog.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Eye class="w-4 h-4 mr-2" />
|
||||
<span>{{ vlog.views_count }} vue{{ vlog.views_count > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="vlog.duration" class="flex items-center">
|
||||
<Clock class="w-4 h-4 mr-2" />
|
||||
<span>{{ formatDuration(vlog.duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Player -->
|
||||
<div class="card p-6 mb-8">
|
||||
<VideoPlayer
|
||||
:src="vlog.video_url"
|
||||
:poster="vlog.thumbnail_url"
|
||||
:title="vlog.title"
|
||||
:description="vlog.description"
|
||||
:duration="vlog.duration"
|
||||
:views-count="vlog.views_count"
|
||||
:likes-count="vlog.likes_count"
|
||||
:comments-count="vlog.comments?.length || 0"
|
||||
:is-liked="vlog.is_liked"
|
||||
@like="toggleLike"
|
||||
@toggle-comments="showComments = !showComments"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div v-if="vlog.description" class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Description</h2>
|
||||
<p class="text-gray-700 whitespace-pre-wrap">{{ vlog.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div class="card p-6 mb-8">
|
||||
<VlogComments
|
||||
:vlog-id="vlog.id"
|
||||
:comments="vlog.comments || []"
|
||||
:comment-users="users"
|
||||
@comment-added="onCommentAdded"
|
||||
@comment-deleted="onCommentDeleted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Related Vlogs -->
|
||||
<div class="card p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Autres vlogs de {{ vlog.author_name }}</h2>
|
||||
|
||||
<div v-if="relatedVlogs.length === 0" class="text-center py-8 text-gray-500">
|
||||
Aucun autre vlog de cet auteur
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<router-link
|
||||
v-for="relatedVlog in relatedVlogs"
|
||||
:key="relatedVlog.id"
|
||||
:to="`/vlogs/${relatedVlog.id}`"
|
||||
class="block hover:shadow-md transition-shadow rounded-lg overflow-hidden"
|
||||
>
|
||||
<div class="aspect-video bg-gray-100 relative overflow-hidden">
|
||||
<img
|
||||
v-if="relatedVlog.thumbnail_url"
|
||||
:src="getMediaUrl(relatedVlog.thumbnail_url)"
|
||||
:alt="relatedVlog.title"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Film class="w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Play Button Overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
||||
<Play class="w-8 h-8 text-white" />
|
||||
</div>
|
||||
|
||||
<!-- Duration Badge -->
|
||||
<div v-if="relatedVlog.duration" class="absolute bottom-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||
{{ formatDuration(relatedVlog.duration) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3">
|
||||
<h3 class="font-medium text-gray-900 line-clamp-2">{{ relatedVlog.title }}</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">{{ formatRelativeDate(relatedVlog.created_at) }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ relatedVlog.views_count }} vue{{ relatedVlog.views_count > 1 ? 's' : '' }}</p>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Vlog 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="showEditModal"
|
||||
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-md w-full p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Modifier le vlog</h2>
|
||||
|
||||
<form @submit.prevent="updateVlog" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Titre</label>
|
||||
<input
|
||||
v-model="editForm.title"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="3"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showEditModal = false"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="updating"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
{{ updating ? 'Mise à jour...' : 'Mettre à jour' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
ArrowLeft,
|
||||
User,
|
||||
Calendar,
|
||||
Eye,
|
||||
Clock,
|
||||
Edit,
|
||||
Trash2,
|
||||
Film,
|
||||
Play
|
||||
} from 'lucide-vue-next'
|
||||
import VideoPlayer from '@/components/VideoPlayer.vue'
|
||||
import VlogComments from '@/components/VlogComments.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const vlog = ref(null)
|
||||
const relatedVlogs = ref([])
|
||||
const users = ref([])
|
||||
const loading = ref(true)
|
||||
const updating = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showComments = ref(true)
|
||||
|
||||
const editForm = ref({
|
||||
title: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const canEdit = computed(() =>
|
||||
vlog.value && (vlog.value.author_id === authStore.user?.id || authStore.user?.is_admin)
|
||||
)
|
||||
|
||||
function formatDate(date) {
|
||||
return format(new Date(date), 'dd MMMM yyyy', { locale: fr })
|
||||
}
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds) return '--:--'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function toggleLike() {
|
||||
try {
|
||||
const response = await axios.post(`/api/vlogs/${vlog.value.id}/like`)
|
||||
// Refresh vlog data to get updated like count
|
||||
await fetchVlog()
|
||||
toast.success(response.data.message)
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour du like')
|
||||
}
|
||||
}
|
||||
|
||||
function onCommentAdded(comment) {
|
||||
// Add the new comment to the vlog
|
||||
if (!vlog.value.comments) {
|
||||
vlog.value.comments = []
|
||||
}
|
||||
vlog.value.comments.unshift(comment)
|
||||
}
|
||||
|
||||
function onCommentDeleted(commentId) {
|
||||
// Remove the deleted comment from the vlog
|
||||
if (vlog.value.comments) {
|
||||
const index = vlog.value.comments.findIndex(c => c.id === commentId)
|
||||
if (index > -1) {
|
||||
vlog.value.comments.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const response = await axios.get('/api/users')
|
||||
users.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVlog() {
|
||||
try {
|
||||
const response = await axios.get(`/api/vlogs/${route.params.id}`)
|
||||
vlog.value = response.data
|
||||
|
||||
// Initialize edit form
|
||||
editForm.value = {
|
||||
title: vlog.value.title,
|
||||
description: vlog.value.description || ''
|
||||
}
|
||||
|
||||
// Fetch related vlogs from same author
|
||||
const relatedResponse = await axios.get(`/api/vlogs?limit=6&offset=0`)
|
||||
relatedVlogs.value = relatedResponse.data
|
||||
.filter(v => v.id !== vlog.value.id && v.author_id === vlog.value.author_id)
|
||||
.slice(0, 3)
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement du vlog')
|
||||
console.error('Error fetching vlog:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVlog() {
|
||||
updating.value = true
|
||||
try {
|
||||
const response = await axios.put(`/api/vlogs/${vlog.value.id}`, editForm.value)
|
||||
vlog.value = response.data
|
||||
showEditModal.value = false
|
||||
toast.success('Vlog mis à jour')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour')
|
||||
}
|
||||
updating.value = false
|
||||
}
|
||||
|
||||
async function deleteVlog() {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce vlog ?')) return
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/vlogs/${vlog.value.id}`)
|
||||
toast.success('Vlog supprimé')
|
||||
router.push('/vlogs')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchVlog()
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
473
frontend/src/views/Vlogs.vue
Normal file
473
frontend/src/views/Vlogs.vue
Normal file
@@ -0,0 +1,473 @@
|
||||
<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">Vlogs</h1>
|
||||
<p class="text-gray-600 mt-1">Partagez vos moments en vidéo avec le groupe</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn-primary"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nouveau vlog
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Vlogs Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="vlog in vlogs"
|
||||
:key="vlog.id"
|
||||
class="card hover:shadow-lg transition-shadow cursor-pointer"
|
||||
@click="openVlog(vlog)"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="aspect-video bg-gray-100 relative overflow-hidden">
|
||||
<img
|
||||
v-if="vlog.thumbnail_url"
|
||||
:src="getMediaUrl(vlog.thumbnail_url)"
|
||||
:alt="vlog.title"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Film class="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Play Button Overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
||||
<Play class="w-12 h-12 text-white" />
|
||||
</div>
|
||||
|
||||
<!-- Duration Badge -->
|
||||
<div v-if="vlog.duration" class="absolute bottom-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||
{{ formatDuration(vlog.duration) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2 line-clamp-2">{{ vlog.title }}</h3>
|
||||
|
||||
<!-- Description with mentions -->
|
||||
<div v-if="vlog.description" class="mb-3">
|
||||
<Mentions :content="vlog.description" :mentions="getMentionsFromContent(vlog.description)" class="text-gray-600 text-sm line-clamp-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-600 mb-3">
|
||||
<img
|
||||
v-if="vlog.author_avatar"
|
||||
:src="vlog.author_avatar"
|
||||
:alt="vlog.author_name"
|
||||
class="w-5 h-5 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User class="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
<router-link
|
||||
:to="`/profile/${vlog.author_id}`"
|
||||
class="text-gray-900 hover:text-primary-600 transition-colors cursor-pointer"
|
||||
>
|
||||
{{ vlog.author_name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{{ formatRelativeDate(vlog.created_at) }}</span>
|
||||
<div class="flex items-center space-x-1">
|
||||
<Eye class="w-4 h-4" />
|
||||
<span>{{ vlog.views_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load more -->
|
||||
<div v-if="hasMoreVlogs" class="text-center mt-8">
|
||||
<button
|
||||
@click="loadMoreVlogs"
|
||||
:disabled="loading"
|
||||
class="btn-secondary"
|
||||
>
|
||||
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="vlogs.length === 0 && !loading" class="text-center py-12">
|
||||
<Film class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun vlog</h3>
|
||||
<p class="text-gray-600">Soyez le premier à partager un vlog !</p>
|
||||
</div>
|
||||
|
||||
<!-- Create Vlog 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 nouveau vlog</h2>
|
||||
|
||||
<form @submit.prevent="createVlog" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Titre</label>
|
||||
<input
|
||||
v-model="newVlog.title"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
placeholder="Titre de votre vlog..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<MentionInput
|
||||
v-model="newVlog.description"
|
||||
:users="users"
|
||||
:rows="3"
|
||||
placeholder="Décrivez votre vlog... (utilisez @username pour mentionner)"
|
||||
@mentions-changed="handleVlogMentionsChanged"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Vidéo</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<input
|
||||
ref="videoInput"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
class="hidden"
|
||||
@change="handleVideoChange"
|
||||
>
|
||||
|
||||
<div v-if="!newVlog.video" class="space-y-2">
|
||||
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
|
||||
<p class="text-gray-600">Cliquez pour sélectionner une vidéo</p>
|
||||
<p class="text-sm text-gray-500">MP4, WebM, MOV (max {{ uploadLimits.max_video_size_mb }}MB)</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.videoInput.click()"
|
||||
class="btn-secondary"
|
||||
>
|
||||
Sélectionner une vidéo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<video
|
||||
:src="newVlog.video"
|
||||
class="w-full max-h-48 object-cover rounded"
|
||||
controls
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeVideo"
|
||||
class="btn-secondary text-sm"
|
||||
>
|
||||
Changer de vidéo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Miniature (optionnel)</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<input
|
||||
ref="thumbnailInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleThumbnailChange"
|
||||
>
|
||||
|
||||
<div v-if="!newVlog.thumbnail" class="space-y-2">
|
||||
<Image class="w-12 h-12 text-gray-400 mx-auto" />
|
||||
<p class="text-gray-600">Ajoutez une miniature personnalisée</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.thumbnailInput.click()"
|
||||
class="btn-secondary"
|
||||
>
|
||||
Sélectionner une image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<img
|
||||
:src="newVlog.thumbnail"
|
||||
class="w-full max-h-48 object-cover rounded mx-auto"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeThumbnail"
|
||||
class="btn-secondary text-sm"
|
||||
>
|
||||
Changer la miniature
|
||||
</button>
|
||||
</div>
|
||||
</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 || !newVlog.video"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
{{ creating ? 'Création...' : 'Créer le vlog' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, 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 } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
Plus,
|
||||
Film,
|
||||
Play,
|
||||
User,
|
||||
Eye,
|
||||
Upload,
|
||||
Image
|
||||
} from 'lucide-vue-next'
|
||||
import Mentions from '@/components/Mentions.vue'
|
||||
import MentionInput from '@/components/MentionInput.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const vlogs = ref([])
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const hasMoreVlogs = ref(true)
|
||||
const offset = ref(0)
|
||||
const uploadLimits = ref({
|
||||
max_video_size_mb: 100
|
||||
})
|
||||
|
||||
const newVlog = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
video: null,
|
||||
thumbnail: null
|
||||
})
|
||||
|
||||
const vlogMentions = ref([])
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function openVlog(vlog) {
|
||||
router.push(`/vlogs/${vlog.id}`)
|
||||
}
|
||||
|
||||
async function handleVideoChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith('video/')) {
|
||||
toast.error('Veuillez sélectionner une vidéo')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > uploadLimits.max_video_size_mb * 1024 * 1024) {
|
||||
toast.error(`La vidéo est trop volumineuse (max ${uploadLimits.max_video_size_mb}MB)`)
|
||||
return
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
newVlog.value.video = URL.createObjectURL(file)
|
||||
newVlog.value.videoFile = file
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function removeVideo() {
|
||||
if (newVlog.value.video && newVlog.value.video.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(newVlog.value.video)
|
||||
}
|
||||
newVlog.value.video = null
|
||||
newVlog.value.videoFile = null
|
||||
}
|
||||
|
||||
async function handleThumbnailChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Veuillez sélectionner une image')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > uploadLimits.max_image_size_mb * 1024 * 1024) {
|
||||
toast.error(`L'image est trop volumineuse (max ${uploadLimits.max_image_size_mb}MB)`)
|
||||
return
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
newVlog.value.thumbnail = URL.createObjectURL(file)
|
||||
newVlog.value.thumbnailFile = file
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function removeThumbnail() {
|
||||
if (newVlog.value.thumbnail && newVlog.value.thumbnail.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(newVlog.value.thumbnail)
|
||||
}
|
||||
newVlog.value.thumbnail = null
|
||||
newVlog.value.thumbnailFile = null
|
||||
}
|
||||
|
||||
function handleVlogMentionsChanged(mentions) {
|
||||
vlogMentions.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)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUploadLimits() {
|
||||
try {
|
||||
const response = await axios.get('/api/settings/upload-limits')
|
||||
uploadLimits.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching upload limits:', 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
|
||||
}
|
||||
|
||||
async function createVlog() {
|
||||
if (!newVlog.value.title || !newVlog.value.videoFile) return
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('title', newVlog.value.title)
|
||||
if (newVlog.value.description) {
|
||||
formData.append('description', newVlog.value.description)
|
||||
}
|
||||
formData.append('video', newVlog.value.videoFile)
|
||||
if (newVlog.value.thumbnailFile) {
|
||||
formData.append('thumbnail', newVlog.value.thumbnailFile)
|
||||
}
|
||||
|
||||
const response = await axios.post('/api/vlogs/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
vlogs.value.unshift(response.data)
|
||||
showCreateModal.value = false
|
||||
resetForm()
|
||||
toast.success('Vlog créé avec succès')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la création du vlog')
|
||||
}
|
||||
creating.value = false
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
removeVideo()
|
||||
removeThumbnail()
|
||||
newVlog.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
video: null,
|
||||
thumbnail: null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVlogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get(`/api/vlogs?limit=12&offset=${offset.value}`)
|
||||
if (offset.value === 0) {
|
||||
vlogs.value = response.data
|
||||
} else {
|
||||
vlogs.value.push(...response.data)
|
||||
}
|
||||
|
||||
hasMoreVlogs.value = response.data.length === 12
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement des vlogs')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function loadMoreVlogs() {
|
||||
offset.value += 12
|
||||
await fetchVlogs()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchVlogs()
|
||||
fetchUsers()
|
||||
fetchUploadLimits()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user