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
This commit was merged in pull request #1.
This commit is contained in:
2026-01-25 21:06:06 +01:00
42 changed files with 14476 additions and 433 deletions

View File

@@ -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
# 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
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,

View File

@@ -1,9 +1,10 @@
from fastapi import FastAPI
from fastapi import FastAPI, Request, Response, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.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"])

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.orm import relationship
from 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'),
)

View File

@@ -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

View File

@@ -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
# Convert value based on expected type (basic handling)
if isinstance(default, int):
try:
return int(value)
return int(setting.value)
except ValueError:
pass
return default
elif isinstance(default, bool):
return setting.value.lower() == "true"
elif isinstance(default, list):
return setting.value.split(",")
# Nombres flottants
try:
return float(value)
except ValueError:
pass
return setting.value
# JSON
if value.startswith('{') or value.startswith('['):
try:
return json.loads(value)
except json.JSONDecodeError:
pass
@staticmethod
def set_setting(db: Session, key: str, value: str, description: str = None, category: str = "general") -> SystemSettings:
"""Set a setting value."""
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
if setting:
setting.value = str(value)
if description:
setting.description = description
if category:
setting.category = category
else:
setting = SystemSettings(
key=key,
value=str(value),
description=description,
category=category
)
db.add(setting)
# Liste séparée par des virgules
if ',' in value and not value.startswith('{') and not value.startswith('['):
return [item.strip() for item in value.split(',')]
return value
db.commit()
db.refresh(setting)
return setting
@staticmethod
def get_upload_limits(db: Session) -> Dict[str, Any]:
@@ -60,7 +64,7 @@ class SettingsService:
}
@staticmethod
def get_max_upload_size(db: Session, content_type: str) -> int:
def get_max_upload_size(db: Session, content_type: str, is_vlog: bool = False) -> int:
"""Get max upload size for a specific content type."""
if content_type.startswith('image/'):
max_size_mb = SettingsService.get_setting(db, "max_image_size_mb", 10)
@@ -68,9 +72,14 @@ class SettingsService:
print(f"DEBUG - Image upload limit: {max_size_mb}MB = {max_size_bytes} bytes")
return max_size_bytes
elif content_type.startswith('video/'):
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:
if isinstance(allowed, str):
allowed = allowed.split(",")
return content_type in allowed
return False
return content_type in allowed_types

51
frontend/PWA_SETUP.md Normal file
View 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

View 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
View 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} didnt 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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View 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>

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View 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" }]
}
]
}

View 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()

View 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>

View File

@@ -2,29 +2,37 @@
<div class="video-player-container">
<div class="relative">
<!-- Video.js Player -->
<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"
data-setup="{}"
playsinline
>
<source :src="videoUrl" type="video/mp4" />
<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">
<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 }} vue{{ viewsCount > 1 ? 's' : '' }}
{{ 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 {

View File

@@ -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>

View File

@@ -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()
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>

View 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()

View File

@@ -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 }
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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">

View File

@@ -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 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" />
<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>
<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 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: 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 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>
<!-- 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>
<!-- 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>
<div>
<h4 class="font-semibold">{{ feature.title }}</h4>
<p class="text-sm text-gray-600">{{ feature.description }}</p>
<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>

View File

@@ -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')

View File

@@ -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

View File

@@ -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
View File

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

1
package.json Normal file
View File

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