Files
LeDiscord/frontend/src/views/Vlogs.vue
2026-01-25 18:08:38 +01:00

492 lines
16 KiB
Vue

<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Vlogs</h1>
<p class="text-sm sm:text-base text-gray-600 mt-1">Partagez vos moments en vidéo avec le groupe</p>
</div>
<button
@click="showCreateModal = true"
class="w-full sm:w-auto btn-primary justify-center"
>
<Plus class="w-4 h-4 mr-2" />
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="getMediaUrl(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">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Créer un nouveau vlog</h2>
<div v-if="creating" class="flex items-center space-x-2 text-primary-600">
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-sm font-medium">Upload en cours...</span>
</div>
</div>
<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"
:disabled="creating"
>
{{ creating ? 'Upload en cours...' : '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"
:disabled="creating"
>
{{ creating ? 'Upload en cours...' : '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"
>
<span v-if="creating" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Upload en cours...
</span>
<span v-else>Créer le vlog</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 } 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/public/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>