initial commit - LeDiscord plateforme des copains
This commit is contained in:
473
frontend/src/views/Vlogs.vue
Normal file
473
frontend/src/views/Vlogs.vue
Normal file
@@ -0,0 +1,473 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Vlogs</h1>
|
||||
<p class="text-gray-600 mt-1">Partagez vos moments en vidéo avec le groupe</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn-primary"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nouveau vlog
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Vlogs Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="vlog in vlogs"
|
||||
:key="vlog.id"
|
||||
class="card hover:shadow-lg transition-shadow cursor-pointer"
|
||||
@click="openVlog(vlog)"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="aspect-video bg-gray-100 relative overflow-hidden">
|
||||
<img
|
||||
v-if="vlog.thumbnail_url"
|
||||
:src="getMediaUrl(vlog.thumbnail_url)"
|
||||
:alt="vlog.title"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Film class="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Play Button Overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
||||
<Play class="w-12 h-12 text-white" />
|
||||
</div>
|
||||
|
||||
<!-- Duration Badge -->
|
||||
<div v-if="vlog.duration" class="absolute bottom-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||
{{ formatDuration(vlog.duration) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2 line-clamp-2">{{ vlog.title }}</h3>
|
||||
|
||||
<!-- Description with mentions -->
|
||||
<div v-if="vlog.description" class="mb-3">
|
||||
<Mentions :content="vlog.description" :mentions="getMentionsFromContent(vlog.description)" class="text-gray-600 text-sm line-clamp-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-600 mb-3">
|
||||
<img
|
||||
v-if="vlog.author_avatar"
|
||||
:src="vlog.author_avatar"
|
||||
:alt="vlog.author_name"
|
||||
class="w-5 h-5 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User class="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
<router-link
|
||||
:to="`/profile/${vlog.author_id}`"
|
||||
class="text-gray-900 hover:text-primary-600 transition-colors cursor-pointer"
|
||||
>
|
||||
{{ vlog.author_name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{{ formatRelativeDate(vlog.created_at) }}</span>
|
||||
<div class="flex items-center space-x-1">
|
||||
<Eye class="w-4 h-4" />
|
||||
<span>{{ vlog.views_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load more -->
|
||||
<div v-if="hasMoreVlogs" class="text-center mt-8">
|
||||
<button
|
||||
@click="loadMoreVlogs"
|
||||
:disabled="loading"
|
||||
class="btn-secondary"
|
||||
>
|
||||
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="vlogs.length === 0 && !loading" class="text-center py-12">
|
||||
<Film class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun vlog</h3>
|
||||
<p class="text-gray-600">Soyez le premier à partager un vlog !</p>
|
||||
</div>
|
||||
|
||||
<!-- Create Vlog Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showCreateModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-xl font-semibold mb-4">Créer un nouveau vlog</h2>
|
||||
|
||||
<form @submit.prevent="createVlog" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Titre</label>
|
||||
<input
|
||||
v-model="newVlog.title"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
placeholder="Titre de votre vlog..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<MentionInput
|
||||
v-model="newVlog.description"
|
||||
:users="users"
|
||||
:rows="3"
|
||||
placeholder="Décrivez votre vlog... (utilisez @username pour mentionner)"
|
||||
@mentions-changed="handleVlogMentionsChanged"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Vidéo</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<input
|
||||
ref="videoInput"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
class="hidden"
|
||||
@change="handleVideoChange"
|
||||
>
|
||||
|
||||
<div v-if="!newVlog.video" class="space-y-2">
|
||||
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
|
||||
<p class="text-gray-600">Cliquez pour sélectionner une vidéo</p>
|
||||
<p class="text-sm text-gray-500">MP4, WebM, MOV (max {{ uploadLimits.max_video_size_mb }}MB)</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.videoInput.click()"
|
||||
class="btn-secondary"
|
||||
>
|
||||
Sélectionner une vidéo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<video
|
||||
:src="newVlog.video"
|
||||
class="w-full max-h-48 object-cover rounded"
|
||||
controls
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeVideo"
|
||||
class="btn-secondary text-sm"
|
||||
>
|
||||
Changer de vidéo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Miniature (optionnel)</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<input
|
||||
ref="thumbnailInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleThumbnailChange"
|
||||
>
|
||||
|
||||
<div v-if="!newVlog.thumbnail" class="space-y-2">
|
||||
<Image class="w-12 h-12 text-gray-400 mx-auto" />
|
||||
<p class="text-gray-600">Ajoutez une miniature personnalisée</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.thumbnailInput.click()"
|
||||
class="btn-secondary"
|
||||
>
|
||||
Sélectionner une image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<img
|
||||
:src="newVlog.thumbnail"
|
||||
class="w-full max-h-48 object-cover rounded mx-auto"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeThumbnail"
|
||||
class="btn-secondary text-sm"
|
||||
>
|
||||
Changer la miniature
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showCreateModal = false"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="creating || !newVlog.video"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
{{ creating ? 'Création...' : 'Créer le vlog' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
Plus,
|
||||
Film,
|
||||
Play,
|
||||
User,
|
||||
Eye,
|
||||
Upload,
|
||||
Image
|
||||
} from 'lucide-vue-next'
|
||||
import Mentions from '@/components/Mentions.vue'
|
||||
import MentionInput from '@/components/MentionInput.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const vlogs = ref([])
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const hasMoreVlogs = ref(true)
|
||||
const offset = ref(0)
|
||||
const uploadLimits = ref({
|
||||
max_video_size_mb: 100
|
||||
})
|
||||
|
||||
const newVlog = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
video: null,
|
||||
thumbnail: null
|
||||
})
|
||||
|
||||
const vlogMentions = ref([])
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function openVlog(vlog) {
|
||||
router.push(`/vlogs/${vlog.id}`)
|
||||
}
|
||||
|
||||
async function handleVideoChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith('video/')) {
|
||||
toast.error('Veuillez sélectionner une vidéo')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > uploadLimits.max_video_size_mb * 1024 * 1024) {
|
||||
toast.error(`La vidéo est trop volumineuse (max ${uploadLimits.max_video_size_mb}MB)`)
|
||||
return
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
newVlog.value.video = URL.createObjectURL(file)
|
||||
newVlog.value.videoFile = file
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function removeVideo() {
|
||||
if (newVlog.value.video && newVlog.value.video.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(newVlog.value.video)
|
||||
}
|
||||
newVlog.value.video = null
|
||||
newVlog.value.videoFile = null
|
||||
}
|
||||
|
||||
async function handleThumbnailChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Veuillez sélectionner une image')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > uploadLimits.max_image_size_mb * 1024 * 1024) {
|
||||
toast.error(`L'image est trop volumineuse (max ${uploadLimits.max_image_size_mb}MB)`)
|
||||
return
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
newVlog.value.thumbnail = URL.createObjectURL(file)
|
||||
newVlog.value.thumbnailFile = file
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function removeThumbnail() {
|
||||
if (newVlog.value.thumbnail && newVlog.value.thumbnail.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(newVlog.value.thumbnail)
|
||||
}
|
||||
newVlog.value.thumbnail = null
|
||||
newVlog.value.thumbnailFile = null
|
||||
}
|
||||
|
||||
function handleVlogMentionsChanged(mentions) {
|
||||
vlogMentions.value = mentions
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const response = await axios.get('/api/users')
|
||||
users.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUploadLimits() {
|
||||
try {
|
||||
const response = await axios.get('/api/settings/upload-limits')
|
||||
uploadLimits.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching upload limits:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function getMentionsFromContent(content) {
|
||||
if (!content) return []
|
||||
|
||||
const mentions = []
|
||||
const mentionRegex = /@(\w+)/g
|
||||
let match
|
||||
|
||||
while ((match = mentionRegex.exec(content)) !== null) {
|
||||
const username = match[1]
|
||||
const user = users.value.find(u => u.username === username)
|
||||
if (user) {
|
||||
mentions.push({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
full_name: user.full_name
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return mentions
|
||||
}
|
||||
|
||||
async function createVlog() {
|
||||
if (!newVlog.value.title || !newVlog.value.videoFile) return
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('title', newVlog.value.title)
|
||||
if (newVlog.value.description) {
|
||||
formData.append('description', newVlog.value.description)
|
||||
}
|
||||
formData.append('video', newVlog.value.videoFile)
|
||||
if (newVlog.value.thumbnailFile) {
|
||||
formData.append('thumbnail', newVlog.value.thumbnailFile)
|
||||
}
|
||||
|
||||
const response = await axios.post('/api/vlogs/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
vlogs.value.unshift(response.data)
|
||||
showCreateModal.value = false
|
||||
resetForm()
|
||||
toast.success('Vlog créé avec succès')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la création du vlog')
|
||||
}
|
||||
creating.value = false
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
removeVideo()
|
||||
removeThumbnail()
|
||||
newVlog.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
video: null,
|
||||
thumbnail: null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVlogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get(`/api/vlogs?limit=12&offset=${offset.value}`)
|
||||
if (offset.value === 0) {
|
||||
vlogs.value = response.data
|
||||
} else {
|
||||
vlogs.value.push(...response.data)
|
||||
}
|
||||
|
||||
hasMoreVlogs.value = response.data.length === 12
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement des vlogs')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function loadMoreVlogs() {
|
||||
offset.value += 12
|
||||
await fetchVlogs()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchVlogs()
|
||||
fetchUsers()
|
||||
fetchUploadLimits()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user