474 lines
14 KiB
Vue
474 lines
14 KiB
Vue
<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>
|