initial commit - LeDiscord plateforme des copains

This commit is contained in:
EvanChal
2025-08-21 00:28:21 +02:00
commit b7a84a53aa
93 changed files with 16247 additions and 0 deletions

2129
frontend/src/views/Admin.vue Normal file

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>