Merge pull request 'develop' (#1) from develop into prod
All checks were successful
Deploy to Production / build-and-deploy (push) Successful in 1m7s
Reviewed-on: https://git.local.evan.casa/evan/LeDiscord/pulls/1
@@ -6,8 +6,9 @@ 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 models.notification import Notification, NotificationType
|
||||
from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate
|
||||
from utils.security import get_current_active_user
|
||||
from utils.video_utils import generate_video_thumbnail, get_video_duration
|
||||
@@ -56,8 +57,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 +163,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 +263,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(
|
||||
@@ -314,6 +333,22 @@ async def upload_vlog_video(
|
||||
db.commit()
|
||||
db.refresh(vlog)
|
||||
|
||||
# Create notifications for all active users (except the creator)
|
||||
users = db.query(User).filter(User.is_active == True).all()
|
||||
for user in users:
|
||||
if user.id != current_user.id:
|
||||
notification = Notification(
|
||||
user_id=user.id,
|
||||
type=NotificationType.NEW_VLOG,
|
||||
title="Nouveau vlog",
|
||||
message=f"{current_user.full_name} a publié un nouveau vlog : {vlog.title}",
|
||||
link=f"/vlogs/{vlog.id}",
|
||||
is_read=False
|
||||
)
|
||||
db.add(notification)
|
||||
|
||||
db.commit()
|
||||
|
||||
return format_vlog_response(vlog, db, current_user.id)
|
||||
|
||||
def format_vlog_response(vlog: Vlog, db: Session, current_user_id: int) -> dict:
|
||||
@@ -363,6 +398,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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(",")
|
||||
|
||||
# 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 setting.value
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
51
frontend/PWA_SETUP.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Configuration PWA - LeDiscord
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Installer les dépendances PWA :**
|
||||
```bash
|
||||
npm install --save-dev vite-plugin-pwa sharp
|
||||
```
|
||||
|
||||
2. **Générer les icônes PWA :**
|
||||
```bash
|
||||
npm run generate-icons
|
||||
```
|
||||
|
||||
Cette commande génère automatiquement toutes les icônes nécessaires (72x72, 96x96, 128x128, 144x144, 152x152, 192x192, 384x384, 512x512) à partir du logo `public/logo_lediscord.png`.
|
||||
|
||||
## Fonctionnalités PWA
|
||||
|
||||
### ✅ Fonctionnalités implémentées
|
||||
|
||||
- **Service Worker** : Cache automatique des assets statiques
|
||||
- **Manifest.json** : Configuration complète de l'application
|
||||
- **Icônes** : Support multi-tailles pour tous les appareils
|
||||
- **Offline** : Cache des ressources pour fonctionner hors ligne
|
||||
- **Installation** : L'application peut être installée sur mobile et desktop
|
||||
|
||||
### 📱 Cache Strategy
|
||||
|
||||
- **Assets statiques** : Cache First (JS, CSS, images, vidéos)
|
||||
- **API** : Network First avec cache de 5 minutes
|
||||
- **Uploads** : Cache First avec expiration de 7 jours
|
||||
- **Fonts Google** : Cache First avec expiration de 1 an
|
||||
|
||||
### 🔧 Configuration
|
||||
|
||||
La configuration PWA se trouve dans `vite.config.js` dans le plugin `VitePWA`.
|
||||
|
||||
### 🚀 Build Production
|
||||
|
||||
Lors du build en production, le service worker sera automatiquement généré :
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 📝 Notes
|
||||
|
||||
- Le service worker est activé en développement (`devOptions.enabled: true`)
|
||||
- Les mises à jour sont automatiques (`registerType: 'autoUpdate'`)
|
||||
- Les icônes doivent être générées avant le premier build
|
||||
|
||||
1
frontend/dev-dist/registerSW.js
Normal file
@@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
128
frontend/dev-dist/sw.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "registerSW.js",
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.abqp38bc5fg"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/]
|
||||
}));
|
||||
workbox.registerRoute(/^https:\/\/fonts\.googleapis\.com\/.*/i, new workbox.CacheFirst({
|
||||
"cacheName": "google-fonts-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 31536000
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/^https:\/\/fonts\.gstatic\.com\/.*/i, new workbox.CacheFirst({
|
||||
"cacheName": "gstatic-fonts-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 31536000
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/^https?:\/\/.*\/api\/.*/i, new workbox.NetworkFirst({
|
||||
"cacheName": "api-cache",
|
||||
"networkTimeoutSeconds": 10,
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 300
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/^https?:\/\/.*\/uploads\/.*/i, new workbox.CacheFirst({
|
||||
"cacheName": "uploads-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 604800
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
|
||||
}));
|
||||
//# sourceMappingURL=sw.js.map
|
||||
1
frontend/dev-dist/sw.js.map
Normal file
4785
frontend/dev-dist/workbox-47da91e0.js
Normal file
1
frontend/dev-dist/workbox-47da91e0.js.map
Normal file
@@ -4,6 +4,15 @@
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Plateforme communautaire LeDiscord - Notre espace">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="LeDiscord">
|
||||
<link rel="apple-touch-icon" href="/icon-192x192.png">
|
||||
|
||||
<title>LeDiscord - Notre espace</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
8101
frontend/package-lock.json
generated
@@ -5,7 +5,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"generate-icons": "node scripts/generate-icons.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^10.6.1",
|
||||
@@ -23,8 +24,10 @@
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"sharp": "^0.33.5",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"terser": "^5.43.1",
|
||||
"vite": "^5.0.0"
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-pwa": "^0.20.5"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
frontend/public/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
frontend/public/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
frontend/public/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
frontend/public/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
frontend/public/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
frontend/public/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
80
frontend/public/manifest.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"screenshots": [],
|
||||
"categories": ["social", "entertainment"],
|
||||
"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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
79
frontend/scripts/generate-icons.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Script pour générer les icônes PWA à partir du logo
|
||||
*
|
||||
* Usage: node scripts/generate-icons.js
|
||||
*
|
||||
* Nécessite: npm install --save-dev sharp
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// Tailles d'icônes requises pour PWA
|
||||
const iconSizes = [72, 96, 128, 144, 152, 192, 384, 512]
|
||||
|
||||
async function generateIcons() {
|
||||
try {
|
||||
// Vérifier si sharp est installé
|
||||
let sharp
|
||||
try {
|
||||
sharp = require('sharp')
|
||||
} catch (e) {
|
||||
console.error('❌ Le package "sharp" n\'est pas installé.')
|
||||
console.log('📦 Installez-le avec: npm install --save-dev sharp')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const logoPath = path.join(__dirname, '../public/logo_lediscord.png')
|
||||
const publicDir = path.join(__dirname, '../public')
|
||||
|
||||
// Vérifier que le logo existe
|
||||
if (!fs.existsSync(logoPath)) {
|
||||
console.error(`❌ Logo introuvable: ${logoPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('🎨 Génération des icônes PWA avec fond transparent...')
|
||||
|
||||
// Générer chaque taille d'icône
|
||||
for (const size of iconSizes) {
|
||||
const outputPath = path.join(publicDir, `icon-${size}x${size}.png`)
|
||||
|
||||
// Calculer le padding (10% de la taille) pour éviter que le logo touche les bords
|
||||
const padding = Math.floor(size * 0.1)
|
||||
const contentSize = size - (padding * 2)
|
||||
|
||||
await sharp(logoPath)
|
||||
.resize(contentSize, contentSize, {
|
||||
fit: 'contain',
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 } // Fond transparent
|
||||
})
|
||||
.extend({
|
||||
top: padding,
|
||||
bottom: padding,
|
||||
left: padding,
|
||||
right: padding,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 } // Fond transparent
|
||||
})
|
||||
.resize(size, size, {
|
||||
kernel: sharp.kernel.lanczos3 // Meilleure qualité de redimensionnement
|
||||
})
|
||||
.png({
|
||||
quality: 100,
|
||||
compressionLevel: 9
|
||||
})
|
||||
.toFile(outputPath)
|
||||
|
||||
console.log(`✅ Généré: icon-${size}x${size}.png`)
|
||||
}
|
||||
|
||||
console.log('✨ Toutes les icônes ont été générées avec succès!')
|
||||
console.log('💡 Les icônes utilisent un fond transparent avec un padding intelligent.')
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la génération des icônes:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
generateIcons()
|
||||
|
||||
152
frontend/src/components/PWAInstallPrompt.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="showInstallPrompt"
|
||||
class="fixed bottom-4 right-4 z-50 max-w-sm bg-white rounded-lg shadow-lg border border-gray-200 p-4 animate-slide-up"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
class="w-6 h-6 text-indigo-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm font-semibold text-gray-900">
|
||||
Installer LeDiscord
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Installez l'application pour un accès rapide et une meilleure expérience.
|
||||
</p>
|
||||
<div class="mt-3 flex space-x-2">
|
||||
<button
|
||||
@click="installApp"
|
||||
class="flex-1 bg-indigo-600 text-white text-sm font-medium py-2 px-4 rounded-md hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Installer
|
||||
</button>
|
||||
<button
|
||||
@click="dismissPrompt"
|
||||
class="flex-1 bg-gray-100 text-gray-700 text-sm font-medium py-2 px-4 rounded-md hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Plus tard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="dismissPrompt"
|
||||
class="flex-shrink-0 text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const showInstallPrompt = ref(false)
|
||||
const deferredPrompt = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
// Vérifier si l'app est déjà installée
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier si le prompt a été rejeté récemment
|
||||
const dismissed = localStorage.getItem('pwa-install-dismissed')
|
||||
if (dismissed) {
|
||||
const dismissedTime = parseInt(dismissed, 10)
|
||||
const daysSinceDismissed = (Date.now() - dismissedTime) / (1000 * 60 * 60 * 24)
|
||||
// Réafficher après 7 jours
|
||||
if (daysSinceDismissed < 7) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Écouter l'événement beforeinstallprompt
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
})
|
||||
|
||||
function handleBeforeInstallPrompt(e) {
|
||||
// Empêcher le prompt par défaut
|
||||
e.preventDefault()
|
||||
// Stocker l'événement pour l'utiliser plus tard
|
||||
deferredPrompt.value = e
|
||||
// Afficher notre prompt personnalisé
|
||||
showInstallPrompt.value = true
|
||||
}
|
||||
|
||||
async function installApp() {
|
||||
if (!deferredPrompt.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Afficher le prompt d'installation
|
||||
deferredPrompt.value.prompt()
|
||||
|
||||
// Attendre la réponse de l'utilisateur
|
||||
const { outcome } = await deferredPrompt.value.userChoice
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
console.log('✅ PWA installée avec succès')
|
||||
} else {
|
||||
console.log('❌ Installation PWA annulée')
|
||||
}
|
||||
|
||||
// Réinitialiser
|
||||
deferredPrompt.value = null
|
||||
showInstallPrompt.value = false
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'installation:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function dismissPrompt() {
|
||||
showInstallPrompt.value = false
|
||||
// Enregistrer le rejet avec timestamp
|
||||
localStorage.setItem('pwa-install-dismissed', Date.now().toString())
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,29 +2,37 @@
|
||||
<div class="video-player-container">
|
||||
<div class="relative">
|
||||
<!-- Video.js Player -->
|
||||
<video
|
||||
ref="videoPlayer"
|
||||
class="video-js vjs-default-skin vjs-big-play-centered w-full rounded-lg"
|
||||
controls
|
||||
preload="auto"
|
||||
:poster="posterUrl"
|
||||
data-setup="{}"
|
||||
>
|
||||
<source :src="videoUrl" type="video/mp4" />
|
||||
<p class="vjs-no-js">
|
||||
Pour voir cette vidéo, activez JavaScript et considérez passer à un navigateur web qui
|
||||
<a href="https://videojs.com/html5-video-support/" target="_blank">supporte la vidéo HTML5</a>.
|
||||
</p>
|
||||
</video>
|
||||
<div data-vjs-player>
|
||||
<video
|
||||
ref="videoPlayer"
|
||||
class="video-js vjs-default-skin vjs-big-play-centered w-full rounded-lg"
|
||||
controls
|
||||
preload="auto"
|
||||
:poster="posterUrl"
|
||||
playsinline
|
||||
>
|
||||
<source :src="videoUrl" />
|
||||
<p class="vjs-no-js">
|
||||
Pour voir cette vidéo, activez JavaScript et considérez passer à un navigateur web qui
|
||||
<a href="https://videojs.com/html5-video-support/" target="_blank">supporte la vidéo HTML5</a>.
|
||||
</p>
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Stats -->
|
||||
<div class="mt-4 flex items-center justify-between text-sm text-gray-600">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex items-center">
|
||||
<Eye class="w-4 h-4 mr-1" />
|
||||
{{ viewsCount }} vue{{ viewsCount > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="flex items-center" title="Vues uniques">
|
||||
<Eye class="w-4 h-4 mr-1" />
|
||||
{{ 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">
|
||||
<Clock class="w-4 h-4 mr-1" />
|
||||
{{ 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 })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-js {
|
||||
aspect-ratio: 16/9;
|
||||
/* Fix pour l'erreur "passive event listener" sur certains navigateurs */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.video-js .vjs-big-play-button {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-discord flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-4">
|
||||
<h1 class="text-4xl font-bold bg-gradient-primary bg-clip-text text-transparent mb-0">LeDiscord</h1>
|
||||
<p class="text-secondary-600">Notre espace privé</p>
|
||||
<div class="min-h-screen bg-gradient-discord flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="w-full max-w-md lg:max-w-lg flex flex-col">
|
||||
<div class="text-center mb-3 sm:mb-4">
|
||||
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-primary bg-clip-text text-transparent mb-0">LeDiscord</h1>
|
||||
<p class="text-xs sm:text-sm md:text-base text-secondary-600 mt-1">Notre espace privé</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<div class="bg-white rounded-xl sm:rounded-2xl shadow-xl p-4 sm:p-6 md:p-8 flex-1 flex flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<div class="text-center mt-3 sm:mt-4">
|
||||
<img
|
||||
src="/logo_lediscord.png"
|
||||
alt="LeDiscord Logo"
|
||||
class="mx-auto h-48 w-auto mb-0 drop-shadow-lg"
|
||||
class="mx-auto h-24 sm:h-32 md:h-40 w-auto drop-shadow-lg"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,11 +21,21 @@
|
||||
v-for="item in navigation"
|
||||
:key="item.name"
|
||||
:to="item.to"
|
||||
class="inline-flex items-center px-1 pt-1 text-sm font-medium text-secondary-600 hover:text-primary-600 border-b-2 border-transparent hover:border-primary-600 transition-colors"
|
||||
active-class="!text-primary-600 !border-primary-600"
|
||||
class="inline-flex items-center px-1 pt-1 text-sm font-medium border-b-2 border-transparent transition-colors relative"
|
||||
:class="item.comingSoon
|
||||
? 'text-gray-400 cursor-not-allowed opacity-60 !border-transparent'
|
||||
: 'text-secondary-600 hover:text-primary-600 hover:border-primary-600'"
|
||||
:active-class="item.comingSoon ? '' : '!text-primary-600 !border-primary-600'"
|
||||
@click.prevent="item.comingSoon ? null : null"
|
||||
>
|
||||
<component :is="item.icon" class="w-4 h-4 mr-2" />
|
||||
{{ item.name }}
|
||||
<span
|
||||
v-if="item.comingSoon"
|
||||
class="ml-1.5 px-1 py-0.5 text-[9px] font-medium text-white bg-purple-600 rounded"
|
||||
>
|
||||
Soon
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,8 +49,10 @@
|
||||
<Bell class="w-5 h-5" />
|
||||
<span
|
||||
v-if="unreadNotifications > 0"
|
||||
class="absolute top-0 right-0 block h-2 w-2 rounded-full bg-red-500"
|
||||
/>
|
||||
class="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-500 rounded-full"
|
||||
>
|
||||
{{ unreadNotifications > 99 ? '99+' : unreadNotifications }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- User Menu -->
|
||||
@@ -112,6 +124,25 @@
|
||||
Administration
|
||||
</router-link>
|
||||
|
||||
<hr class="my-1">
|
||||
|
||||
<!-- Installer l'app -->
|
||||
<button
|
||||
@click="handleInstallApp"
|
||||
:disabled="isPWAInstalled || !canInstall"
|
||||
class="block w-full text-left px-4 py-2 text-sm transition-colors"
|
||||
:class="isPWAInstalled || !canInstall
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: 'text-gray-700 hover:bg-gray-100'"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{{ isPWAInstalled ? 'Application installée' : 'Installer l\'app' }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<hr class="my-1">
|
||||
<button
|
||||
@click="logout"
|
||||
@@ -122,22 +153,44 @@
|
||||
</div>
|
||||
</transition>
|
||||
</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>
|
||||
|
||||
<!-- 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">
|
||||
<router-link
|
||||
v-for="item in navigation"
|
||||
:key="item.name"
|
||||
:to="item.to"
|
||||
class="flex items-center px-4 py-2 text-base font-medium text-gray-600 hover:text-primary-600 hover:bg-gray-50"
|
||||
class="flex items-center px-4 py-2 text-base font-medium hover:bg-gray-50 relative"
|
||||
:class="item.comingSoon
|
||||
? 'text-gray-400 cursor-not-allowed opacity-60'
|
||||
: 'text-gray-600 hover:text-primary-600'"
|
||||
active-class="!text-primary-600 bg-primary-50"
|
||||
@click.prevent="item.comingSoon ? null : null"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 mr-3" />
|
||||
{{ item.name }}
|
||||
<span>{{ item.name }}</span>
|
||||
<span
|
||||
v-if="item.comingSoon"
|
||||
class="ml-auto px-1 py-0.5 text-[9px] font-medium text-white bg-purple-600 rounded"
|
||||
>
|
||||
Soon
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,7 +267,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { format } from 'date-fns'
|
||||
@@ -229,7 +282,9 @@ import {
|
||||
Bell,
|
||||
User,
|
||||
ChevronDown,
|
||||
X
|
||||
X,
|
||||
Menu,
|
||||
Dice6
|
||||
} from 'lucide-vue-next'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import TicketFloatingButton from '@/components/TicketFloatingButton.vue'
|
||||
@@ -242,11 +297,16 @@ const navigation = [
|
||||
{ name: 'Événements', to: '/events', icon: Calendar },
|
||||
{ name: 'Albums', to: '/albums', icon: Image },
|
||||
{ name: 'Vlogs', to: '/vlogs', icon: Film },
|
||||
{ name: 'Publications', to: '/posts', icon: MessageSquare }
|
||||
{ name: 'Publications', to: '/posts', icon: MessageSquare },
|
||||
{ name: 'Jeux', to: '#', icon: Dice6, comingSoon: true }
|
||||
]
|
||||
|
||||
const showUserMenu = ref(false)
|
||||
const showNotifications = ref(false)
|
||||
const isMobileMenuOpen = ref(false)
|
||||
const deferredPrompt = ref(null)
|
||||
const isPWAInstalled = ref(false)
|
||||
const canInstall = ref(false)
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const notifications = computed(() => authStore.notifications)
|
||||
@@ -262,7 +322,8 @@ async function logout() {
|
||||
}
|
||||
|
||||
async function fetchNotifications() {
|
||||
await authStore.fetchNotifications()
|
||||
const result = await authStore.fetchNotifications()
|
||||
// Les notifications sont maintenant mises à jour automatiquement via le polling
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
@@ -281,9 +342,82 @@ async function handleNotificationClick(notification) {
|
||||
showNotifications.value = false
|
||||
}
|
||||
|
||||
// PWA Installation logic
|
||||
function checkPWAInstalled() {
|
||||
// Vérifier si l'app est déjà installée (mode standalone)
|
||||
isPWAInstalled.value = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.navigator.standalone === true ||
|
||||
document.referrer.includes('android-app://')
|
||||
}
|
||||
|
||||
function handleBeforeInstallPrompt(e) {
|
||||
// Empêcher le prompt par défaut
|
||||
e.preventDefault()
|
||||
// Stocker l'événement pour l'utiliser plus tard
|
||||
deferredPrompt.value = e
|
||||
canInstall.value = true
|
||||
}
|
||||
|
||||
async function handleInstallApp() {
|
||||
if (!deferredPrompt.value || isPWAInstalled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
showUserMenu.value = false
|
||||
|
||||
// Afficher le prompt d'installation
|
||||
deferredPrompt.value.prompt()
|
||||
|
||||
// Attendre la réponse de l'utilisateur
|
||||
const { outcome } = await deferredPrompt.value.userChoice
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
console.log('✅ PWA installée avec succès')
|
||||
isPWAInstalled.value = true
|
||||
} else {
|
||||
console.log('❌ Installation PWA annulée')
|
||||
}
|
||||
|
||||
// Réinitialiser
|
||||
deferredPrompt.value = null
|
||||
canInstall.value = false
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'installation:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.fetchCurrentUser()
|
||||
await fetchNotifications()
|
||||
await authStore.fetchUnreadCount()
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
await fetchNotifications()
|
||||
await authStore.fetchUnreadCount()
|
||||
|
||||
// Démarrer le polling des notifications
|
||||
const notificationService = (await import('@/services/notificationService')).default
|
||||
notificationService.startPolling()
|
||||
notificationService.setupServiceWorkerListener()
|
||||
|
||||
// Demander la permission pour les notifications push
|
||||
await notificationService.requestNotificationPermission()
|
||||
}
|
||||
|
||||
// Vérifier si PWA est installée
|
||||
checkPWAInstalled()
|
||||
|
||||
// Écouter l'événement beforeinstallprompt
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
|
||||
// Écouter les changements de display mode
|
||||
window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPWAInstalled)
|
||||
})
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
|
||||
// Arrêter le polling des notifications
|
||||
const notificationService = (await import('@/services/notificationService')).default
|
||||
notificationService.stopPolling()
|
||||
})
|
||||
</script>
|
||||
159
frontend/src/services/notificationService.js
Normal file
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,30 +18,30 @@
|
||||
<div v-else>
|
||||
<!-- Header -->
|
||||
<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">
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Retour aux albums
|
||||
</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
|
||||
@click="showEditModal = true"
|
||||
class="btn-secondary"
|
||||
class="flex-1 sm:flex-none btn-secondary justify-center"
|
||||
>
|
||||
<Edit class="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
@click="showUploadModal = true"
|
||||
class="btn-primary"
|
||||
class="flex-1 sm:flex-none btn-primary justify-center"
|
||||
>
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
Ajouter des médias
|
||||
Ajouter
|
||||
</button>
|
||||
<button
|
||||
@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" />
|
||||
Supprimer
|
||||
@@ -49,9 +49,9 @@
|
||||
</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 -->
|
||||
<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" />
|
||||
<img
|
||||
v-else
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<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 class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
|
||||
<div>
|
||||
<h1 class="text-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>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Albums photos</h1>
|
||||
<p class="text-sm sm:text-base text-gray-600 mt-1">Partagez vos souvenirs en photos et vidéos</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn-primary"
|
||||
class="w-full sm:w-auto btn-primary justify-center"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nouvel album
|
||||
|
||||
@@ -18,23 +18,23 @@
|
||||
<div v-else>
|
||||
<!-- Header -->
|
||||
<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">
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Retour aux événements
|
||||
</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
|
||||
@click="showEditModal = true"
|
||||
class="btn-secondary"
|
||||
class="flex-1 sm:flex-none btn-secondary justify-center"
|
||||
>
|
||||
<Edit class="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
@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" />
|
||||
Supprimer
|
||||
@@ -42,9 +42,9 @@
|
||||
</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 -->
|
||||
<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
|
||||
v-if="!event.cover_image && event.creator_avatar"
|
||||
:src="getMediaUrl(event.creator_avatar)"
|
||||
@@ -122,25 +122,6 @@
|
||||
<p class="text-gray-700 whitespace-pre-wrap">{{ event.description }}</p>
|
||||
</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 -->
|
||||
<div class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Ma participation</h2>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<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 class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
|
||||
<div>
|
||||
<h1 class="text-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>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Événements</h1>
|
||||
<p class="text-sm sm:text-base text-gray-600 mt-1">Organisez et participez aux événements du groupe</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn-primary"
|
||||
class="w-full sm:w-auto btn-primary justify-center"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nouvel événement
|
||||
@@ -214,51 +214,6 @@
|
||||
>
|
||||
</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>
|
||||
<label class="label">Date et heure</label>
|
||||
@@ -339,8 +294,6 @@ const newEvent = ref({
|
||||
description: '',
|
||||
date: '',
|
||||
location: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
end_date: null
|
||||
})
|
||||
|
||||
@@ -453,8 +406,6 @@ async function createEvent() {
|
||||
description: newEvent.value.description,
|
||||
date: new Date(newEvent.value.date).toISOString(),
|
||||
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
|
||||
}
|
||||
|
||||
@@ -478,8 +429,6 @@ function resetForm() {
|
||||
description: '',
|
||||
date: '',
|
||||
location: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
end_date: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">
|
||||
@@ -9,8 +9,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="card p-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Prochain événement</p>
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Taux de présence</p>
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Nouveaux posts</p>
|
||||
@@ -44,7 +44,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Membres actifs</p>
|
||||
@@ -59,17 +59,17 @@
|
||||
<!-- Recent Posts -->
|
||||
<div class="lg:col-span-2">
|
||||
<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>
|
||||
</div>
|
||||
<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
|
||||
</div>
|
||||
<div
|
||||
v-for="post in posts"
|
||||
: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">
|
||||
<img
|
||||
@@ -133,11 +133,11 @@
|
||||
<!-- Upcoming Events -->
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<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
|
||||
</div>
|
||||
<router-link
|
||||
@@ -171,11 +171,11 @@
|
||||
|
||||
<!-- Recent Vlogs -->
|
||||
<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>
|
||||
</div>
|
||||
<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
|
||||
</div>
|
||||
<router-link
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- 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>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Publications</h1>
|
||||
<p class="text-gray-600 mt-1">Partagez vos moments avec le groupe</p>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Publications</h1>
|
||||
<p class="text-sm sm:text-base text-gray-600 mt-1">Partagez vos moments avec le groupe</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn-primary"
|
||||
class="w-full sm:w-auto btn-primary justify-center"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nouvelle publication
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<img
|
||||
v-if="user?.avatar_url"
|
||||
@@ -99,7 +99,7 @@
|
||||
<div
|
||||
v-for="post in posts"
|
||||
:key="post.id"
|
||||
class="card p-6"
|
||||
class="card p-4 sm:p-6"
|
||||
>
|
||||
<!-- Post Header -->
|
||||
<div class="flex items-start space-x-3 mb-4">
|
||||
|
||||
@@ -1,144 +1,333 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="w-full max-w-2xl mx-auto space-y-4 sm:space-y-6 px-4 sm:px-6 pb-4 sm:pb-6">
|
||||
<!-- Header -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Créer un compte</h2>
|
||||
<p class="mt-2 text-sm text-gray-500">Rejoignez notre communauté en quelques étapes</p>
|
||||
<div class="text-center pt-2 sm:pt-0">
|
||||
<h2 class="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900">Créer un compte</h2>
|
||||
<p class="mt-2 text-xs sm:text-sm text-gray-500">Remplissez ça et après promis je vous embête plus</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="pt-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-medium text-gray-700">Étape {{ currentStep }} sur {{ totalSteps }}</span>
|
||||
<span class="text-sm font-medium text-gray-500">{{ Math.round((currentStep / totalSteps) * 100) }}%</span>
|
||||
<div class="pt-2 sm:pt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs sm:text-sm font-medium text-gray-700">Étape {{ currentStep }} sur {{ totalSteps }}</span>
|
||||
<span class="text-xs sm:text-sm font-medium text-gray-500">{{ Math.round((currentStep / totalSteps) * 100) }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 sm:h-2.5">
|
||||
<div
|
||||
class="bg-primary-600 h-1.5 rounded-full transition-all duration-500 ease-in-out"
|
||||
class="bg-primary-600 h-2 sm:h-2.5 rounded-full transition-all duration-500 ease-in-out"
|
||||
:style="{ width: `${(currentStep / totalSteps) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step Content -->
|
||||
<div class="min-h-[350px] flex flex-col justify-center">
|
||||
<div class="flex flex-col justify-center py-4 sm:py-6">
|
||||
<StepTransition :step="currentStep">
|
||||
<!-- Step 1: Welcome -->
|
||||
<div v-if="currentStep === 1" class="text-center">
|
||||
<h3 class="text-2xl font-bold text-gray-900 mb-4">Bienvenue sur LeDiscord !</h3>
|
||||
<p class="text-gray-600 max-w-sm mx-auto">
|
||||
Nous sommes ravis de vous accueillir. Préparez-vous à rejoindre une communauté passionnante.
|
||||
<div v-if="currentStep === 1" class="text-center px-2 sm:px-0">
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<h3 class="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 mb-3 sm:mb-4">Yo les ptits potes ! 🎉</h3>
|
||||
<p class="text-sm sm:text-base text-gray-600 max-w-md mx-auto leading-relaxed mb-6 sm:mb-8">
|
||||
Bienvenue sur LeDiscord ! Ici on partage nos vlogs, nos photos de soirées et on organise nos prochaines beuveries. 🍻
|
||||
</p>
|
||||
|
||||
<!-- Minimalist features preview -->
|
||||
<div class="grid grid-cols-3 gap-2 sm:gap-4 max-w-md mx-auto mt-6 sm:mt-8">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl sm:text-3xl mb-1">📹</div>
|
||||
<div class="text-xs sm:text-sm font-medium text-gray-700">Vlogs</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl sm:text-3xl mb-1">🍺</div>
|
||||
<div class="text-xs sm:text-sm font-medium text-gray-700">Événements</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl sm:text-3xl mb-1">📸</div>
|
||||
<div class="text-xs sm:text-sm font-medium text-gray-700">Albums</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Registration Form -->
|
||||
<div v-if="currentStep === 2">
|
||||
<form @submit.prevent="nextStep" class="space-y-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
|
||||
<div v-if="currentStep === 2" class="w-full">
|
||||
<form @submit.prevent="nextStep" class="space-y-4 sm:space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div>
|
||||
<label for="email" class="label">Email</label>
|
||||
<input id="email" v-model="form.email" type="email" required class="input" @blur="touchedFields.email = true">
|
||||
<label for="email" class="label text-sm sm:text-base">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
class="input text-sm sm:text-base"
|
||||
placeholder="exemple@email.com"
|
||||
@blur="touchedFields.email = true"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="username" class="label">Nom d'utilisateur</label>
|
||||
<input id="username" v-model="form.username" type="text" required minlength="3" class="input" @blur="touchedFields.username = true">
|
||||
<label for="username" class="label text-sm sm:text-base">Nom d'utilisateur</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
required
|
||||
minlength="3"
|
||||
class="input text-sm sm:text-base"
|
||||
placeholder="nom_utilisateur"
|
||||
@blur="touchedFields.username = true"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="full_name" class="label">Nom complet</label>
|
||||
<input id="full_name" v-model="form.full_name" type="text" required class="input" @blur="touchedFields.full_name = true">
|
||||
<label for="full_name" class="label text-sm sm:text-base">Nom complet</label>
|
||||
<input
|
||||
id="full_name"
|
||||
v-model="form.full_name"
|
||||
type="text"
|
||||
required
|
||||
class="input text-sm sm:text-base"
|
||||
placeholder="Prénom Nom"
|
||||
@blur="touchedFields.full_name = true"
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div>
|
||||
<label for="password" class="label">Mot de passe</label>
|
||||
<input id="password" v-model="form.password" type="password" required minlength="6" class="input" @blur="touchedFields.password = true">
|
||||
<label for="password" class="label text-sm sm:text-base">Mot de passe</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
minlength="6"
|
||||
class="input text-sm sm:text-base"
|
||||
placeholder="••••••••"
|
||||
@blur="touchedFields.password = true"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password_confirm" class="label">Confirmer</label>
|
||||
<input id="password_confirm" v-model="form.password_confirm" type="password" required class="input" @blur="touchedFields.password_confirm = true">
|
||||
<label for="password_confirm" class="label text-sm sm:text-base">Confirmer</label>
|
||||
<input
|
||||
id="password_confirm"
|
||||
v-model="form.password_confirm"
|
||||
type="password"
|
||||
required
|
||||
class="input text-sm sm:text-base"
|
||||
placeholder="••••••••"
|
||||
@blur="touchedFields.password_confirm = true"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<PasswordStrength :password="form.password" />
|
||||
<div v-if="touchedFields.password_confirm && form.password_confirm && form.password !== form.password_confirm" class="flex items-center text-sm text-red-600">
|
||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" /></svg>
|
||||
Les mots de passe ne correspondent pas
|
||||
</div>
|
||||
<PasswordStrength :password="form.password" />
|
||||
<div v-if="touchedFields.password_confirm && form.password_confirm && form.password !== form.password_confirm" class="flex items-center text-xs sm:text-sm text-red-600 mt-2">
|
||||
<svg class="w-4 h-4 mr-1 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Les mots de passe ne correspondent pas</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Warning -->
|
||||
<div v-if="currentStep === 3" class="text-center">
|
||||
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 text-left">
|
||||
<p class="text-sm text-yellow-700">
|
||||
LeDiscord est actuellement en version bêta. Votre retour est précieux pour nous aider à améliorer la plateforme.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="currentStep === 3" class="w-full px-2 sm:px-0 text-center">
|
||||
<h3 class="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
|
||||
Version Bêta en cours ! 🚧
|
||||
</h3>
|
||||
|
||||
<div class="bg-gradient-to-br from-yellow-50 to-orange-50 border-2 border-yellow-200 rounded-xl p-4 sm:p-6 max-w-lg mx-auto mb-4 sm:mb-6">
|
||||
<p class="text-sm sm:text-base text-gray-700 leading-relaxed mb-4">
|
||||
Je suis encore en train de peaufiner tout ça, donc si tu vois un bug ou que quelque chose te fait chier, <strong>dis-moi !</strong> 💬
|
||||
</p>
|
||||
<div class="flex items-center justify-center space-x-2 text-xs sm:text-sm text-gray-600">
|
||||
<span>🔧</span>
|
||||
<span>J'améliore au fur et à mesure</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 sm:mt-6">
|
||||
<p class="text-xs sm:text-sm text-gray-700 mb-3 font-medium">Pour me signaler un problème, utilise le bouton ticket :</p>
|
||||
<div class="inline-flex items-center justify-center bg-primary-600 text-white rounded-full p-3 sm:p-4 shadow-lg">
|
||||
<svg class="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-xs sm:text-sm text-gray-500 mt-3">
|
||||
Il se trouve en bas à droite de l'écran une fois connecté
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Features Tour -->
|
||||
<div v-if="currentStep === 4" class="text-center">
|
||||
<h3 class="text-2xl font-bold text-gray-900 mb-4">Découvrez les fonctionnalités</h3>
|
||||
<div class="space-y-4">
|
||||
<div v-for="feature in features" :key="feature.title" class="border rounded-lg p-4 text-left flex items-center space-x-4">
|
||||
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<span v-html="feature.icon"></span>
|
||||
<!-- Step 4: Interactive Tour -->
|
||||
<div v-if="currentStep === 4" class="w-full px-2 sm:px-0">
|
||||
<h3 class="text-xl sm:text-2xl font-bold text-gray-900 mb-4 sm:mb-6 text-center">Petite visite guidée 🗺️</h3>
|
||||
|
||||
<div class="max-w-lg mx-auto">
|
||||
<!-- Tour Content -->
|
||||
<div class="bg-white border-2 border-gray-200 rounded-xl p-4 sm:p-6 mb-4 sm:mb-6 min-h-[280px] flex flex-col justify-center">
|
||||
<div v-if="tourStep === 0" class="text-center">
|
||||
<div class="text-4xl sm:text-5xl mb-4">🏠</div>
|
||||
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Accueil</h4>
|
||||
<p class="text-sm sm:text-base text-gray-600 mb-3">C'est ici que tu verras toutes les dernières activités de la communauté</p>
|
||||
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
|
||||
<p class="mb-1">✨ <strong>Astuce :</strong> Tu peux liker, commenter et partager tout ce qui t'intéresse</p>
|
||||
<p>🔔 Les notifications te tiendront au courant des nouvelles interactions</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold">{{ feature.title }}</h4>
|
||||
<p class="text-sm text-gray-600">{{ feature.description }}</p>
|
||||
|
||||
<div v-if="tourStep === 1" class="text-center">
|
||||
<div class="text-4xl sm:text-5xl mb-4">📅</div>
|
||||
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Événements</h4>
|
||||
<p class="text-sm sm:text-base text-gray-600 mb-3">Organise et participe aux prochaines soirées et beuveries</p>
|
||||
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
|
||||
<p class="mb-1">📝 <strong>Crée un événement :</strong> Date, lieu, description, le tout en quelques clics</p>
|
||||
<p>✅ <strong>Participe :</strong> Indique ta présence et vois qui vient</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tourStep === 2" class="text-center">
|
||||
<div class="text-4xl sm:text-5xl mb-4">📸</div>
|
||||
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Albums</h4>
|
||||
<p class="text-sm sm:text-base text-gray-600 mb-3">Partage tes meilleures photos de soirées et de moments entre potes</p>
|
||||
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
|
||||
<p class="mb-1">📁 <strong>Crée un album :</strong> Regroupe tes photos par événement ou thème</p>
|
||||
<p>💾 <strong>Upload multiple :</strong> Ajoute plusieurs photos d'un coup</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tourStep === 3" class="text-center">
|
||||
<div class="text-4xl sm:text-5xl mb-4">📹</div>
|
||||
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Vlogs</h4>
|
||||
<p class="text-sm sm:text-base text-gray-600 mb-3">Regarde et partage tes vlogs avec la communauté</p>
|
||||
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
|
||||
<p class="mb-1">🎬 <strong>Upload un vlog :</strong> Vidéos jusqu'à 500MB, avec thumbnail personnalisé</p>
|
||||
<p>👀 <strong>Statistiques :</strong> Vues, replays, likes, tout est tracké</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tourStep === 4" class="text-center">
|
||||
<div class="text-4xl sm:text-5xl mb-4">💬</div>
|
||||
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Publications</h4>
|
||||
<p class="text-sm sm:text-base text-gray-600 mb-3">Discute, partage et échange avec tout le monde</p>
|
||||
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
|
||||
<p class="mb-1">@<strong>Mentions :</strong> Tag tes potes avec @nom_utilisateur</p>
|
||||
<p>📎 <strong>Médias :</strong> Ajoute des images directement dans tes posts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tour Navigation -->
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
@click="tourStep = Math.max(0, tourStep - 1)"
|
||||
:disabled="tourStep === 0"
|
||||
class="px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Précédent
|
||||
</button>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<div
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
class="w-2 h-2 rounded-full transition-all duration-300"
|
||||
:class="tourStep === i - 1 ? 'bg-primary-600 w-6' : 'bg-gray-300'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="tourStep = Math.min(4, tourStep + 1)"
|
||||
:disabled="tourStep === 4"
|
||||
class="px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Suivant →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Install App -->
|
||||
<div v-if="currentStep === 5" class="w-full px-2 sm:px-0 text-center">
|
||||
<h3 class="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
|
||||
Ah et tiens... 📱
|
||||
</h3>
|
||||
|
||||
<p class="text-sm sm:text-base text-gray-600 max-w-md mx-auto leading-relaxed mb-6 sm:mb-8">
|
||||
Prends l'app, ce sera plus sympa sur mobile ! Accès rapide, notifications, et tout ça directement depuis ton téléphone.
|
||||
</p>
|
||||
|
||||
<div class="bg-gradient-to-br from-primary-50 to-purple-50 border-2 border-primary-200 rounded-xl p-4 sm:p-6 max-w-md mx-auto mb-6">
|
||||
<div class="flex items-center justify-center space-x-3 mb-4">
|
||||
<div class="text-3xl">📱</div>
|
||||
<div class="text-3xl">→</div>
|
||||
<div class="text-3xl">✨</div>
|
||||
</div>
|
||||
<p class="text-xs sm:text-sm text-gray-700 mb-4">
|
||||
Une fois connecté, tu pourras installer l'app depuis le menu de ton profil
|
||||
</p>
|
||||
<button
|
||||
v-if="deferredPrompt"
|
||||
@click="handleInstallApp"
|
||||
class="w-full sm:w-auto bg-primary-600 hover:bg-primary-700 text-white font-medium py-3 px-6 rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105 text-sm sm:text-base"
|
||||
>
|
||||
<div class="flex items-center justify-center space-x-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>Installer l'app maintenant</span>
|
||||
</div>
|
||||
</button>
|
||||
<p v-else class="text-xs sm:text-sm text-gray-500">
|
||||
Voilà, c'est quand même mieux comme ça !
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</StepTransition>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<div class="flex items-center pt-6" :class="currentStep > 1 ? 'justify-between' : 'justify-end'">
|
||||
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 sm:gap-4 pt-4 sm:pt-6" :class="currentStep > 1 ? 'sm:justify-between' : 'sm:justify-end'">
|
||||
<button
|
||||
v-if="currentStep > 1"
|
||||
@click="previousStep"
|
||||
class="btn-secondary"
|
||||
class="btn-secondary w-full sm:w-auto order-2 sm:order-1 text-sm sm:text-base py-2.5 sm:py-2"
|
||||
:disabled="loading"
|
||||
>Précédent</button>
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="currentStep < totalSteps"
|
||||
@click="nextStep"
|
||||
:disabled="!canProceed || loading"
|
||||
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !canProceed }"
|
||||
>Suivant</button>
|
||||
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600 w-full sm:w-auto order-1 sm:order-2 text-sm sm:text-base py-2.5 sm:py-2"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !canProceed || loading }"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="currentStep === totalSteps"
|
||||
@click="handleRegister"
|
||||
:disabled="loading"
|
||||
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600"
|
||||
:disabled="loading || !canProceed"
|
||||
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600 w-full sm:w-auto order-1 sm:order-2 text-sm sm:text-base py-2.5 sm:py-2"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': loading || !canProceed }"
|
||||
>
|
||||
<span v-if="loading">Création en cours...</span>
|
||||
<span v-else>Créer mon compte</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mt-4 text-center text-red-600">
|
||||
<div v-if="error" class="mt-4 text-center text-xs sm:text-sm text-red-600 px-2">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Login Link -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
<div class="mt-6 sm:mt-8 text-center pb-4 sm:pb-0">
|
||||
<p class="text-xs sm:text-sm text-gray-600">
|
||||
Déjà un compte ?
|
||||
<router-link to="/login" class="font-medium text-primary-600 hover:text-primary-500">
|
||||
<router-link to="/login" class="font-medium text-primary-600 hover:text-primary-500 transition-colors">
|
||||
Se connecter
|
||||
</router-link>
|
||||
</p>
|
||||
@@ -147,7 +336,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import StepTransition from '@/components/StepTransition.vue'
|
||||
@@ -159,7 +348,9 @@ const router = useRouter()
|
||||
|
||||
// Step management
|
||||
const currentStep = ref(1)
|
||||
const totalSteps = 4
|
||||
const totalSteps = 5
|
||||
const tourStep = ref(0)
|
||||
const deferredPrompt = ref(null)
|
||||
|
||||
// Form data
|
||||
const form = ref({
|
||||
@@ -214,6 +405,10 @@ const canProceed = computed(() => {
|
||||
function nextStep() {
|
||||
if (currentStep.value < totalSteps && canProceed.value) {
|
||||
currentStep.value++
|
||||
// Reset tour step when entering step 4
|
||||
if (currentStep.value === 4) {
|
||||
tourStep.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,4 +437,35 @@ async function handleRegister() {
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// PWA Installation
|
||||
function handleBeforeInstallPrompt(e) {
|
||||
e.preventDefault()
|
||||
deferredPrompt.value = e
|
||||
}
|
||||
|
||||
async function handleInstallApp() {
|
||||
if (!deferredPrompt.value) return
|
||||
|
||||
try {
|
||||
deferredPrompt.value.prompt()
|
||||
const { outcome } = await deferredPrompt.value.userChoice
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
console.log('✅ PWA installée avec succès')
|
||||
}
|
||||
|
||||
deferredPrompt.value = null
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'installation:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -18,23 +18,23 @@
|
||||
<div v-else>
|
||||
<!-- Header -->
|
||||
<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">
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Retour aux vlogs
|
||||
</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
|
||||
@click="showEditModal = true"
|
||||
class="btn-secondary"
|
||||
class="flex-1 sm:flex-none btn-secondary justify-center"
|
||||
>
|
||||
<Edit class="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
@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" />
|
||||
Supprimer
|
||||
@@ -42,9 +42,9 @@
|
||||
</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">
|
||||
<img
|
||||
v-if="vlog.author_avatar"
|
||||
@@ -52,10 +52,10 @@
|
||||
:alt="vlog.author_name"
|
||||
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" />
|
||||
</div>
|
||||
<span>Par {{ vlog.author_name }}</span>
|
||||
<span>{{ vlog.author_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
@@ -68,6 +68,11 @@
|
||||
<span>{{ vlog.views_count }} vue{{ vlog.views_count > 1 ? 's' : '' }}</span>
|
||||
</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">
|
||||
<Clock class="w-4 h-4 mr-2" />
|
||||
<span>{{ formatDuration(vlog.duration) }}</span>
|
||||
@@ -76,7 +81,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
:src="vlog.video_url"
|
||||
:poster="vlog.thumbnail_url"
|
||||
@@ -84,6 +89,7 @@
|
||||
:description="vlog.description"
|
||||
:duration="vlog.duration"
|
||||
:views-count="vlog.views_count"
|
||||
:replays-count="vlog.replays_count"
|
||||
:likes-count="vlog.likes_count"
|
||||
:comments-count="vlog.comments?.length || 0"
|
||||
:is-liked="vlog.is_liked"
|
||||
@@ -93,13 +99,13 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<p class="text-gray-700 whitespace-pre-wrap">{{ vlog.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div class="card p-6 mb-8">
|
||||
<div class="card p-4 sm:p-6 mb-8">
|
||||
<VlogComments
|
||||
:vlog-id="vlog.id"
|
||||
:comments="vlog.comments || []"
|
||||
@@ -233,7 +239,8 @@ import {
|
||||
Edit,
|
||||
Trash2,
|
||||
Film,
|
||||
Play
|
||||
Play,
|
||||
RotateCcw
|
||||
} from 'lucide-vue-next'
|
||||
import VideoPlayer from '@/components/VideoPlayer.vue'
|
||||
import VlogComments from '@/components/VlogComments.vue'
|
||||
@@ -279,8 +286,9 @@ function formatDuration(seconds) {
|
||||
async function toggleLike() {
|
||||
try {
|
||||
const response = await axios.post(`/api/vlogs/${vlog.value.id}/like`)
|
||||
// Refresh vlog data to get updated like count
|
||||
await fetchVlog()
|
||||
// Update local state without refreshing full vlog data (avoiding view increment)
|
||||
vlog.value.likes_count = response.data.likes_count
|
||||
vlog.value.is_liked = !vlog.value.is_liked
|
||||
toast.success(response.data.message)
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour du like')
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<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 class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
|
||||
<div>
|
||||
<h1 class="text-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>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Vlogs</h1>
|
||||
<p class="text-sm sm:text-base text-gray-600 mt-1">Partagez vos moments en vidéo avec le groupe</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn-primary"
|
||||
class="w-full sm:w-auto btn-primary justify-center"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nouveau vlog
|
||||
|
||||
@@ -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')
|
||||
|
||||
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "LeDiscord",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
1
package.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||