fix(video-player): fix the video player to permit the nagivation through the video (it was because de fast api server refused range request)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 2m15s

This commit is contained in:
EvanChal
2026-01-25 18:08:38 +01:00
parent 3fbf372dae
commit 0020c13bfd
17 changed files with 393 additions and 212 deletions

View File

@@ -6,7 +6,7 @@ import uuid
from pathlib import Path from pathlib import Path
from config.database import get_db from config.database import get_db
from config.settings import settings from config.settings import settings
from models.vlog import Vlog, VlogLike, VlogComment from models.vlog import Vlog, VlogLike, VlogComment, VlogView
from models.user import User from models.user import User
from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate
from utils.security import get_current_active_user from utils.security import get_current_active_user
@@ -56,8 +56,21 @@ async def get_vlog(
detail="Vlog not found" detail="Vlog not found"
) )
# Increment view count # Manage views and replays
vlog.views_count += 1 view = db.query(VlogView).filter(
VlogView.vlog_id == vlog_id,
VlogView.user_id == current_user.id
).first()
if view:
# User has already viewed this vlog -> Count as replay
vlog.replays_count = (vlog.replays_count or 0) + 1
else:
# First time viewing -> Count as unique view
new_view = VlogView(vlog_id=vlog_id, user_id=current_user.id)
db.add(new_view)
vlog.views_count += 1
db.commit() db.commit()
return format_vlog_response(vlog, db, current_user.id) return format_vlog_response(vlog, db, current_user.id)
@@ -149,10 +162,15 @@ async def toggle_vlog_like(
message = "Like removed" message = "Like removed"
else: else:
# Like # Like
like = VlogLike(vlog_id=vlog_id, user_id=current_user.id) try:
db.add(like) like = VlogLike(vlog_id=vlog_id, user_id=current_user.id)
vlog.likes_count += 1 db.add(like)
message = "Vlog liked" vlog.likes_count += 1
message = "Vlog liked"
except Exception:
# Handle potential race condition or constraint violation
db.rollback()
return {"message": "Already liked", "likes_count": vlog.likes_count}
db.commit() db.commit()
return {"message": message, "likes_count": vlog.likes_count} return {"message": message, "likes_count": vlog.likes_count}
@@ -244,7 +262,7 @@ async def upload_vlog_video(
# Check file size # Check file size
video_content = await video.read() video_content = await video.read()
max_size = SettingsService.get_max_upload_size(db, video.content_type or "video/mp4") max_size = SettingsService.get_max_upload_size(db, video.content_type or "video/mp4", is_vlog=True)
if len(video_content) > max_size: if len(video_content) > max_size:
max_size_mb = max_size // (1024 * 1024) max_size_mb = max_size // (1024 * 1024)
raise HTTPException( raise HTTPException(
@@ -363,6 +381,7 @@ def format_vlog_response(vlog: Vlog, db: Session, current_user_id: int) -> dict:
"thumbnail_url": vlog.thumbnail_url, "thumbnail_url": vlog.thumbnail_url,
"duration": vlog.duration, "duration": vlog.duration,
"views_count": vlog.views_count, "views_count": vlog.views_count,
"replays_count": vlog.replays_count,
"likes_count": vlog.likes_count, "likes_count": vlog.likes_count,
"created_at": vlog.created_at, "created_at": vlog.created_at,
"updated_at": vlog.updated_at, "updated_at": vlog.updated_at,

View File

@@ -1,9 +1,10 @@
from fastapi import FastAPI from fastapi import FastAPI, Request, Response, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import os import os
from pathlib import Path from pathlib import Path
import mimetypes
from config.settings import settings from config.settings import settings
from config.database import engine, Base from config.database import engine, Base
@@ -198,10 +199,89 @@ app.add_middleware(
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
expose_headers=["Content-Range", "Accept-Ranges"],
) )
# Mount static files for uploads # Endpoint personnalisé pour servir les vidéos avec support Range
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_PATH), name="uploads") @app.get("/uploads/{file_path:path}")
async def serve_media_with_range(request: Request, file_path: str):
"""
Serve media files with proper Range request support for video scrubbing.
"""
file_full_path = Path(settings.UPLOAD_PATH) / file_path
# Vérifier que le fichier existe
if not file_full_path.exists() or not file_full_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
# Vérifier que le fichier est dans le répertoire uploads (sécurité)
try:
file_full_path.resolve().relative_to(Path(settings.UPLOAD_PATH).resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
# Obtenir la taille du fichier
file_size = file_full_path.stat().st_size
# Déterminer le content type
content_type, _ = mimetypes.guess_type(str(file_full_path))
if not content_type:
content_type = "application/octet-stream"
# Gérer les requêtes Range
range_header = request.headers.get("Range")
if range_header:
# Parser le header Range (format: bytes=start-end)
range_match = range_header.replace("bytes=", "").split("-")
start = int(range_match[0]) if range_match[0] else 0
end = int(range_match[1]) if range_match[1] and range_match[1] else file_size - 1
# Valider la plage
if start >= file_size or end >= file_size or start > end:
return Response(
status_code=416,
headers={
"Content-Range": f"bytes */{file_size}",
"Accept-Ranges": "bytes"
}
)
# Lire la plage demandée
chunk_size = end - start + 1
with open(file_full_path, "rb") as f:
f.seek(start)
chunk = f.read(chunk_size)
# Retourner la réponse 206 Partial Content
return Response(
content=chunk,
status_code=206,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk_size),
"Content-Type": content_type,
},
media_type=content_type
)
else:
# Pas de Range header, retourner le fichier complet
with open(file_full_path, "rb") as f:
content = f.read()
return Response(
content=content,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Content-Type": content_type,
},
media_type=content_type
)
# Note: StaticFiles mount retiré car notre endpoint personnalisé gère tous les fichiers
# avec support Range pour permettre le scrubbing vidéo
# Include routers # Include routers
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
from config.database import Base from config.database import Base
@@ -14,6 +14,7 @@ class Vlog(Base):
thumbnail_url = Column(String) thumbnail_url = Column(String)
duration = Column(Integer) # in seconds duration = Column(Integer) # in seconds
views_count = Column(Integer, default=0) views_count = Column(Integer, default=0)
replays_count = Column(Integer, default=0)
likes_count = Column(Integer, default=0) likes_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -35,6 +36,10 @@ class VlogLike(Base):
vlog = relationship("Vlog", back_populates="likes") vlog = relationship("Vlog", back_populates="likes")
user = relationship("User", back_populates="vlog_likes") user = relationship("User", back_populates="vlog_likes")
__table_args__ = (
UniqueConstraint('vlog_id', 'user_id', name='_vlog_user_like_uc'),
)
class VlogComment(Base): class VlogComment(Base):
__tablename__ = "vlog_comments" __tablename__ = "vlog_comments"
@@ -48,3 +53,19 @@ class VlogComment(Base):
# Relationships # Relationships
vlog = relationship("Vlog", back_populates="comments") vlog = relationship("Vlog", back_populates="comments")
user = relationship("User", back_populates="vlog_comments") user = relationship("User", back_populates="vlog_comments")
class VlogView(Base):
__tablename__ = "vlog_views"
id = Column(Integer, primary_key=True, index=True)
vlog_id = Column(Integer, ForeignKey("vlogs.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
vlog = relationship("Vlog")
user = relationship("User")
__table_args__ = (
UniqueConstraint('vlog_id', 'user_id', name='_vlog_user_view_uc'),
)

View File

@@ -49,6 +49,7 @@ class VlogResponse(VlogBase):
thumbnail_url: Optional[str] thumbnail_url: Optional[str]
duration: Optional[int] duration: Optional[int]
views_count: int views_count: int
replays_count: int = 0
likes_count: int likes_count: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -1,49 +1,53 @@
import os
from typing import Any, Dict, List
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models.settings import SystemSettings from models.settings import SystemSettings
from typing import Optional, Dict, Any
import json
class SettingsService: class SettingsService:
"""Service for managing system settings.""" """Service for managing system settings."""
@staticmethod @staticmethod
def get_setting(db: Session, key: str, default: Any = None) -> Any: def get_setting(db: Session, key: str, default: Any = None) -> Any:
"""Get a setting value by key.""" """Get a setting value by key, return default if not found."""
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first() setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
if not setting: if not setting:
return default return default
# Essayer de convertir en type approprié # Convert value based on expected type (basic handling)
value = setting.value if isinstance(default, int):
# Booléens
if value.lower() in ['true', 'false']:
return value.lower() == 'true'
# Nombres entiers
try:
return int(value)
except ValueError:
pass
# Nombres flottants
try:
return float(value)
except ValueError:
pass
# JSON
if value.startswith('{') or value.startswith('['):
try: try:
return json.loads(value) return int(setting.value)
except json.JSONDecodeError: except ValueError:
pass return default
elif isinstance(default, bool):
return setting.value.lower() == "true"
elif isinstance(default, list):
return setting.value.split(",")
# Liste séparée par des virgules return setting.value
if ',' in value and not value.startswith('{') and not value.startswith('['):
return [item.strip() for item in value.split(',')]
return value @staticmethod
def set_setting(db: Session, key: str, value: str, description: str = None, category: str = "general") -> SystemSettings:
"""Set a setting value."""
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
if setting:
setting.value = str(value)
if description:
setting.description = description
if category:
setting.category = category
else:
setting = SystemSettings(
key=key,
value=str(value),
description=description,
category=category
)
db.add(setting)
db.commit()
db.refresh(setting)
return setting
@staticmethod @staticmethod
def get_upload_limits(db: Session) -> Dict[str, Any]: def get_upload_limits(db: Session) -> Dict[str, Any]:
@@ -60,7 +64,7 @@ class SettingsService:
} }
@staticmethod @staticmethod
def get_max_upload_size(db: Session, content_type: str) -> int: def get_max_upload_size(db: Session, content_type: str, is_vlog: bool = False) -> int:
"""Get max upload size for a specific content type.""" """Get max upload size for a specific content type."""
if content_type.startswith('image/'): if content_type.startswith('image/'):
max_size_mb = SettingsService.get_setting(db, "max_image_size_mb", 10) max_size_mb = SettingsService.get_setting(db, "max_image_size_mb", 10)
@@ -68,9 +72,14 @@ class SettingsService:
print(f"DEBUG - Image upload limit: {max_size_mb}MB = {max_size_bytes} bytes") print(f"DEBUG - Image upload limit: {max_size_mb}MB = {max_size_bytes} bytes")
return max_size_bytes return max_size_bytes
elif content_type.startswith('video/'): elif content_type.startswith('video/'):
max_size_mb = SettingsService.get_setting(db, "max_video_size_mb", 100) if is_vlog:
max_size_mb = SettingsService.get_setting(db, "max_vlog_size_mb", 500)
print(f"DEBUG - Vlog upload limit: {max_size_mb}MB")
else:
max_size_mb = SettingsService.get_setting(db, "max_video_size_mb", 100)
print(f"DEBUG - Video upload limit: {max_size_mb}MB")
max_size_bytes = max_size_mb * 1024 * 1024 max_size_bytes = max_size_mb * 1024 * 1024
print(f"DEBUG - Video upload limit: {max_size_mb}MB = {max_size_bytes} bytes")
return max_size_bytes return max_size_bytes
else: else:
default_size = 10 * 1024 * 1024 # 10MB par défaut default_size = 10 * 1024 * 1024 # 10MB par défaut
@@ -79,14 +88,17 @@ class SettingsService:
@staticmethod @staticmethod
def is_file_type_allowed(db: Session, content_type: str) -> bool: def is_file_type_allowed(db: Session, content_type: str) -> bool:
"""Check if a file type is allowed.""" """Check if file type is allowed."""
if content_type.startswith('image/'): if content_type.startswith('image/'):
allowed_types = SettingsService.get_setting(db, "allowed_image_types", allowed = SettingsService.get_setting(db, "allowed_image_types",
["image/jpeg", "image/png", "image/gif", "image/webp"]) ["image/jpeg", "image/png", "image/gif", "image/webp"])
if isinstance(allowed, str):
allowed = allowed.split(",")
return content_type in allowed
elif content_type.startswith('video/'): elif content_type.startswith('video/'):
allowed_types = SettingsService.get_setting(db, "allowed_video_types", allowed = SettingsService.get_setting(db, "allowed_video_types",
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"]) ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
else: if isinstance(allowed, str):
return False allowed = allowed.split(",")
return content_type in allowed
return content_type in allowed_types return False

View File

@@ -2,29 +2,37 @@
<div class="video-player-container"> <div class="video-player-container">
<div class="relative"> <div class="relative">
<!-- Video.js Player --> <!-- Video.js Player -->
<video <div data-vjs-player>
ref="videoPlayer" <video
class="video-js vjs-default-skin vjs-big-play-centered w-full rounded-lg" ref="videoPlayer"
controls class="video-js vjs-default-skin vjs-big-play-centered w-full rounded-lg"
preload="auto" controls
:poster="posterUrl" preload="auto"
data-setup="{}" :poster="posterUrl"
> playsinline
<source :src="videoUrl" type="video/mp4" /> >
<p class="vjs-no-js"> <source :src="videoUrl" />
Pour voir cette vidéo, activez JavaScript et considérez passer à un navigateur web qui <p class="vjs-no-js">
<a href="https://videojs.com/html5-video-support/" target="_blank">supporte la vidéo HTML5</a>. Pour voir cette vidéo, activez JavaScript et considérez passer à un navigateur web qui
</p> <a href="https://videojs.com/html5-video-support/" target="_blank">supporte la vidéo HTML5</a>.
</video> </p>
</video>
</div>
</div> </div>
<!-- Video Stats --> <!-- Video Stats -->
<div class="mt-4 flex items-center justify-between text-sm text-gray-600"> <div class="mt-4 flex items-center justify-between text-sm text-gray-600">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<span class="flex items-center"> <div class="flex items-center space-x-3">
<Eye class="w-4 h-4 mr-1" /> <span class="flex items-center" title="Vues uniques">
{{ viewsCount }} vue{{ viewsCount > 1 ? 's' : '' }} <Eye class="w-4 h-4 mr-1" />
</span> {{ viewsCount }}
</span>
<span class="flex items-center" title="Replays">
<RotateCcw class="w-4 h-4 mr-1" />
{{ replaysCount }}
</span>
</div>
<span class="flex items-center"> <span class="flex items-center">
<Clock class="w-4 h-4 mr-1" /> <Clock class="w-4 h-4 mr-1" />
{{ formatDuration(duration) }} {{ formatDuration(duration) }}
@@ -57,7 +65,7 @@
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue' import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import videojs from 'video.js' import videojs from 'video.js'
import 'video.js/dist/video-js.css' import 'video.js/dist/video-js.css'
import { Eye, Clock, Heart, MessageSquare } from 'lucide-vue-next' import { Eye, Clock, Heart, MessageSquare, RotateCcw } from 'lucide-vue-next'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl } from '@/utils/axios'
const props = defineProps({ const props = defineProps({
@@ -85,6 +93,10 @@ const props = defineProps({
type: Number, type: Number,
default: 0 default: 0
}, },
replaysCount: {
type: Number,
default: 0
},
likesCount: { likesCount: {
type: Number, type: Number,
default: 0 default: 0
@@ -103,6 +115,7 @@ const emit = defineEmits(['like', 'toggle-comments'])
const videoPlayer = ref(null) const videoPlayer = ref(null)
const player = ref(null) const player = ref(null)
const currentVideoSrc = ref(null) // Track la source actuelle pour éviter les rechargements inutiles
// Computed properties pour les URLs // Computed properties pour les URLs
const videoUrl = computed(() => getMediaUrl(props.src)) const videoUrl = computed(() => getMediaUrl(props.src))
@@ -124,16 +137,62 @@ function toggleComments() {
emit('toggle-comments') emit('toggle-comments')
} }
// Fonction pour gérer les raccourcis clavier manuellement
function handleKeydown(e) {
if (!player.value) return;
// Ignorer si l'utilisateur tape dans un input
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
switch(e.key) {
case 'ArrowLeft':
e.preventDefault();
player.value.currentTime(Math.max(0, player.value.currentTime() - 10));
break;
case 'ArrowRight':
e.preventDefault();
player.value.currentTime(Math.min(player.value.duration(), player.value.currentTime() + 10));
break;
case ' ':
case 'Space': // Espace pour pause/play
e.preventDefault();
if (player.value.paused()) {
player.value.play();
} else {
player.value.pause();
}
break;
}
}
onMounted(() => { onMounted(() => {
if (videoPlayer.value) { if (videoPlayer.value) {
player.value = videojs(videoPlayer.value, { // Options de base pour Video.js
const options = {
controls: true, controls: true,
fluid: true, fluid: true,
responsive: true, responsive: true,
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2], playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
// Désactiver les hotkeys natifs qui causent l'erreur passive listener
userActions: {
hotkeys: false
},
html5: {
vhs: {
overrideNative: true
},
nativeAudioTracks: false,
nativeVideoTracks: false
},
controlBar: { controlBar: {
skipButtons: {
forward: 10,
backward: 10
},
children: [ children: [
'playToggle', 'playToggle',
'skipBackward',
'skipForward',
'volumePanel', 'volumePanel',
'currentTimeDisplay', 'currentTimeDisplay',
'timeDivider', 'timeDivider',
@@ -143,12 +202,23 @@ onMounted(() => {
'fullscreenToggle' 'fullscreenToggle'
] ]
} }
}) };
player.value = videojs(videoPlayer.value, options);
// Définir la source initiale après l'initialisation
if (videoUrl.value) {
player.value.src({ src: videoUrl.value, type: 'video/mp4' })
currentVideoSrc.value = videoUrl.value
}
// Error handling // Error handling
player.value.on('error', (error) => { player.value.on('error', (error) => {
console.error('Video.js error:', error) console.error('Video.js error:', error)
}) })
// Ajouter l'écouteur d'événements clavier global
document.addEventListener('keydown', handleKeydown)
} }
}) })
@@ -156,20 +226,39 @@ onBeforeUnmount(() => {
if (player.value) { if (player.value) {
player.value.dispose() player.value.dispose()
} }
// Retirer l'écouteur
document.removeEventListener('keydown', handleKeydown)
}) })
// Watch for src changes to reload video // Watch for src changes to reload video - amélioré pour éviter les rechargements inutiles
watch(() => props.src, () => { watch(() => videoUrl.value, (newUrl, oldUrl) => {
if (player.value && videoUrl.value) { // Ne recharger que si l'URL a vraiment changé et que le player est prêt
player.value.src({ src: videoUrl.value, type: 'video/mp4' }) if (player.value && newUrl && newUrl !== currentVideoSrc.value) {
const wasPlaying = !player.value.paused()
const currentTime = player.value.currentTime()
player.value.src({ src: newUrl, type: 'video/mp4' })
player.value.load() player.value.load()
currentVideoSrc.value = newUrl
// Restaurer la position si possible (optionnel)
player.value.ready(() => {
if (currentTime > 0 && currentTime < player.value.duration()) {
player.value.currentTime(currentTime)
}
if (wasPlaying) {
player.value.play().catch(() => {})
}
})
} }
}) }, { immediate: false })
</script> </script>
<style scoped> <style scoped>
.video-js { .video-js {
aspect-ratio: 16/9; aspect-ratio: 16/9;
/* Fix pour l'erreur "passive event listener" sur certains navigateurs */
touch-action: manipulation;
} }
.video-js .vjs-big-play-button { .video-js .vjs-big-play-button {

View File

@@ -122,12 +122,24 @@
</div> </div>
</transition> </transition>
</div> </div>
<!-- Mobile menu button -->
<div class="flex items-center sm:hidden ml-4">
<button
@click="isMobileMenuOpen = !isMobileMenuOpen"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
>
<span class="sr-only">Open main menu</span>
<Menu v-if="!isMobileMenuOpen" class="block h-6 w-6" aria-hidden="true" />
<X v-else class="block h-6 w-6" aria-hidden="true" />
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Mobile menu --> <!-- Mobile menu -->
<div class="sm:hidden"> <div class="sm:hidden border-t border-gray-200" v-show="isMobileMenuOpen">
<div class="pt-2 pb-3 space-y-1"> <div class="pt-2 pb-3 space-y-1">
<router-link <router-link
v-for="item in navigation" v-for="item in navigation"
@@ -229,7 +241,8 @@ import {
Bell, Bell,
User, User,
ChevronDown, ChevronDown,
X X,
Menu
} from 'lucide-vue-next' } from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import TicketFloatingButton from '@/components/TicketFloatingButton.vue' import TicketFloatingButton from '@/components/TicketFloatingButton.vue'
@@ -247,6 +260,7 @@ const navigation = [
const showUserMenu = ref(false) const showUserMenu = ref(false)
const showNotifications = ref(false) const showNotifications = ref(false)
const isMobileMenuOpen = ref(false)
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const notifications = computed(() => authStore.notifications) const notifications = computed(() => authStore.notifications)

View File

@@ -18,30 +18,30 @@
<div v-else> <div v-else>
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-4"> <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 space-y-4 sm:space-y-0">
<router-link to="/albums" class="text-primary-600 hover:text-primary-700 flex items-center"> <router-link to="/albums" class="text-primary-600 hover:text-primary-700 flex items-center">
<ArrowLeft class="w-4 h-4 mr-2" /> <ArrowLeft class="w-4 h-4 mr-2" />
Retour aux albums Retour aux albums
</router-link> </router-link>
<div v-if="canEdit" class="flex space-x-2"> <div v-if="canEdit" class="flex flex-wrap gap-2 w-full sm:w-auto">
<button <button
@click="showEditModal = true" @click="showEditModal = true"
class="btn-secondary" class="flex-1 sm:flex-none btn-secondary justify-center"
> >
<Edit class="w-4 h-4 mr-2" /> <Edit class="w-4 h-4 mr-2" />
Modifier Modifier
</button> </button>
<button <button
@click="showUploadModal = true" @click="showUploadModal = true"
class="btn-primary" class="flex-1 sm:flex-none btn-primary justify-center"
> >
<Upload class="w-4 h-4 mr-2" /> <Upload class="w-4 h-4 mr-2" />
Ajouter des médias Ajouter
</button> </button>
<button <button
@click="deleteAlbum" @click="deleteAlbum"
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300" class="flex-1 sm:flex-none btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300 justify-center"
> >
<Trash2 class="w-4 h-4 mr-2" /> <Trash2 class="w-4 h-4 mr-2" />
Supprimer Supprimer
@@ -49,9 +49,9 @@
</div> </div>
</div> </div>
<div class="flex items-start space-x-6"> <div class="flex flex-col md:flex-row items-start space-y-6 md:space-y-0 md:space-x-6">
<!-- Cover Image --> <!-- 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"> <div class="w-full md:w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center flex-shrink-0">
<Image v-if="!album.cover_image" class="w-16 h-16 text-white" /> <Image v-if="!album.cover_image" class="w-16 h-16 text-white" />
<img <img
v-else v-else

View File

@@ -1,14 +1,14 @@
<template> <template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-center mb-8"> <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> <div>
<h1 class="text-3xl font-bold text-gray-900">Albums photos</h1> <h1 class="text-2xl sm: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> <p class="text-sm sm:text-base text-gray-600 mt-1">Partagez vos souvenirs en photos et vidéos</p>
</div> </div>
<button <button
@click="showCreateModal = true" @click="showCreateModal = true"
class="btn-primary" class="w-full sm:w-auto btn-primary justify-center"
> >
<Plus class="w-4 h-4 mr-2" /> <Plus class="w-4 h-4 mr-2" />
Nouvel album Nouvel album

View File

@@ -18,23 +18,23 @@
<div v-else> <div v-else>
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-4"> <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 space-y-4 sm:space-y-0">
<router-link to="/events" class="text-primary-600 hover:text-primary-700 flex items-center"> <router-link to="/events" class="text-primary-600 hover:text-primary-700 flex items-center">
<ArrowLeft class="w-4 h-4 mr-2" /> <ArrowLeft class="w-4 h-4 mr-2" />
Retour aux événements Retour aux événements
</router-link> </router-link>
<div v-if="canEdit" class="flex space-x-2"> <div v-if="canEdit" class="flex w-full sm:w-auto space-x-2">
<button <button
@click="showEditModal = true" @click="showEditModal = true"
class="btn-secondary" class="flex-1 sm:flex-none btn-secondary justify-center"
> >
<Edit class="w-4 h-4 mr-2" /> <Edit class="w-4 h-4 mr-2" />
Modifier Modifier
</button> </button>
<button <button
@click="deleteEvent" @click="deleteEvent"
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300" class="flex-1 sm:flex-none btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300 justify-center"
> >
<Trash2 class="w-4 h-4 mr-2" /> <Trash2 class="w-4 h-4 mr-2" />
Supprimer Supprimer
@@ -42,9 +42,9 @@
</div> </div>
</div> </div>
<div class="flex items-start space-x-6"> <div class="flex flex-col md:flex-row items-start space-y-6 md:space-y-0 md:space-x-6">
<!-- Cover Image --> <!-- 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"> <div class="w-full md:w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center flex-shrink-0">
<img <img
v-if="!event.cover_image && event.creator_avatar" v-if="!event.cover_image && event.creator_avatar"
:src="getMediaUrl(event.creator_avatar)" :src="getMediaUrl(event.creator_avatar)"
@@ -122,25 +122,6 @@
<p class="text-gray-700 whitespace-pre-wrap">{{ event.description }}</p> <p class="text-gray-700 whitespace-pre-wrap">{{ event.description }}</p>
</div> </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 --> <!-- My Participation -->
<div class="card p-6 mb-8"> <div class="card p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Ma participation</h2> <h2 class="text-xl font-semibold text-gray-900 mb-4">Ma participation</h2>

View File

@@ -1,14 +1,14 @@
<template> <template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-center mb-8"> <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> <div>
<h1 class="text-3xl font-bold text-gray-900">Événements</h1> <h1 class="text-2xl sm: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> <p class="text-sm sm:text-base text-gray-600 mt-1">Organisez et participez aux événements du groupe</p>
</div> </div>
<button <button
@click="showCreateModal = true" @click="showCreateModal = true"
class="btn-primary" class="w-full sm:w-auto btn-primary justify-center"
> >
<Plus class="w-4 h-4 mr-2" /> <Plus class="w-4 h-4 mr-2" />
Nouvel événement Nouvel événement
@@ -214,51 +214,6 @@
> >
</div> </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 class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="label">Date et heure</label> <label class="label">Date et heure</label>
@@ -339,8 +294,6 @@ const newEvent = ref({
description: '', description: '',
date: '', date: '',
location: '', location: '',
latitude: null,
longitude: null,
end_date: null end_date: null
}) })
@@ -453,8 +406,6 @@ async function createEvent() {
description: newEvent.value.description, description: newEvent.value.description,
date: new Date(newEvent.value.date).toISOString(), date: new Date(newEvent.value.date).toISOString(),
location: newEvent.value.location, 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 end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null
} }
@@ -478,8 +429,6 @@ function resetForm() {
description: '', description: '',
date: '', date: '',
location: '', location: '',
latitude: null,
longitude: null,
end_date: null end_date: null
} }
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8">
<!-- Welcome Section --> <!-- Welcome Section -->
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2"> <h1 class="text-3xl font-bold text-gray-900 mb-2">
@@ -9,8 +9,8 @@
</div> </div>
<!-- Quick Stats --> <!-- Quick Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8"> <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="card p-6"> <div class="card p-4 sm:p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-gray-600">Prochain événement</p> <p class="text-sm text-gray-600">Prochain événement</p>
@@ -23,7 +23,7 @@
</div> </div>
</div> </div>
<div class="card p-6"> <div class="card p-4 sm:p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-gray-600">Taux de présence</p> <p class="text-sm text-gray-600">Taux de présence</p>
@@ -33,7 +33,7 @@
</div> </div>
</div> </div>
<div class="card p-6"> <div class="card p-4 sm:p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-gray-600">Nouveaux posts</p> <p class="text-sm text-gray-600">Nouveaux posts</p>
@@ -44,7 +44,7 @@
</div> </div>
</div> </div>
<div class="card p-6"> <div class="card p-4 sm:p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-gray-600">Membres actifs</p> <p class="text-sm text-gray-600">Membres actifs</p>
@@ -59,17 +59,17 @@
<!-- Recent Posts --> <!-- Recent Posts -->
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<div class="card"> <div class="card">
<div class="p-6 border-b border-gray-100"> <div class="p-4 sm:p-6 border-b border-gray-100">
<h2 class="text-lg font-semibold text-gray-900">Publications récentes</h2> <h2 class="text-lg font-semibold text-gray-900">Publications récentes</h2>
</div> </div>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
<div v-if="posts.length === 0" class="p-6 text-center text-gray-500"> <div v-if="posts.length === 0" class="p-4 sm:p-6 text-center text-gray-500">
Aucune publication récente Aucune publication récente
</div> </div>
<div <div
v-for="post in posts" v-for="post in posts"
:key="post.id" :key="post.id"
class="p-6 hover:bg-gray-50 transition-colors" class="p-4 sm:p-6 hover:bg-gray-50 transition-colors"
> >
<div class="flex items-start space-x-3"> <div class="flex items-start space-x-3">
<img <img
@@ -133,11 +133,11 @@
<!-- Upcoming Events --> <!-- Upcoming Events -->
<div> <div>
<div class="card"> <div class="card">
<div class="p-6 border-b border-gray-100"> <div class="p-4 sm:p-6 border-b border-gray-100">
<h2 class="text-lg font-semibold text-gray-900">Événements à venir</h2> <h2 class="text-lg font-semibold text-gray-900">Événements à venir</h2>
</div> </div>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
<div v-if="upcomingEvents.length === 0" class="p-6 text-center text-gray-500"> <div v-if="upcomingEvents.length === 0" class="p-4 sm:p-6 text-center text-gray-500">
Aucun événement prévu Aucun événement prévu
</div> </div>
<router-link <router-link
@@ -171,11 +171,11 @@
<!-- Recent Vlogs --> <!-- Recent Vlogs -->
<div class="card mt-6"> <div class="card mt-6">
<div class="p-6 border-b border-gray-100"> <div class="p-4 sm:p-6 border-b border-gray-100">
<h2 class="text-lg font-semibold text-gray-900">Derniers vlogs</h2> <h2 class="text-lg font-semibold text-gray-900">Derniers vlogs</h2>
</div> </div>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
<div v-if="recentVlogs.length === 0" class="p-6 text-center text-gray-500"> <div v-if="recentVlogs.length === 0" class="p-4 sm:p-6 text-center text-gray-500">
Aucun vlog récent Aucun vlog récent
</div> </div>
<router-link <router-link

View File

@@ -1,14 +1,14 @@
<template> <template>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-center mb-8"> <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> <div>
<h1 class="text-3xl font-bold text-gray-900">Publications</h1> <h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Publications</h1>
<p class="text-gray-600 mt-1">Partagez vos moments avec le groupe</p> <p class="text-sm sm:text-base text-gray-600 mt-1">Partagez vos moments avec le groupe</p>
</div> </div>
<button <button
@click="showCreateModal = true" @click="showCreateModal = true"
class="btn-primary" class="w-full sm:w-auto btn-primary justify-center"
> >
<Plus class="w-4 h-4 mr-2" /> <Plus class="w-4 h-4 mr-2" />
Nouvelle publication Nouvelle publication
@@ -16,7 +16,7 @@
</div> </div>
<!-- Create Post Form --> <!-- Create Post Form -->
<div class="card p-6 mb-8"> <div class="card p-4 sm:p-6 mb-8">
<div class="flex items-start space-x-3"> <div class="flex items-start space-x-3">
<img <img
v-if="user?.avatar_url" v-if="user?.avatar_url"
@@ -99,7 +99,7 @@
<div <div
v-for="post in posts" v-for="post in posts"
:key="post.id" :key="post.id"
class="card p-6" class="card p-4 sm:p-6"
> >
<!-- Post Header --> <!-- Post Header -->
<div class="flex items-start space-x-3 mb-4"> <div class="flex items-start space-x-3 mb-4">

View File

@@ -18,23 +18,23 @@
<div v-else> <div v-else>
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-4"> <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 space-y-4 sm:space-y-0">
<router-link to="/vlogs" class="text-primary-600 hover:text-primary-700 flex items-center"> <router-link to="/vlogs" class="text-primary-600 hover:text-primary-700 flex items-center">
<ArrowLeft class="w-4 h-4 mr-2" /> <ArrowLeft class="w-4 h-4 mr-2" />
Retour aux vlogs Retour aux vlogs
</router-link> </router-link>
<div v-if="canEdit" class="flex space-x-2"> <div v-if="canEdit" class="flex w-full sm:w-auto space-x-2">
<button <button
@click="showEditModal = true" @click="showEditModal = true"
class="btn-secondary" class="flex-1 sm:flex-none btn-secondary justify-center"
> >
<Edit class="w-4 h-4 mr-2" /> <Edit class="w-4 h-4 mr-2" />
Modifier Modifier
</button> </button>
<button <button
@click="deleteVlog" @click="deleteVlog"
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300" class="flex-1 sm:flex-none btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300 justify-center"
> >
<Trash2 class="w-4 h-4 mr-2" /> <Trash2 class="w-4 h-4 mr-2" />
Supprimer Supprimer
@@ -42,9 +42,9 @@
</div> </div>
</div> </div>
<h1 class="text-4xl font-bold text-gray-900 mb-4">{{ vlog.title }}</h1> <h1 class="text-2xl sm: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 flex-wrap items-center gap-4 text-sm sm:text-base text-gray-600 mb-6">
<div class="flex items-center"> <div class="flex items-center">
<img <img
v-if="vlog.author_avatar" v-if="vlog.author_avatar"
@@ -52,10 +52,10 @@
:alt="vlog.author_name" :alt="vlog.author_name"
class="w-8 h-8 rounded-full object-cover mr-3" 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"> <div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center mr-2">
<User class="w-4 h-4 text-primary-600" /> <User class="w-4 h-4 text-primary-600" />
</div> </div>
<span>Par {{ vlog.author_name }}</span> <span>{{ vlog.author_name }}</span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
@@ -68,6 +68,11 @@
<span>{{ vlog.views_count }} vue{{ vlog.views_count > 1 ? 's' : '' }}</span> <span>{{ vlog.views_count }} vue{{ vlog.views_count > 1 ? 's' : '' }}</span>
</div> </div>
<div class="flex items-center">
<RotateCcw class="w-4 h-4 mr-2" />
<span>{{ vlog.replays_count }} replay{{ vlog.replays_count > 1 ? 's' : '' }}</span>
</div>
<div v-if="vlog.duration" class="flex items-center"> <div v-if="vlog.duration" class="flex items-center">
<Clock class="w-4 h-4 mr-2" /> <Clock class="w-4 h-4 mr-2" />
<span>{{ formatDuration(vlog.duration) }}</span> <span>{{ formatDuration(vlog.duration) }}</span>
@@ -76,7 +81,7 @@
</div> </div>
<!-- Video Player --> <!-- Video Player -->
<div class="card p-6 mb-8"> <div class="card p-0 sm:p-6 mb-8 overflow-hidden sm:overflow-visible">
<VideoPlayer <VideoPlayer
:src="vlog.video_url" :src="vlog.video_url"
:poster="vlog.thumbnail_url" :poster="vlog.thumbnail_url"
@@ -84,6 +89,7 @@
:description="vlog.description" :description="vlog.description"
:duration="vlog.duration" :duration="vlog.duration"
:views-count="vlog.views_count" :views-count="vlog.views_count"
:replays-count="vlog.replays_count"
:likes-count="vlog.likes_count" :likes-count="vlog.likes_count"
:comments-count="vlog.comments?.length || 0" :comments-count="vlog.comments?.length || 0"
:is-liked="vlog.is_liked" :is-liked="vlog.is_liked"
@@ -93,13 +99,13 @@
</div> </div>
<!-- Description --> <!-- Description -->
<div v-if="vlog.description" class="card p-6 mb-8"> <div v-if="vlog.description" class="card p-4 sm:p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Description</h2> <h2 class="text-xl font-semibold text-gray-900 mb-4">Description</h2>
<p class="text-gray-700 whitespace-pre-wrap">{{ vlog.description }}</p> <p class="text-gray-700 whitespace-pre-wrap">{{ vlog.description }}</p>
</div> </div>
<!-- Comments Section --> <!-- Comments Section -->
<div class="card p-6 mb-8"> <div class="card p-4 sm:p-6 mb-8">
<VlogComments <VlogComments
:vlog-id="vlog.id" :vlog-id="vlog.id"
:comments="vlog.comments || []" :comments="vlog.comments || []"
@@ -233,7 +239,8 @@ import {
Edit, Edit,
Trash2, Trash2,
Film, Film,
Play Play,
RotateCcw
} from 'lucide-vue-next' } from 'lucide-vue-next'
import VideoPlayer from '@/components/VideoPlayer.vue' import VideoPlayer from '@/components/VideoPlayer.vue'
import VlogComments from '@/components/VlogComments.vue' import VlogComments from '@/components/VlogComments.vue'
@@ -279,8 +286,9 @@ function formatDuration(seconds) {
async function toggleLike() { async function toggleLike() {
try { try {
const response = await axios.post(`/api/vlogs/${vlog.value.id}/like`) const response = await axios.post(`/api/vlogs/${vlog.value.id}/like`)
// Refresh vlog data to get updated like count // Update local state without refreshing full vlog data (avoiding view increment)
await fetchVlog() vlog.value.likes_count = response.data.likes_count
vlog.value.is_liked = !vlog.value.is_liked
toast.success(response.data.message) toast.success(response.data.message)
} catch (error) { } catch (error) {
toast.error('Erreur lors de la mise à jour du like') toast.error('Erreur lors de la mise à jour du like')

View File

@@ -1,14 +1,14 @@
<template> <template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-center mb-8"> <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> <div>
<h1 class="text-3xl font-bold text-gray-900">Vlogs</h1> <h1 class="text-2xl sm: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> <p class="text-sm sm:text-base text-gray-600 mt-1">Partagez vos moments en vidéo avec le groupe</p>
</div> </div>
<button <button
@click="showCreateModal = true" @click="showCreateModal = true"
class="btn-primary" class="w-full sm:w-auto btn-primary justify-center"
> >
<Plus class="w-4 h-4 mr-2" /> <Plus class="w-4 h-4 mr-2" />
Nouveau vlog Nouveau vlog

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "LeDiscord",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

1
package.json Normal file
View File

@@ -0,0 +1 @@
{}