Compare commits

...

20 Commits

Author SHA1 Message Date
d9ab8230a6 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
2026-01-25 21:06:06 +01:00
EvanChal
5bbe05000e feat(front+back): pwa added, register parkour update with it, and jeux added in coming soon
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 20s
2026-01-25 19:28:21 +01:00
EvanChal
0020c13bfd fix(video-player): fix the video player to permit the nagivation through the video (it was because de fast api server refused range request)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 2m15s
2026-01-25 18:08:38 +01:00
EvanChal
87de65e6b2 ci/cd update 6
Some checks failed
Deploy to Production / build-and-deploy (push) Failing after 19s
2025-12-24 03:46:58 +01:00
EvanChal
7325f25b5f ci/cd update 5
Some checks failed
Deploy to Production / build-and-deploy (push) Failing after 2s
2025-12-24 03:45:04 +01:00
EvanChal
362b19fd66 ci/cd update 4
Some checks failed
Deploy to Production / build-and-deploy (push) Failing after 36s
2025-12-24 03:41:20 +01:00
EvanChal
7f8897d76d ci/cd update 3
Some checks failed
Deploy to Production / build-and-deploy (push) Failing after 21s
2025-12-24 03:35:04 +01:00
EvanChal
2bd07d09d3 ci/cd update 2
Some checks failed
Deploy to Production / build-and-deploy (push) Has been cancelled
2025-12-24 03:34:27 +01:00
EvanChal
d3446aa428 ci/cd update
Some checks failed
Deploy to Production / build-and-deploy (push) Has been cancelled
2025-12-24 03:32:09 +01:00
EvanChal
3fbf372dae update ci/cd 11
Some checks failed
Deploy to Development / build-and-deploy (push) Successful in 25s
Deploy to Production / test (push) Waiting to run
Deploy to Production / build-and-deploy (push) Has been cancelled
2025-12-24 00:31:13 +01:00
EvanChal
480ae1e40e update ci/cd 10
All checks were successful
Deploy to Development / build-and-deploy (push) Successful in 15s
2025-12-24 00:29:01 +01:00
EvanChal
857b136124 update ci/cd 9
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 14s
2025-12-24 00:25:59 +01:00
EvanChal
93c9935465 update ci/cd 8
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 16s
2025-12-24 00:19:53 +01:00
EvanChal
7cd468a859 update ci/cd 7
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 1m40s
2025-12-24 00:14:56 +01:00
EvanChal
eebd26bb7d update ci/cd 6
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 2s
2025-12-24 00:13:54 +01:00
EvanChal
414eb824f8 update ci/cd 5
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 2s
2025-12-24 00:12:05 +01:00
EvanChal
2319a8c0cb update ci/cd 4
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 4s
2025-12-24 00:10:29 +01:00
EvanChal
6cf379f01b update ci/cd 3
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
2025-12-24 00:00:54 +01:00
EvanChal
b436e19087 update ci/cd 2
Some checks are pending
Deploy to Development / build-and-deploy (push) Waiting to run
2025-12-23 23:28:28 +01:00
EvanChal
ae3821a68e update ci/cd
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 9s
2025-12-23 23:25:15 +01:00
44 changed files with 14529 additions and 572 deletions

View File

@@ -8,49 +8,23 @@ on:
jobs: jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up Docker Buildx - name: Login to Gitea Registry
uses: docker/setup-buildx-action@v2 run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ secrets.REGISTRY_URL }} -u ${{ secrets.REGISTRY_USERNAME }} --password-stdin
- name: Login to Gitea Container Registry
uses: docker/login-action@v2 - name: Build and push
with: run: |
registry: ${{ secrets.GITEA_REGISTRY }} echo "Building backend image..."
username: ${{ secrets.GITEA_USERNAME }} docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:develop-backend ./backend
password: ${{ secrets.GITEA_TOKEN }} echo "Pushing backend image..."
docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:develop-backend
- name: Build and push backend image echo "Building frontend image..."
uses: docker/build-push-action@v4 docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:develop-frontend ./frontend
with: echo "Pushing frontend image..."
context: ./backend docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:develop-frontend
file: ./backend/Dockerfile
push: true
tags: ${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-backend:develop
cache-from: type=registry,ref=${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-backend:develop
cache-to: type=inline
- name: Build and push frontend image
uses: docker/build-push-action@v4
with:
context: ./frontend
file: ./frontend/Dockerfile
push: true
tags: ${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-frontend:develop
cache-from: type=registry,ref=${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-frontend:develop
cache-to: type=inline
- name: Deploy to development server
uses: appleboy/ssh-action@v0.1.7
with:
host: ${{ secrets.DEV_HOST }}
username: ${{ secrets.DEV_USERNAME }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
cd /path/to/lediscord
docker-compose pull
docker-compose up -d --build
docker-compose restart

View File

@@ -6,104 +6,44 @@ on:
- prod - prod
jobs: jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: lediscord_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install backend dependencies
run: |
cd backend
pip install -r requirements.txt
- name: Run backend tests
run: |
cd backend
python -m pytest tests/ -v || true
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/lediscord_test
JWT_SECRET_KEY: test-secret-key
CORS_ORIGINS: http://localhost:3000
ADMIN_EMAIL: test@test.com
ADMIN_PASSWORD: test123
ENVIRONMENT: test
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: |
cd frontend
npm ci
- name: Build frontend
run: |
cd frontend
npm run build
build-and-deploy: build-and-deploy:
needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up Docker Buildx - name: Configure DNS
uses: docker/setup-buildx-action@v2 run: |
echo "nameserver 192.168.1.50" > /etc/resolv.conf
- name: Login to Gitea Container Registry echo "nameserver 8.8.8.8" >> /etc/resolv.conf
uses: docker/login-action@v2 echo "DNS configured:"
with: cat /etc/resolv.conf
registry: ${{ secrets.GITEA_REGISTRY }} # Ajouter l'IP du registry à /etc/hosts si disponible
username: ${{ secrets.GITEA_USERNAME }} if [ -n "${{ secrets.REGISTRY_IP }}" ]; then
password: ${{ secrets.GITEA_TOKEN }} echo "${{ secrets.REGISTRY_IP }} ${{ secrets.REGISTRY_URL }}" >> /etc/hosts
echo "Added to /etc/hosts: ${{ secrets.REGISTRY_IP }} ${{ secrets.REGISTRY_URL }}"
- name: Build and push backend image fi
uses: docker/build-push-action@v4 # Tester la résolution DNS
with: echo "Testing DNS resolution..."
context: ./backend getent hosts ${{ secrets.REGISTRY_URL }} || echo "DNS resolution test"
file: ./backend/Dockerfile
push: true - name: Login to Gitea Registry
tags: | run: |
${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-backend:prod echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ secrets.REGISTRY_URL }} -u ${{ secrets.REGISTRY_USERNAME }} --password-stdin
${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-backend:latest
cache-from: type=registry,ref=${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-backend:prod - name: Build and push
cache-to: type=inline run: |
echo "Building backend image..."
- name: Build and push frontend image docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend ./backend
uses: docker/build-push-action@v4 docker tag ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:latest-backend
with: echo "Pushing backend images..."
context: ./frontend docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend
file: ./frontend/Dockerfile docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:latest-backend
push: true echo "Building frontend image..."
tags: | docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend ./frontend
${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-frontend:prod docker tag ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:latest-frontend
${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-frontend:latest echo "Pushing frontend images..."
cache-from: type=registry,ref=${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-frontend:prod docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend
cache-to: type=inline docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:latest-frontend

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,20 +1,20 @@
<template> <template>
<div class="min-h-screen bg-gradient-discord flex items-center justify-center p-4"> <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"> <div class="w-full max-w-md lg:max-w-lg flex flex-col">
<div class="text-center mb-4"> <div class="text-center mb-3 sm:mb-4">
<h1 class="text-4xl font-bold bg-gradient-primary bg-clip-text text-transparent mb-0">LeDiscord</h1> <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-secondary-600">Notre espace privé</p> <p class="text-xs sm:text-sm md:text-base text-secondary-600 mt-1">Notre espace privé</p>
</div> </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 /> <slot />
</div> </div>
<div class="text-center mt-4"> <div class="text-center mt-3 sm:mt-4">
<img <img
src="/logo_lediscord.png" src="/logo_lediscord.png"
alt="LeDiscord Logo" 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>
</div> </div>

View File

@@ -21,11 +21,21 @@
v-for="item in navigation" v-for="item in navigation"
:key="item.name" :key="item.name"
:to="item.to" :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" class="inline-flex items-center px-1 pt-1 text-sm font-medium border-b-2 border-transparent transition-colors relative"
active-class="!text-primary-600 !border-primary-600" :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" /> <component :is="item.icon" class="w-4 h-4 mr-2" />
{{ item.name }} {{ 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> </router-link>
</div> </div>
</div> </div>
@@ -39,8 +49,10 @@
<Bell class="w-5 h-5" /> <Bell class="w-5 h-5" />
<span <span
v-if="unreadNotifications > 0" 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> </button>
<!-- User Menu --> <!-- User Menu -->
@@ -112,6 +124,25 @@
Administration Administration
</router-link> </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"> <hr class="my-1">
<button <button
@click="logout" @click="logout"
@@ -122,22 +153,44 @@
</div> </div>
</transition> </transition>
</div> </div>
<!-- Mobile menu button -->
<div class="flex items-center sm:hidden ml-4">
<button
@click="isMobileMenuOpen = !isMobileMenuOpen"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
>
<span class="sr-only">Open main menu</span>
<Menu v-if="!isMobileMenuOpen" class="block h-6 w-6" aria-hidden="true" />
<X v-else class="block h-6 w-6" aria-hidden="true" />
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Mobile menu --> <!-- Mobile menu -->
<div class="sm:hidden"> <div class="sm:hidden border-t border-gray-200" v-show="isMobileMenuOpen">
<div class="pt-2 pb-3 space-y-1"> <div class="pt-2 pb-3 space-y-1">
<router-link <router-link
v-for="item in navigation" v-for="item in navigation"
:key="item.name" :key="item.name"
:to="item.to" :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" 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" /> <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> </router-link>
</div> </div>
</div> </div>
@@ -214,7 +267,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { format } from 'date-fns' import { format } from 'date-fns'
@@ -229,7 +282,9 @@ import {
Bell, Bell,
User, User,
ChevronDown, ChevronDown,
X X,
Menu,
Dice6
} from 'lucide-vue-next' } from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import TicketFloatingButton from '@/components/TicketFloatingButton.vue' import TicketFloatingButton from '@/components/TicketFloatingButton.vue'
@@ -242,11 +297,16 @@ const navigation = [
{ name: 'Événements', to: '/events', icon: Calendar }, { name: 'Événements', to: '/events', icon: Calendar },
{ name: 'Albums', to: '/albums', icon: Image }, { name: 'Albums', to: '/albums', icon: Image },
{ name: 'Vlogs', to: '/vlogs', icon: Film }, { 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 showUserMenu = ref(false)
const showNotifications = 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 user = computed(() => authStore.user)
const notifications = computed(() => authStore.notifications) const notifications = computed(() => authStore.notifications)
@@ -262,7 +322,8 @@ async function logout() {
} }
async function fetchNotifications() { async function fetchNotifications() {
await authStore.fetchNotifications() const result = await authStore.fetchNotifications()
// Les notifications sont maintenant mises à jour automatiquement via le polling
} }
async function markAllRead() { async function markAllRead() {
@@ -281,9 +342,82 @@ async function handleNotificationClick(notification) {
showNotifications.value = false 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 () => { onMounted(async () => {
await authStore.fetchCurrentUser() 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> </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() { async function logout() {
token.value = null token.value = null
user.value = null user.value = null
notifications.value = []
unreadCount.value = 0
localStorage.removeItem('token') localStorage.removeItem('token')
delete axios.defaults.headers.common['Authorization'] delete axios.defaults.headers.common['Authorization']
// Arrêter le polling des notifications
const notificationService = (await import('@/services/notificationService')).default
notificationService.stopPolling()
router.push('/login') router.push('/login')
toast.info('Déconnexion réussie') toast.info('Déconnexion réussie')
} }
@@ -130,10 +137,36 @@ export const useAuthStore = defineStore('auth', () => {
try { try {
const response = await axios.get('/api/notifications?limit=50') const response = await axios.get('/api/notifications?limit=50')
notifications.value = response.data const newNotifications = response.data
unreadCount.value = notifications.value.filter(n => !n.is_read).length
// 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) { } catch (error) {
console.error('Error fetching notifications:', 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> <div v-else>
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-4"> <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 space-y-4 sm:space-y-0">
<router-link to="/albums" class="text-primary-600 hover:text-primary-700 flex items-center"> <router-link to="/albums" class="text-primary-600 hover:text-primary-700 flex items-center">
<ArrowLeft class="w-4 h-4 mr-2" /> <ArrowLeft class="w-4 h-4 mr-2" />
Retour aux albums Retour aux albums
</router-link> </router-link>
<div v-if="canEdit" class="flex space-x-2"> <div v-if="canEdit" class="flex flex-wrap gap-2 w-full sm:w-auto">
<button <button
@click="showEditModal = true" @click="showEditModal = true"
class="btn-secondary" class="flex-1 sm:flex-none btn-secondary justify-center"
> >
<Edit class="w-4 h-4 mr-2" /> <Edit class="w-4 h-4 mr-2" />
Modifier Modifier
</button> </button>
<button <button
@click="showUploadModal = true" @click="showUploadModal = true"
class="btn-primary" class="flex-1 sm:flex-none btn-primary justify-center"
> >
<Upload class="w-4 h-4 mr-2" /> <Upload class="w-4 h-4 mr-2" />
Ajouter des médias Ajouter
</button> </button>
<button <button
@click="deleteAlbum" @click="deleteAlbum"
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300" class="flex-1 sm:flex-none btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300 justify-center"
> >
<Trash2 class="w-4 h-4 mr-2" /> <Trash2 class="w-4 h-4 mr-2" />
Supprimer Supprimer
@@ -49,9 +49,9 @@
</div> </div>
</div> </div>
<div class="flex items-start space-x-6"> <div class="flex flex-col md:flex-row items-start space-y-6 md:space-y-0 md:space-x-6">
<!-- Cover Image --> <!-- Cover Image -->
<div class="w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center"> <div class="w-full md:w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center flex-shrink-0">
<Image v-if="!album.cover_image" class="w-16 h-16 text-white" /> <Image v-if="!album.cover_image" class="w-16 h-16 text-white" />
<img <img
v-else v-else

View File

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

View File

@@ -18,23 +18,23 @@
<div v-else> <div v-else>
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-4"> <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 space-y-4 sm:space-y-0">
<router-link to="/events" class="text-primary-600 hover:text-primary-700 flex items-center"> <router-link to="/events" class="text-primary-600 hover:text-primary-700 flex items-center">
<ArrowLeft class="w-4 h-4 mr-2" /> <ArrowLeft class="w-4 h-4 mr-2" />
Retour aux événements Retour aux événements
</router-link> </router-link>
<div v-if="canEdit" class="flex space-x-2"> <div v-if="canEdit" class="flex w-full sm:w-auto space-x-2">
<button <button
@click="showEditModal = true" @click="showEditModal = true"
class="btn-secondary" class="flex-1 sm:flex-none btn-secondary justify-center"
> >
<Edit class="w-4 h-4 mr-2" /> <Edit class="w-4 h-4 mr-2" />
Modifier Modifier
</button> </button>
<button <button
@click="deleteEvent" @click="deleteEvent"
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300" class="flex-1 sm:flex-none btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300 justify-center"
> >
<Trash2 class="w-4 h-4 mr-2" /> <Trash2 class="w-4 h-4 mr-2" />
Supprimer Supprimer
@@ -42,9 +42,9 @@
</div> </div>
</div> </div>
<div class="flex items-start space-x-6"> <div class="flex flex-col md:flex-row items-start space-y-6 md:space-y-0 md:space-x-6">
<!-- Cover Image --> <!-- Cover Image -->
<div class="w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center"> <div class="w-full md:w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center flex-shrink-0">
<img <img
v-if="!event.cover_image && event.creator_avatar" v-if="!event.cover_image && event.creator_avatar"
:src="getMediaUrl(event.creator_avatar)" :src="getMediaUrl(event.creator_avatar)"
@@ -122,25 +122,6 @@
<p class="text-gray-700 whitespace-pre-wrap">{{ event.description }}</p> <p class="text-gray-700 whitespace-pre-wrap">{{ event.description }}</p>
</div> </div>
<!-- Map Section -->
<div v-if="event.latitude && event.longitude" class="card p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Localisation</h2>
<div class="bg-gray-100 rounded-lg p-6 h-48 flex items-center justify-center">
<div class="text-center text-gray-600">
<MapPin class="w-12 h-12 mx-auto mb-2 text-primary-600" />
<p class="text-sm">Carte interactive</p>
<p class="text-xs mt-1">{{ event.latitude }}, {{ event.longitude }}</p>
<a
:href="`https://www.openstreetmap.org/?mlat=${event.latitude}&mlon=${event.longitude}&zoom=15`"
target="_blank"
class="text-primary-600 hover:underline text-sm mt-2 inline-block"
>
Voir sur OpenStreetMap
</a>
</div>
</div>
</div>
<!-- My Participation --> <!-- My Participation -->
<div class="card p-6 mb-8"> <div class="card p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Ma participation</h2> <h2 class="text-xl font-semibold text-gray-900 mb-4">Ma participation</h2>

View File

@@ -1,14 +1,14 @@
<template> <template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-center mb-8"> <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
<div> <div>
<h1 class="text-3xl font-bold text-gray-900">Événements</h1> <h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Événements</h1>
<p class="text-gray-600 mt-1">Organisez et participez aux événements du groupe</p> <p class="text-sm sm:text-base text-gray-600 mt-1">Organisez et participez aux événements du groupe</p>
</div> </div>
<button <button
@click="showCreateModal = true" @click="showCreateModal = true"
class="btn-primary" class="w-full sm:w-auto btn-primary justify-center"
> >
<Plus class="w-4 h-4 mr-2" /> <Plus class="w-4 h-4 mr-2" />
Nouvel événement Nouvel événement
@@ -214,51 +214,6 @@
> >
</div> </div>
<!-- Map Section -->
<div>
<label class="label">Coordonnées géographiques (optionnel)</label>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-sm text-gray-600">Latitude</label>
<input
v-model="newEvent.latitude"
type="number"
step="0.000001"
class="input"
placeholder="48.8566"
>
</div>
<div>
<label class="text-sm text-gray-600">Longitude</label>
<input
v-model="newEvent.longitude"
type="number"
step="0.000001"
class="input"
placeholder="2.3522"
>
</div>
</div>
<!-- Map Preview -->
<div v-if="newEvent.latitude && newEvent.longitude" class="mt-3">
<div class="bg-gray-100 rounded-lg p-4 h-32 flex items-center justify-center">
<div class="text-center text-gray-600">
<MapPin class="w-8 h-8 mx-auto mb-2 text-primary-600" />
<p class="text-sm">Localisation sélectionnée</p>
<p class="text-xs mt-1">{{ newEvent.latitude }}, {{ newEvent.longitude }}</p>
<a
:href="`https://www.openstreetmap.org/?mlat=${newEvent.latitude}&mlon=${newEvent.longitude}&zoom=15`"
target="_blank"
class="text-primary-600 hover:underline text-xs mt-2 inline-block"
>
Voir sur OpenStreetMap
</a>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="label">Date et heure</label> <label class="label">Date et heure</label>
@@ -339,8 +294,6 @@ const newEvent = ref({
description: '', description: '',
date: '', date: '',
location: '', location: '',
latitude: null,
longitude: null,
end_date: null end_date: null
}) })
@@ -453,8 +406,6 @@ async function createEvent() {
description: newEvent.value.description, description: newEvent.value.description,
date: new Date(newEvent.value.date).toISOString(), date: new Date(newEvent.value.date).toISOString(),
location: newEvent.value.location, location: newEvent.value.location,
latitude: newEvent.value.latitude,
longitude: newEvent.value.longitude,
end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null
} }
@@ -478,8 +429,6 @@ function resetForm() {
description: '', description: '',
date: '', date: '',
location: '', location: '',
latitude: null,
longitude: null,
end_date: null end_date: null
} }
} }

View File

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

View File

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

View File

@@ -1,144 +1,333 @@
<template> <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 --> <!-- Header -->
<div class="text-center"> <div class="text-center pt-2 sm:pt-0">
<h2 class="text-2xl font-bold text-gray-900">Créer un compte</h2> <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-sm text-gray-500">Rejoignez notre communauté en quelques étapes</p> <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> </div>
<!-- Progress Bar --> <!-- Progress Bar -->
<div class="pt-4"> <div class="pt-2 sm:pt-4">
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">Étape {{ currentStep }} sur {{ totalSteps }}</span> <span class="text-xs sm: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> <span class="text-xs sm:text-sm font-medium text-gray-500">{{ Math.round((currentStep / totalSteps) * 100) }}%</span>
</div> </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 <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}%` }" :style="{ width: `${(currentStep / totalSteps) * 100}%` }"
></div> ></div>
</div> </div>
</div> </div>
<!-- Step Content --> <!-- 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"> <StepTransition :step="currentStep">
<!-- Step 1: Welcome --> <!-- Step 1: Welcome -->
<div v-if="currentStep === 1" class="text-center"> <div v-if="currentStep === 1" class="text-center px-2 sm:px-0">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Bienvenue sur LeDiscord !</h3> <br />
<p class="text-gray-600 max-w-sm mx-auto"> <br />
Nous sommes ravis de vous accueillir. Préparez-vous à rejoindre une communauté passionnante. <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> </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> </div>
<!-- Step 2: Registration Form --> <!-- Step 2: Registration Form -->
<div v-if="currentStep === 2"> <div v-if="currentStep === 2" class="w-full">
<form @submit.prevent="nextStep" class="space-y-6"> <form @submit.prevent="nextStep" class="space-y-4 sm:space-y-6">
<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> <div>
<label for="email" class="label">Email</label> <label for="email" class="label text-sm sm:text-base">Email</label>
<input id="email" v-model="form.email" type="email" required class="input" @blur="touchedFields.email = true"> <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>
<div> <div>
<label for="username" class="label">Nom d'utilisateur</label> <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" @blur="touchedFields.username = true"> <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> </div>
<div> <div>
<label for="full_name" class="label">Nom complet</label> <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" @blur="touchedFields.full_name = true"> <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>
<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> <div>
<label for="password" class="label">Mot de passe</label> <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" @blur="touchedFields.password = true"> <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>
<div> <div>
<label for="password_confirm" class="label">Confirmer</label> <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" @blur="touchedFields.password_confirm = true"> <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>
</div> </div>
<PasswordStrength :password="form.password" /> <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"> <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" 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> <svg class="w-4 h-4 mr-1 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
Les mots de passe ne correspondent pas <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" />
</div> </svg>
<span>Les mots de passe ne correspondent pas</span>
</div>
</form> </form>
</div> </div>
<!-- Step 3: Warning --> <!-- Step 3: Warning -->
<div v-if="currentStep === 3" class="text-center"> <div v-if="currentStep === 3" class="w-full px-2 sm:px-0 text-center">
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4"> <h3 class="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
<div class="flex"> Version Bêta en cours ! 🚧
<div class="flex-shrink-0"> </h3>
<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 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">
</svg> <p class="text-sm sm:text-base text-gray-700 leading-relaxed mb-4">
</div> 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> 💬
<div class="ml-3 text-left"> </p>
<p class="text-sm text-yellow-700"> <div class="flex items-center justify-center space-x-2 text-xs sm:text-sm text-gray-600">
LeDiscord est actuellement en version bêta. Votre retour est précieux pour nous aider à améliorer la plateforme. <span>🔧</span>
</p> <span>J'améliore au fur et à mesure</span>
</div> </div>
</div> </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> </div>
<!-- Step 4: Features Tour --> <!-- Step 4: Interactive Tour -->
<div v-if="currentStep === 4" class="text-center"> <div v-if="currentStep === 4" class="w-full px-2 sm:px-0">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Découvrez les fonctionnalités</h3> <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="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="max-w-lg mx-auto">
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center"> <!-- Tour Content -->
<span v-html="feature.icon"></span> <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>
<div>
<h4 class="font-semibold">{{ feature.title }}</h4> <div v-if="tourStep === 1" class="text-center">
<p class="text-sm text-gray-600">{{ feature.description }}</p> <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>
</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>
</div> </div>
</StepTransition> </StepTransition>
</div> </div>
<!-- Navigation Buttons --> <!-- 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 <button
v-if="currentStep > 1" v-if="currentStep > 1"
@click="previousStep" @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" :disabled="loading"
>Précédent</button> >
Précédent
</button>
<button <button
v-if="currentStep < totalSteps" v-if="currentStep < totalSteps"
@click="nextStep" @click="nextStep"
:disabled="!canProceed || loading" :disabled="!canProceed || loading"
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600" 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 }" :class="{ 'opacity-50 cursor-not-allowed': !canProceed || loading }"
>Suivant</button> >
Suivant
</button>
<button <button
v-if="currentStep === totalSteps" v-if="currentStep === totalSteps"
@click="handleRegister" @click="handleRegister"
:disabled="loading" :disabled="loading || !canProceed"
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600" 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-if="loading">Création en cours...</span>
<span v-else>Créer mon compte</span> <span v-else>Créer mon compte</span>
</button> </button>
</div> </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 }} {{ error }}
</div> </div>
<!-- Login Link --> <!-- Login Link -->
<div class="mt-8 text-center"> <div class="mt-6 sm:mt-8 text-center pb-4 sm:pb-0">
<p class="text-sm text-gray-600"> <p class="text-xs sm:text-sm text-gray-600">
Déjà un compte ? 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 Se connecter
</router-link> </router-link>
</p> </p>
@@ -147,7 +336,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import StepTransition from '@/components/StepTransition.vue' import StepTransition from '@/components/StepTransition.vue'
@@ -159,7 +348,9 @@ const router = useRouter()
// Step management // Step management
const currentStep = ref(1) const currentStep = ref(1)
const totalSteps = 4 const totalSteps = 5
const tourStep = ref(0)
const deferredPrompt = ref(null)
// Form data // Form data
const form = ref({ const form = ref({
@@ -214,6 +405,10 @@ const canProceed = computed(() => {
function nextStep() { function nextStep() {
if (currentStep.value < totalSteps && canProceed.value) { if (currentStep.value < totalSteps && canProceed.value) {
currentStep.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 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> </script>

View File

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

View File

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

View File

@@ -2,6 +2,15 @@ const { defineConfig } = require('vite')
const vue = require('@vitejs/plugin-vue') const vue = require('@vitejs/plugin-vue')
const path = require('path') 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 // Configuration par environnement
const getEnvironmentConfig = (mode) => { const getEnvironmentConfig = (mode) => {
const configs = { const configs = {
@@ -79,8 +88,163 @@ module.exports = defineConfig(({ command, mode }) => {
VITE_APP_URL: process.env.VITE_APP_URL 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 { return {
plugins: [vue()], plugins,
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src') '@': 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 @@
{}