From 0020c13bfd57103d8f6956b6f2a5fdfd2e0b6228 Mon Sep 17 00:00:00 2001 From: EvanChal Date: Sun, 25 Jan 2026 18:08:38 +0100 Subject: [PATCH 1/2] fix(video-player): fix the video player to permit the nagivation through the video (it was because de fast api server refused range request) --- backend/api/routers/vlogs.py | 35 ++++-- backend/app.py | 86 ++++++++++++++- backend/models/vlog.py | 23 +++- backend/schemas/vlog.py | 1 + backend/utils/settings_service.py | 98 ++++++++-------- frontend/src/components/VideoPlayer.vue | 141 +++++++++++++++++++----- frontend/src/layouts/DefaultLayout.vue | 18 ++- frontend/src/views/AlbumDetail.vue | 16 +-- frontend/src/views/Albums.vue | 8 +- frontend/src/views/EventDetail.vue | 31 +----- frontend/src/views/Events.vue | 59 +--------- frontend/src/views/Home.vue | 26 ++--- frontend/src/views/Posts.vue | 12 +- frontend/src/views/VlogDetail.vue | 36 +++--- frontend/src/views/Vlogs.vue | 8 +- package-lock.json | 6 + package.json | 1 + 17 files changed, 393 insertions(+), 212 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json diff --git a/backend/api/routers/vlogs.py b/backend/api/routers/vlogs.py index 0482f2b..7fea8fd 100644 --- a/backend/api/routers/vlogs.py +++ b/backend/api/routers/vlogs.py @@ -6,7 +6,7 @@ import uuid from pathlib import Path from config.database import get_db 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 schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate from utils.security import get_current_active_user @@ -56,8 +56,21 @@ async def get_vlog( detail="Vlog not found" ) - # Increment view count - vlog.views_count += 1 + # Manage views and replays + 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() return format_vlog_response(vlog, db, current_user.id) @@ -149,10 +162,15 @@ async def toggle_vlog_like( message = "Like removed" else: # Like - like = VlogLike(vlog_id=vlog_id, user_id=current_user.id) - db.add(like) - vlog.likes_count += 1 - message = "Vlog liked" + try: + like = VlogLike(vlog_id=vlog_id, user_id=current_user.id) + db.add(like) + 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() return {"message": message, "likes_count": vlog.likes_count} @@ -244,7 +262,7 @@ async def upload_vlog_video( # Check file size 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: max_size_mb = max_size // (1024 * 1024) raise HTTPException( @@ -363,6 +381,7 @@ def format_vlog_response(vlog: Vlog, db: Session, current_user_id: int) -> dict: "thumbnail_url": vlog.thumbnail_url, "duration": vlog.duration, "views_count": vlog.views_count, + "replays_count": vlog.replays_count, "likes_count": vlog.likes_count, "created_at": vlog.created_at, "updated_at": vlog.updated_at, diff --git a/backend/app.py b/backend/app.py index a56c125..6cf6a59 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,9 +1,10 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request, Response, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from contextlib import asynccontextmanager import os from pathlib import Path +import mimetypes from config.settings import settings from config.database import engine, Base @@ -198,10 +199,89 @@ app.add_middleware( allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=["Content-Range", "Accept-Ranges"], ) -# Mount static files for uploads -app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_PATH), name="uploads") +# Endpoint personnalisé pour servir les vidéos avec support Range +@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 app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) diff --git a/backend/models/vlog.py b/backend/models/vlog.py index f17b4de..6ebd68f 100644 --- a/backend/models/vlog.py +++ b/backend/models/vlog.py @@ -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 datetime import datetime from config.database import Base @@ -14,6 +14,7 @@ class Vlog(Base): thumbnail_url = Column(String) duration = Column(Integer) # in seconds views_count = Column(Integer, default=0) + replays_count = Column(Integer, default=0) likes_count = Column(Integer, default=0) created_at = Column(DateTime, default=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") user = relationship("User", back_populates="vlog_likes") + __table_args__ = ( + UniqueConstraint('vlog_id', 'user_id', name='_vlog_user_like_uc'), + ) + class VlogComment(Base): __tablename__ = "vlog_comments" @@ -48,3 +53,19 @@ class VlogComment(Base): # Relationships vlog = relationship("Vlog", back_populates="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'), + ) diff --git a/backend/schemas/vlog.py b/backend/schemas/vlog.py index 3d4bd7c..822df2e 100644 --- a/backend/schemas/vlog.py +++ b/backend/schemas/vlog.py @@ -49,6 +49,7 @@ class VlogResponse(VlogBase): thumbnail_url: Optional[str] duration: Optional[int] views_count: int + replays_count: int = 0 likes_count: int created_at: datetime updated_at: datetime diff --git a/backend/utils/settings_service.py b/backend/utils/settings_service.py index e18125b..b7f8acc 100644 --- a/backend/utils/settings_service.py +++ b/backend/utils/settings_service.py @@ -1,49 +1,53 @@ +import os +from typing import Any, Dict, List from sqlalchemy.orm import Session from models.settings import SystemSettings -from typing import Optional, Dict, Any -import json class SettingsService: """Service for managing system settings.""" @staticmethod 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() if not setting: return default - - # Essayer de convertir en type approprié - value = setting.value - - # 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('['): + + # Convert value based on expected type (basic handling) + if isinstance(default, int): try: - return json.loads(value) - except json.JSONDecodeError: - pass + return int(setting.value) + except ValueError: + return default + elif isinstance(default, bool): + return setting.value.lower() == "true" + elif isinstance(default, list): + return setting.value.split(",") + + return setting.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) - # Liste séparée par des virgules - if ',' in value and not value.startswith('{') and not value.startswith('['): - return [item.strip() for item in value.split(',')] - - return value + db.commit() + db.refresh(setting) + return setting @staticmethod def get_upload_limits(db: Session) -> Dict[str, Any]: @@ -60,7 +64,7 @@ class SettingsService: } @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.""" if content_type.startswith('image/'): 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") return max_size_bytes 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 - print(f"DEBUG - Video upload limit: {max_size_mb}MB = {max_size_bytes} bytes") return max_size_bytes else: default_size = 10 * 1024 * 1024 # 10MB par défaut @@ -79,14 +88,17 @@ class SettingsService: @staticmethod 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/'): - 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"]) + if isinstance(allowed, str): + allowed = allowed.split(",") + return content_type in allowed 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"]) - else: - return False - - return content_type in allowed_types + if isinstance(allowed, str): + allowed = allowed.split(",") + return content_type in allowed + return False diff --git a/frontend/src/components/VideoPlayer.vue b/frontend/src/components/VideoPlayer.vue index 3cb45c0..05b3cd1 100644 --- a/frontend/src/components/VideoPlayer.vue +++ b/frontend/src/components/VideoPlayer.vue @@ -2,29 +2,37 @@
- +
+ +
- - - {{ viewsCount }} vue{{ viewsCount > 1 ? 's' : '' }} - +
+ + + {{ viewsCount }} + + + + {{ replaysCount }} + +
{{ formatDuration(duration) }} @@ -57,7 +65,7 @@ import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue' import videojs from 'video.js' 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' const props = defineProps({ @@ -85,6 +93,10 @@ const props = defineProps({ type: Number, default: 0 }, + replaysCount: { + type: Number, + default: 0 + }, likesCount: { type: Number, default: 0 @@ -103,6 +115,7 @@ const emit = defineEmits(['like', 'toggle-comments']) const videoPlayer = ref(null) const player = ref(null) +const currentVideoSrc = ref(null) // Track la source actuelle pour éviter les rechargements inutiles // Computed properties pour les URLs const videoUrl = computed(() => getMediaUrl(props.src)) @@ -124,16 +137,62 @@ function toggleComments() { 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(() => { if (videoPlayer.value) { - player.value = videojs(videoPlayer.value, { + // Options de base pour Video.js + const options = { controls: true, fluid: true, responsive: true, 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: { + skipButtons: { + forward: 10, + backward: 10 + }, children: [ 'playToggle', + 'skipBackward', + 'skipForward', 'volumePanel', 'currentTimeDisplay', 'timeDivider', @@ -143,12 +202,23 @@ onMounted(() => { '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 player.value.on('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) { player.value.dispose() } + // Retirer l'écouteur + document.removeEventListener('keydown', handleKeydown) }) -// Watch for src changes to reload video -watch(() => props.src, () => { - if (player.value && videoUrl.value) { - player.value.src({ src: videoUrl.value, type: 'video/mp4' }) +// Watch for src changes to reload video - amélioré pour éviter les rechargements inutiles +watch(() => videoUrl.value, (newUrl, oldUrl) => { + // Ne recharger que si l'URL a vraiment changé et que le player est prêt + 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() + 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 }) + diff --git a/frontend/src/layouts/AuthLayout.vue b/frontend/src/layouts/AuthLayout.vue index f00515f..04bfd65 100644 --- a/frontend/src/layouts/AuthLayout.vue +++ b/frontend/src/layouts/AuthLayout.vue @@ -1,20 +1,20 @@ \ No newline at end of file diff --git a/frontend/src/services/notificationService.js b/frontend/src/services/notificationService.js new file mode 100644 index 0000000..83df873 --- /dev/null +++ b/frontend/src/services/notificationService.js @@ -0,0 +1,159 @@ +import { useAuthStore } from '@/stores/auth' + +class NotificationService { + constructor() { + this.pollingInterval = null + this.pollInterval = 30000 // 30 secondes + this.isPolling = false + } + + startPolling() { + if (this.isPolling) return + + const authStore = useAuthStore() + if (!authStore.isAuthenticated) return + + this.isPolling = true + + // Récupérer immédiatement + this.fetchNotifications() + + // Puis toutes les 30 secondes + this.pollingInterval = setInterval(() => { + this.fetchNotifications() + }, this.pollInterval) + } + + stopPolling() { + if (this.pollingInterval) { + clearInterval(this.pollingInterval) + this.pollingInterval = null + } + this.isPolling = false + } + + async fetchNotifications() { + const authStore = useAuthStore() + if (!authStore.isAuthenticated) { + this.stopPolling() + return + } + + try { + const result = await authStore.fetchNotifications() + + // Si de nouvelles notifications non lues ont été détectées + if (result && result.hasNewNotifications && result.newCount > result.previousCount) { + // Trouver les nouvelles notifications non lues (les plus récentes en premier) + const newUnreadNotifications = authStore.notifications + .filter(n => !n.is_read) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) + .slice(0, result.newCount - result.previousCount) + + if (newUnreadNotifications.length > 0) { + // Afficher une notification push pour la plus récente + const latestNotification = newUnreadNotifications[0] + await this.showPushNotification(latestNotification.title, { + body: latestNotification.message, + link: latestNotification.link || '/', + data: { notificationId: latestNotification.id } + }) + } + } + } catch (error) { + console.error('Error polling notifications:', error) + } + } + + showNotificationBadge() { + // Mettre à jour le badge du titre de la page + if ('Notification' in window && Notification.permission === 'granted') { + // La notification push sera gérée par le service worker + return + } + } + + // Gestion des notifications push PWA + async requestNotificationPermission() { + if (!('Notification' in window)) { + console.log('Ce navigateur ne supporte pas les notifications') + return false + } + + if (Notification.permission === 'granted') { + return true + } + + if (Notification.permission !== 'denied') { + const permission = await Notification.requestPermission() + return permission === 'granted' + } + + return false + } + + async showPushNotification(title, options = {}) { + if (!('Notification' in window)) return + + const hasPermission = await this.requestNotificationPermission() + if (!hasPermission) return + + // Si on est dans un service worker, utiliser la notification API du SW + if ('serviceWorker' in navigator) { + try { + const registration = await navigator.serviceWorker.ready + await registration.showNotification(title, { + icon: '/icon-192x192.png', + badge: '/icon-96x96.png', + tag: 'lediscord-notification', + requireInteraction: false, + vibrate: [200, 100, 200], + ...options + }) + return + } catch (error) { + console.error('Error showing notification via service worker:', error) + } + } + + // Fallback: notification native du navigateur + const notification = new Notification(title, { + icon: '/icon-192x192.png', + badge: '/icon-96x96.png', + tag: 'lediscord-notification', + requireInteraction: false, + ...options + }) + + notification.onclick = () => { + window.focus() + notification.close() + + if (options.link) { + window.location.href = options.link + } + } + + // Fermer automatiquement après 5 secondes + setTimeout(() => { + notification.close() + }, 5000) + + return notification + } + + // Écouter les messages du service worker pour les notifications push + setupServiceWorkerListener() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data && event.data.type === 'NOTIFICATION') { + const { title, options } = event.data + this.showPushNotification(title, options) + } + }) + } + } +} + +export default new NotificationService() + diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 110ec23..546e1dd 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -68,8 +68,15 @@ export const useAuthStore = defineStore('auth', () => { async function logout() { token.value = null user.value = null + notifications.value = [] + unreadCount.value = 0 localStorage.removeItem('token') delete axios.defaults.headers.common['Authorization'] + + // Arrêter le polling des notifications + const notificationService = (await import('@/services/notificationService')).default + notificationService.stopPolling() + router.push('/login') toast.info('Déconnexion réussie') } @@ -130,10 +137,36 @@ export const useAuthStore = defineStore('auth', () => { try { const response = await axios.get('/api/notifications?limit=50') - notifications.value = response.data - unreadCount.value = notifications.value.filter(n => !n.is_read).length + const newNotifications = response.data + + // Détecter les nouvelles notifications non lues + const previousIds = new Set(notifications.value.map(n => n.id)) + const previousUnreadIds = new Set( + notifications.value.filter(n => !n.is_read).map(n => n.id) + ) + + // Nouvelles notifications = celles qui n'existaient pas avant + const hasNewNotifications = newNotifications.some(n => !previousIds.has(n.id)) + + // Nouvelles notifications non lues = nouvelles ET non lues + const newUnreadNotifications = newNotifications.filter( + n => !previousIds.has(n.id) && !n.is_read + ) + + notifications.value = newNotifications + const newUnreadCount = notifications.value.filter(n => !n.is_read).length + const previousUnreadCount = unreadCount.value + unreadCount.value = newUnreadCount + + // Retourner si de nouvelles notifications non lues ont été détectées + return { + hasNewNotifications: newUnreadNotifications.length > 0, + newCount: newUnreadCount, + previousCount: previousUnreadCount + } } catch (error) { console.error('Error fetching notifications:', error) + return { hasNewNotifications: false, newCount: unreadCount.value, previousCount: unreadCount.value } } } diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue index b3ce4cc..97f729a 100644 --- a/frontend/src/views/Register.vue +++ b/frontend/src/views/Register.vue @@ -1,144 +1,333 @@ diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 056c285..ae7175d 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -2,6 +2,15 @@ const { defineConfig } = require('vite') const vue = require('@vitejs/plugin-vue') const path = require('path') +// Import conditionnel du plugin PWA +let VitePWA = null +try { + VitePWA = require('vite-plugin-pwa').VitePWA +} catch (e) { + console.warn('⚠️ vite-plugin-pwa n\'est pas installé. La fonctionnalité PWA sera désactivée.') + console.warn(' Installez-le avec: npm install --save-dev vite-plugin-pwa') +} + // Configuration par environnement const getEnvironmentConfig = (mode) => { const configs = { @@ -79,8 +88,163 @@ module.exports = defineConfig(({ command, mode }) => { VITE_APP_URL: process.env.VITE_APP_URL }) + const plugins = [vue()] + + // Ajouter le plugin PWA seulement s'il est installé + if (VitePWA) { + plugins.push(VitePWA({ + registerType: 'autoUpdate', + includeAssets: ['favicon.ico', 'logo_lediscord.png'], + manifest: { + name: 'LeDiscord - Notre espace', + short_name: 'LeDiscord', + description: 'Plateforme communautaire LeDiscord', + theme_color: '#6366f1', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait-primary', + scope: '/', + start_url: '/', + icons: [ + { + src: '/icon-72x72.png', + sizes: '72x72', + type: 'image/png', + purpose: 'any maskable' + }, + { + src: '/icon-96x96.png', + sizes: '96x96', + type: 'image/png', + purpose: 'any maskable' + }, + { + src: '/icon-128x128.png', + sizes: '128x128', + type: 'image/png', + purpose: 'any maskable' + }, + { + src: '/icon-144x144.png', + sizes: '144x144', + type: 'image/png', + purpose: 'any maskable' + }, + { + src: '/icon-152x152.png', + sizes: '152x152', + type: 'image/png', + purpose: 'any maskable' + }, + { + src: '/icon-192x192.png', + sizes: '192x192', + type: 'image/png', + purpose: 'any maskable' + }, + { + src: '/icon-384x384.png', + sizes: '384x384', + type: 'image/png', + purpose: 'any maskable' + }, + { + src: '/icon-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any maskable' + } + ], + shortcuts: [ + { + name: 'Vlogs', + short_name: 'Vlogs', + description: 'Voir les vlogs', + url: '/vlogs', + icons: [{ src: '/icon-96x96.png', sizes: '96x96' }] + }, + { + name: 'Albums', + short_name: 'Albums', + description: 'Voir les albums', + url: '/albums', + icons: [{ src: '/icon-96x96.png', sizes: '96x96' }] + } + ], + categories: ['social', 'entertainment'] + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,mp4}'], + // Notifications push + navigateFallback: null, + skipWaiting: true, + clientsClaim: true, + runtimeCaching: [ + { + urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, + handler: 'CacheFirst', + options: { + cacheName: 'google-fonts-cache', + expiration: { + maxEntries: 10, + maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year + }, + cacheableResponse: { + statuses: [0, 200] + } + } + }, + { + urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, + handler: 'CacheFirst', + options: { + cacheName: 'gstatic-fonts-cache', + expiration: { + maxEntries: 10, + maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year + }, + cacheableResponse: { + statuses: [0, 200] + } + } + }, + { + urlPattern: /^https?:\/\/.*\/api\/.*/i, + handler: 'NetworkFirst', + options: { + cacheName: 'api-cache', + expiration: { + maxEntries: 50, + maxAgeSeconds: 60 * 5 // 5 minutes + }, + networkTimeoutSeconds: 10 + } + }, + { + urlPattern: /^https?:\/\/.*\/uploads\/.*/i, + handler: 'CacheFirst', + options: { + cacheName: 'uploads-cache', + expiration: { + maxEntries: 100, + maxAgeSeconds: 60 * 60 * 24 * 7 // 7 days + }, + cacheableResponse: { + statuses: [0, 200] + } + } + } + ] + }, + devOptions: { + enabled: true, + type: 'module' + } + })) + } + return { - plugins: [vue()], + plugins, resolve: { alias: { '@': path.resolve(__dirname, './src') -- 2.49.1