Compare commits

...

24 Commits

Author SHA1 Message Date
EvanChal
7ca168c34c fix
All checks were successful
Deploy to Development / build-and-deploy (push) Successful in 22s
Deploy to Production / build-and-deploy (push) Successful in 4s
2026-01-28 22:31:17 +01:00
EvanChal
8ff0f22682 fix(bcrypt version)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 49s
2026-01-28 20:59:58 +01:00
EvanChal
0eea2f1a1d fix(alembic-migration)
All checks were successful
Deploy to Development / build-and-deploy (push) Successful in 6s
Deploy to Production / build-and-deploy (push) Successful in 4s
2026-01-28 20:53:53 +01:00
EvanChal
d68af8d5a1 fix(dockerfile)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 33s
2026-01-28 20:47:34 +01:00
EvanChal
97ae75c9bf fix(requirement): alemic was missing
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m23s
2026-01-27 02:53:57 +01:00
EvanChal
f33dfd5ab7 fix(notification+vlog upload)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 46s
Deploy to Production / build-and-deploy (push) Successful in 1m47s
2026-01-27 02:39:51 +01:00
EvanChal
658b7a9dda fix(front): try to correct crash on ios
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m39s
2026-01-26 23:03:49 +01:00
EvanChal
08810440e0 fix(date): correction on date utils
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m51s
2026-01-26 22:43:35 +01:00
EvanChal
d63f2f9f51 fix(pwa): added PWAInstallTutorial to handle pwa install instructions
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 36s
Deploy to Production / build-and-deploy (push) Successful in 1m48s
2026-01-26 22:08:57 +01:00
EvanChal
02a54f5625 fix(pwa+timezone)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m32s
2026-01-25 23:54:42 +01:00
EvanChal
e32b1ce04e fix(pwa)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 54s
2026-01-25 23:24:25 +01:00
EvanChal
f63b204c5c fix(migration)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 29s
2026-01-25 23:14:26 +01:00
EvanChal
127aef60e3 fix(migration)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m3s
2026-01-25 23:07:42 +01:00
EvanChal
cb11a74d33 fix(dockefile)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 48s
2026-01-25 23:04:14 +01:00
EvanChal
a3a1d3306d fix(dockerfile-fontend)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 8s
Deploy to Production / build-and-deploy (push) Successful in 1m38s
2026-01-25 22:52:08 +01:00
EvanChal
4756c34fd1 fix(dockerfile-fontend)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m46s
2026-01-25 22:47:36 +01:00
EvanChal
2b172c5d34 fix(ci/cd)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m48s
2026-01-25 22:43:37 +01:00
EvanChal
e4b9e354ef fix(ci/cd)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 8s
Deploy to Production / build-and-deploy (push) Failing after 2s
2026-01-25 22:42:38 +01:00
EvanChal
634b850a89 fix(ci/cd)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Failing after 1s
2026-01-25 22:41:28 +01:00
EvanChal
69a07c9e07 fix(ci/cd)
Some checks failed
Deploy to Production / build-and-deploy (push) Waiting to run
Deploy to Development / build-and-deploy (push) Has been cancelled
2026-01-25 22:39:07 +01:00
EvanChal
8f8e1046c1 fix(ci/cd)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Has been cancelled
2026-01-25 22:34:09 +01:00
EvanChal
d1bfa488a6 fix(ci/cd)
Some checks failed
Deploy to Production / test (push) Waiting to run
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Has been cancelled
2026-01-25 22:32:07 +01:00
EvanChal
ec30db4c2d fix(ci/cd)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / test (push) Failing after 0s
Deploy to Production / build-and-deploy (push) Has been skipped
2026-01-25 22:25:55 +01:00
EvanChal
a47a70e608 fix(dockerfile)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 14s
2026-01-25 22:22:49 +01:00
60 changed files with 11475 additions and 459 deletions

View File

@@ -6,104 +6,38 @@ on:
- prod
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:
needs: test
runs-on: ubuntu-latest
container:
image: docker:24-dind
options: --privileged
steps:
- name: Install git
run: apk add --no-cache git
- name: Checkout code
uses: actions/checkout@v3
run: |
git clone --depth 1 --branch prod https://${{ secrets.REGISTRY_USERNAME }}:${{ secrets.REGISTRY_TOKEN }}@git.local.evan.casa/evan/lediscord.git .
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Registry
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
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push backend
run: |
docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend ./backend
for i in 1 2 3 4 5; do
docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend && break || sleep 10
done
- name: Build and push backend image
uses: docker/build-push-action@v4
with:
context: ./backend
file: ./backend/Dockerfile
push: true
tags: |
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord-backend:prod
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord-backend:latest
cache-from: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord-backend:prod
cache-to: type=inline
- name: Build and push frontend
run: |
docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend ./frontend
for i in 1 2 3 4 5; do
docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend && break || sleep 10
done
- name: Build and push frontend image
uses: docker/build-push-action@v4
with:
context: ./frontend
file: ./frontend/Dockerfile
push: true
tags: |
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord-frontend:prod
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord-frontend:latest
cache-from: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord-frontend:prod
cache-to: type=inline
- name: Done
run: |
echo "🚀 Images pushed! Run: nomad job run -force lediscord.nomad"

View File

@@ -42,17 +42,9 @@ COPY . .
# Permissions
RUN chmod -R 755 /app
RUN chmod +x entrypoint.sh
EXPOSE 8000
# Hot-reload + respect des en-têtes proxy (utile si tu testes derrière Traefik en dev)
# Astuce: on exclut uploads/logs du reload pour éviter les restarts inutiles
CMD ["uvicorn", "app:app", \
"--reload", \
"--reload-exclude", "uploads/*", \
"--reload-exclude", "logs/*", \
"--host", "0.0.0.0", \
"--port", "8000", \
"--log-level", "debug", \
"--proxy-headers", \
"--forwarded-allow-ips=*"]
# Lancer les migrations puis démarrer l'application
CMD ["./entrypoint.sh"]

View File

@@ -135,3 +135,5 @@ backend/
- [Documentation Alembic](https://alembic.sqlalchemy.org/)
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)

View File

@@ -113,3 +113,5 @@ formatter = generic
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -9,6 +9,7 @@ from models.notification import Notification, NotificationType
from schemas.event import EventCreate, EventUpdate, EventResponse, ParticipationUpdate
from utils.security import get_current_active_user
from utils.email import send_event_notification
from utils.push_service import send_push_to_user
router = APIRouter()
@@ -48,15 +49,22 @@ async def create_event(
# Create notification
if user.id != current_user.id:
notif_title = f"Invitation à un événement privé: {event.title}"
notif_message = f"{current_user.full_name} vous a invité à un événement privé"
notif_link = f"/events/{event.id}"
notification = Notification(
user_id=user.id,
type=NotificationType.EVENT_INVITATION,
title=f"Invitation à un événement privé: {event.title}",
message=f"{current_user.full_name} vous a invité à un événement privé",
link=f"/events/{event.id}"
title=notif_title,
message=notif_message,
link=notif_link
)
db.add(notification)
# Send push notification
send_push_to_user(db, user.id, notif_title, notif_message, notif_link)
# Send email notification
try:
send_event_notification(user.email, event)
@@ -75,15 +83,22 @@ async def create_event(
# Create notification
if user.id != current_user.id:
notif_title = f"Nouvel événement: {event.title}"
notif_message = f"{current_user.full_name} a créé un nouvel événement"
notif_link = f"/events/{event.id}"
notification = Notification(
user_id=user.id,
type=NotificationType.EVENT_INVITATION,
title=f"Nouvel événement: {event.title}",
message=f"{current_user.full_name} a créé un nouvel événement",
link=f"/events/{event.id}"
title=notif_title,
message=notif_message,
link=notif_link
)
db.add(notification)
# Send push notification
send_push_to_user(db, user.id, notif_title, notif_message, notif_link)
# Send email notification
try:
send_event_notification(user.email, event)
@@ -309,15 +324,22 @@ async def invite_users_to_event(
db.add(participation)
# Créer une notification
notif_title = f"Invitation à un événement privé: {event.title}"
notif_message = f"{current_user.full_name} vous a invité à un événement privé"
notif_link = f"/events/{event.id}"
notification = Notification(
user_id=user.id,
type=NotificationType.EVENT_INVITATION,
title=f"Invitation à un événement privé: {event.title}",
message=f"{current_user.full_name} vous a invité à un événement privé",
link=f"/events/{event.id}"
title=notif_title,
message=notif_message,
link=notif_link
)
db.add(notification)
# Send push notification
send_push_to_user(db, user.id, notif_title, notif_message, notif_link)
# Envoyer un email
try:
send_event_notification(user.email, event)

170
backend/api/routers/push.py Normal file
View File

@@ -0,0 +1,170 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from config.database import get_db
from config.settings import settings
from models.notification import PushSubscription
from models.user import User
from schemas.notification import PushSubscriptionCreate, VapidPublicKeyResponse
from utils.security import get_current_active_user
from utils.push_service import is_push_configured, send_push_to_user
import base64
router = APIRouter()
def get_vapid_public_key_for_web_push():
"""
Convertit la clé publique VAPID au format attendu par Web Push.
Les clés VAPID peuvent être au format:
1. Base64url brut (65 octets décodés) - Format attendu par Web Push
2. PEM/DER complet (commence par MFk...) - Doit être converti
Web Push attend la clé publique non compressée (65 octets = 0x04 + X + Y)
"""
public_key = settings.VAPID_PUBLIC_KEY
if not public_key:
return None
# Nettoyer la clé (enlever les éventuels espaces/newlines)
public_key = public_key.strip()
# Si la clé commence par MFk, c'est au format DER/SubjectPublicKeyInfo
# On doit extraire les 65 derniers octets
if public_key.startswith('MFk'):
try:
# Décoder le DER
# Ajouter le padding si nécessaire
padding = 4 - len(public_key) % 4
if padding != 4:
public_key += '=' * padding
der_bytes = base64.b64decode(public_key)
# La clé publique non compressée est les 65 derniers octets
# (SubjectPublicKeyInfo header + la clé)
if len(der_bytes) >= 65:
raw_key = der_bytes[-65:]
# Ré-encoder en base64url sans padding
return base64.urlsafe_b64encode(raw_key).rstrip(b'=').decode('ascii')
except Exception as e:
print(f"Erreur conversion clé VAPID: {e}")
return public_key
return public_key
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
async def get_vapid_public_key_endpoint(current_user: User = Depends(get_current_active_user)):
"""Get the VAPID public key for push notifications."""
public_key = get_vapid_public_key_for_web_push()
if not public_key:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="VAPID keys not configured on server"
)
return {"public_key": public_key}
@router.post("/subscribe")
async def subscribe_push(
subscription: PushSubscriptionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Subscribe to push notifications."""
# Check if subscription already exists
existing = db.query(PushSubscription).filter(
PushSubscription.endpoint == subscription.endpoint
).first()
if existing:
# Update existing subscription
existing.user_id = current_user.id
existing.p256dh = subscription.keys.p256dh
existing.auth = subscription.keys.auth
db.commit()
return {"message": "Subscription updated"}
# Create new subscription
new_sub = PushSubscription(
user_id=current_user.id,
endpoint=subscription.endpoint,
p256dh=subscription.keys.p256dh,
auth=subscription.keys.auth
)
db.add(new_sub)
db.commit()
return {"message": "Subscribed successfully"}
@router.delete("/unsubscribe")
async def unsubscribe_push(
endpoint: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Unsubscribe from push notifications."""
db.query(PushSubscription).filter(
PushSubscription.endpoint == endpoint
).delete()
db.commit()
return {"message": "Unsubscribed successfully"}
@router.post("/test")
async def test_push_notification(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Send a test push notification to the current user."""
if not is_push_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Push notifications not configured. Check VAPID keys."
)
# Vérifier que l'utilisateur a au moins un abonnement
sub_count = db.query(PushSubscription).filter(
PushSubscription.user_id == current_user.id
).count()
if sub_count == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Aucun appareil enregistré pour les notifications push"
)
# Envoyer la notification de test
sent = send_push_to_user(
db,
current_user.id,
"🔔 Test de notification",
"Les notifications push fonctionnent correctement !",
"/"
)
return {
"message": f"Notification de test envoyée à {sent} appareil(s)",
"devices_registered": sub_count,
"sent_successfully": sent
}
@router.get("/status")
async def get_push_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Get the push notification configuration status."""
sub_count = db.query(PushSubscription).filter(
PushSubscription.user_id == current_user.id
).count()
return {
"configured": is_push_configured(),
"vapid_public_key_set": bool(settings.VAPID_PUBLIC_KEY),
"vapid_private_key_set": bool(settings.VAPID_PRIVATE_KEY),
"user_subscriptions": sub_count
}

View File

@@ -13,6 +13,7 @@ from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate
from utils.security import get_current_active_user
from utils.video_utils import generate_video_thumbnail, get_video_duration
from utils.settings_service import SettingsService
from utils.push_service import send_push_to_user
router = APIRouter()
@@ -254,12 +255,15 @@ async def upload_vlog_video(
"""Upload a vlog video."""
# Validate video file
if not SettingsService.is_file_type_allowed(db, video.content_type or "unknown"):
allowed_types = SettingsService.get_setting(db, "allowed_video_types",
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid video type. Allowed types: {', '.join(allowed_types)}"
)
# Fallback check for common video types if content_type is generic application/octet-stream
filename = video.filename.lower()
if not (filename.endswith('.mp4') or filename.endswith('.mov') or filename.endswith('.webm') or filename.endswith('.mkv')):
allowed_types = SettingsService.get_setting(db, "allowed_video_types",
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid video type. Allowed types: {', '.join(allowed_types)}"
)
# Check file size
video_content = await video.read()
@@ -335,17 +339,24 @@ async def upload_vlog_video(
# Create notifications for all active users (except the creator)
users = db.query(User).filter(User.is_active == True).all()
notif_title = "Nouveau vlog"
notif_message = f"{current_user.full_name} a publié un nouveau vlog : {vlog.title}"
notif_link = f"/vlogs/{vlog.id}"
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}",
title=notif_title,
message=notif_message,
link=notif_link,
is_read=False
)
db.add(notification)
# Envoyer la notification push
send_push_to_user(db, user.id, notif_title, notif_message, notif_link)
db.commit()

View File

@@ -8,7 +8,7 @@ import mimetypes
from config.settings import settings
from config.database import engine, Base
from api.routers import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings as settings_router, information, tickets
from api.routers import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings as settings_router, information, tickets, push
from utils.init_db import init_database
from utils.settings_service import SettingsService
from config.database import SessionLocal
@@ -203,6 +203,21 @@ app.add_middleware(
expose_headers=["Content-Range", "Accept-Ranges"],
)
# Middleware de debug pour les requêtes POST (à désactiver en production stable)
@app.middleware("http")
async def debug_auth_middleware(request: Request, call_next):
"""Log les informations d'authentification pour debug."""
if request.method == "POST" and "/api/" in request.url.path:
auth_header = request.headers.get("authorization", "")
has_auth = bool(auth_header)
auth_preview = auth_header[:30] + "..." if len(auth_header) > 30 else auth_header
print(f"🔍 DEBUG POST {request.url.path}: auth_header={'present' if has_auth else 'MISSING'}, preview={auth_preview}")
response = await call_next(request)
return response
# Endpoint personnalisé pour servir les vidéos avec support Range
@app.get("/uploads/{file_path:path}")
async def serve_media_with_range(request: Request, file_path: str):
@@ -294,6 +309,7 @@ app.include_router(vlogs.router, prefix="/api/vlogs", tags=["Vlogs"])
app.include_router(stats.router, prefix="/api/stats", tags=["Statistics"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(push.router, prefix="/api/push", tags=["Push Notifications"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"])
app.include_router(information.router, prefix="/api/information", tags=["Information"])
app.include_router(tickets.router, prefix="/api/tickets", tags=["Tickets"])

View File

@@ -55,6 +55,11 @@ class Settings:
if not ADMIN_PASSWORD:
raise ValueError("ADMIN_PASSWORD variable is required")
# Notifications Push (VAPID)
VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "")
VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "")
VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "mailto:admin@lediscord.com")
# App
APP_NAME: str = os.getenv("APP_NAME", "LeDiscord")
APP_URL: str = os.getenv("APP_URL", "http://localhost:5173")

29
backend/entrypoint.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
set -e
echo "Running database migrations..."
alembic upgrade head
echo "Starting application..."
# Détecter l'environnement (production ou développement)
if [ "$ENVIRONMENT" = "production" ]; then
echo "Running in production mode..."
exec uvicorn app:app \
--host 0.0.0.0 \
--port 8000 \
--log-level info \
--proxy-headers \
--forwarded-allow-ips=*
else
echo "Running in development mode..."
exec uvicorn app:app \
--reload \
--reload-exclude "uploads/*" \
--reload-exclude "logs/*" \
--host 0.0.0.0 \
--port 8000 \
--log-level debug \
--proxy-headers \
--forwarded-allow-ips=*
fi

View File

@@ -41,3 +41,5 @@ alembic revision -m "Description de la migration"
- Testez toujours les migrations en développement avant de les appliquer en production
- En cas de problème, vous pouvez toujours revenir en arrière avec `alembic downgrade`

View File

@@ -25,3 +25,5 @@ def upgrade() -> None:
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,54 @@
"""Initial schema
Revision ID: 0001_initial
Revises:
Create Date: 2026-01-25 19:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
from config.database import Base
# Import all models to ensure they're registered with Base.metadata
from models import (
User,
Event,
EventParticipation,
Album,
Media,
MediaLike,
Post,
PostMention,
PostLike,
PostComment,
Vlog,
VlogLike,
VlogComment,
Notification,
SystemSettings,
Information,
Ticket
)
# revision identifiers, used by Alembic.
revision: str = '0001_initial'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create all tables using Base.metadata with the Alembic connection
# This uses the connection from the Alembic context
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade() -> None:
# Drop all tables
bind = op.get_bind()
Base.metadata.drop_all(bind=bind)

View File

@@ -0,0 +1,46 @@
"""Add push_subscriptions table for Web Push notifications
Revision ID: 0002_push_subscriptions
Revises: 89527c8da8e1
Create Date: 2025-01-27
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = '0002_push_subscriptions'
down_revision = '89527c8da8e1'
branch_labels = None
depends_on = None
def upgrade():
# Check if table already exists (idempotent migration)
conn = op.get_bind()
inspector = inspect(conn)
tables = inspector.get_table_names()
if 'push_subscriptions' not in tables:
op.create_table(
'push_subscriptions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('endpoint', sa.String(), nullable=False),
sa.Column('p256dh', sa.String(), nullable=False),
sa.Column('auth', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('endpoint')
)
op.create_index(op.f('ix_push_subscriptions_id'), 'push_subscriptions', ['id'], unique=False)
def downgrade():
op.drop_index(op.f('ix_push_subscriptions_id'), table_name='push_subscriptions')
op.drop_table('push_subscriptions')

View File

@@ -13,23 +13,34 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '89527c8da8e1'
down_revision: Union[str, None] = None
down_revision: Union[str, None] = '0001_initial'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Ajouter la colonne avec nullable=True d'abord
op.add_column('events', sa.Column('is_private', sa.Boolean(), nullable=True))
# Mettre à jour toutes les lignes existantes avec False
op.execute("UPDATE events SET is_private = FALSE WHERE is_private IS NULL")
# Rendre la colonne non-nullable avec une valeur par défaut
op.alter_column('events', 'is_private',
existing_type=sa.Boolean(),
nullable=False,
server_default='false')
# ### end Alembic commands ###
# Vérifier si la colonne existe déjà (cas où la migration initiale l'a déjà créée)
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = [col['name'] for col in inspector.get_columns('events')]
if 'is_private' not in columns:
# Ajouter la colonne avec nullable=True d'abord
op.add_column('events', sa.Column('is_private', sa.Boolean(), nullable=True))
# Mettre à jour toutes les lignes existantes avec False
op.execute("UPDATE events SET is_private = FALSE WHERE is_private IS NULL")
# Rendre la colonne non-nullable avec une valeur par défaut
op.alter_column('events', 'is_private',
existing_type=sa.Boolean(),
nullable=False,
server_default='false')
else:
# La colonne existe déjà, juste s'assurer qu'elle a les bonnes propriétés
# Vérifier si elle est nullable et la corriger si nécessaire
op.alter_column('events', 'is_private',
existing_type=sa.Boolean(),
nullable=False,
server_default='false')
def downgrade() -> None:

View File

@@ -27,3 +27,16 @@ class Notification(Base):
# Relationships
user = relationship("User", back_populates="notifications")
class PushSubscription(Base):
__tablename__ = "push_subscriptions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
endpoint = Column(String, unique=True, nullable=False)
p256dh = Column(String, nullable=False)
auth = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="push_subscriptions")

View File

@@ -27,6 +27,7 @@ class User(Base):
mentions = relationship("PostMention", back_populates="mentioned_user", cascade="all, delete-orphan")
vlogs = relationship("Vlog", back_populates="author", cascade="all, delete-orphan")
notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan")
push_subscriptions = relationship("PushSubscription", back_populates="user", cascade="all, delete-orphan")
vlog_likes = relationship("VlogLike", back_populates="user", cascade="all, delete-orphan")
vlog_comments = relationship("VlogComment", back_populates="user", cascade="all, delete-orphan")
media_likes = relationship("MediaLike", back_populates="user", cascade="all, delete-orphan")

View File

@@ -1,22 +1,18 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
alembic==1.12.1
psycopg2-binary==2.9.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
fastapi>=0.68.0
uvicorn>=0.15.0
sqlalchemy>=1.4.0
alembic>=1.7.0
psycopg2-binary>=2.9.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
bcrypt==4.0.1
python-multipart==0.0.6
python-dotenv==1.0.0
pydantic==2.5.0
pydantic[email]==2.5.0
pydantic-settings==2.1.0
aiofiles==23.2.1
pillow==10.1.0
httpx==0.25.2
redis==5.0.1
celery==5.3.4
flower==2.0.1
python-magic==0.4.27
numpy==1.26.4
opencv-python==4.8.1.78
python-multipart>=0.0.5
python-dotenv>=0.19.0
pydantic>=1.8.0
email-validator>=1.1.3
pillow>=9.0.0
moviepy>=1.0.3
aiofiles>=0.8.0
python-magic>=0.4.27
pywebpush>=1.14.0
opencv-python-headless>=4.5.0

View File

@@ -15,3 +15,14 @@ class NotificationResponse(BaseModel):
class Config:
from_attributes = True
class PushSubscriptionKeys(BaseModel):
p256dh: str
auth: str
class PushSubscriptionCreate(BaseModel):
endpoint: str
keys: PushSubscriptionKeys
class VapidPublicKeyResponse(BaseModel):
public_key: str

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""
Script pour générer des clés VAPID pour les notifications push.
Usage:
python scripts/generate_vapid_keys.py
Les clés générées doivent être ajoutées aux variables d'environnement:
VAPID_PUBLIC_KEY=<clé_publique>
VAPID_PRIVATE_KEY=<clé_privée>
VAPID_CLAIMS_EMAIL=mailto:admin@example.com
"""
import base64
import os
try:
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
except ImportError:
print("❌ cryptography n'est pas installé.")
print(" Installez-le avec: pip install cryptography")
exit(1)
def generate_vapid_keys():
"""Génère une paire de clés VAPID au format base64url."""
# Générer une clé privée EC P-256
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
# Extraire la clé privée brute (32 octets)
private_numbers = private_key.private_numbers()
private_bytes = private_numbers.private_value.to_bytes(32, byteorder='big')
private_key_b64 = base64.urlsafe_b64encode(private_bytes).rstrip(b'=').decode('ascii')
# Extraire la clé publique non compressée (65 octets = 0x04 + X + Y)
public_key = private_key.public_key()
public_bytes = public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint
)
public_key_b64 = base64.urlsafe_b64encode(public_bytes).rstrip(b'=').decode('ascii')
return public_key_b64, private_key_b64
if __name__ == "__main__":
print("🔐 Génération des clés VAPID pour Web Push...\n")
public_key, private_key = generate_vapid_keys()
print("=" * 70)
print("Clés VAPID générées avec succès !")
print("=" * 70)
print()
print("Ajoutez ces variables à votre fichier .env ou configuration:")
print()
print(f'VAPID_PUBLIC_KEY={public_key}')
print()
print(f'VAPID_PRIVATE_KEY={private_key}')
print()
print('VAPID_CLAIMS_EMAIL=mailto:admin@lediscord.com')
print()
print("=" * 70)
print()
print("⚠️ IMPORTANT:")
print(" - Gardez la clé privée SECRÈTE")
print(" - Les clés doivent être les mêmes en dev et prod")
print(" - Après changement de clés, les utilisateurs devront se réabonner")

View File

@@ -40,3 +40,5 @@ case "$1" in
;;
esac

View File

@@ -0,0 +1,136 @@
"""
Service d'envoi de notifications push via Web Push (VAPID).
"""
from typing import Optional, Dict, Any
from sqlalchemy.orm import Session
from config.settings import settings
from models.notification import PushSubscription
# Import conditionnel de pywebpush
try:
from pywebpush import webpush, WebPushException
WEBPUSH_AVAILABLE = True
except ImportError:
WEBPUSH_AVAILABLE = False
print("⚠️ pywebpush non installé - Les notifications push sont désactivées")
def is_push_configured() -> bool:
"""Vérifie si les notifications push sont configurées."""
return (
WEBPUSH_AVAILABLE and
bool(settings.VAPID_PRIVATE_KEY) and
bool(settings.VAPID_PUBLIC_KEY)
)
def send_push_to_user(
db: Session,
user_id: int,
title: str,
body: str,
link: str = "/",
data: Optional[Dict[str, Any]] = None
) -> int:
"""
Envoie une notification push à tous les appareils d'un utilisateur.
Args:
db: Session de base de données
user_id: ID de l'utilisateur
title: Titre de la notification
body: Corps de la notification
link: Lien vers lequel rediriger
data: Données supplémentaires
Returns:
Nombre de notifications envoyées avec succès
"""
if not is_push_configured():
return 0
# Récupérer tous les abonnements de l'utilisateur
subscriptions = db.query(PushSubscription).filter(
PushSubscription.user_id == user_id
).all()
if not subscriptions:
return 0
success_count = 0
failed_endpoints = []
import json
payload = json.dumps({
"title": title,
"body": body,
"link": link,
"data": data or {}
})
vapid_claims = {
"sub": settings.VAPID_CLAIMS_EMAIL
}
for sub in subscriptions:
try:
webpush(
subscription_info={
"endpoint": sub.endpoint,
"keys": {
"p256dh": sub.p256dh,
"auth": sub.auth
}
},
data=payload,
vapid_private_key=settings.VAPID_PRIVATE_KEY,
vapid_claims=vapid_claims
)
success_count += 1
print(f"✅ Push envoyé à {sub.endpoint[:50]}...")
except WebPushException as e:
print(f"❌ Erreur push pour {sub.endpoint[:50]}...: {e}")
# Si l'abonnement est expiré ou invalide, on le marque pour suppression
if e.response and e.response.status_code in [404, 410]:
failed_endpoints.append(sub.endpoint)
except Exception as e:
print(f"❌ Erreur inattendue push: {type(e).__name__}: {e}")
# Supprimer les abonnements invalides
if failed_endpoints:
db.query(PushSubscription).filter(
PushSubscription.endpoint.in_(failed_endpoints)
).delete(synchronize_session=False)
db.commit()
print(f"🗑️ Supprimé {len(failed_endpoints)} abonnements invalides")
return success_count
def send_push_to_users(
db: Session,
user_ids: list,
title: str,
body: str,
link: str = "/",
data: Optional[Dict[str, Any]] = None
) -> int:
"""
Envoie une notification push à plusieurs utilisateurs.
Args:
db: Session de base de données
user_ids: Liste des IDs utilisateurs
title: Titre de la notification
body: Corps de la notification
link: Lien vers lequel rediriger
data: Données supplémentaires
Returns:
Nombre total de notifications envoyées avec succès
"""
total = 0
for user_id in user_ids:
total += send_push_to_user(db, user_id, title, body, link, data)
return total

View File

@@ -2,7 +2,7 @@ from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from config.settings import settings
@@ -11,7 +11,9 @@ from models.user import User
from schemas.user import TokenData
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
# OAuth2 scheme avec auto_error=False pour pouvoir logger les erreurs
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hashed password."""
@@ -45,17 +47,42 @@ def verify_token(token: str, credentials_exception) -> TokenData:
except JWTError:
raise credentials_exception
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
async def get_current_user(
token: Optional[str] = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> User:
"""Get the current authenticated user."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
token_data = verify_token(token, credentials_exception)
# Log si pas de token
if token is None:
print("❌ AUTH: No token provided in Authorization header")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated - no token provided",
headers={"WWW-Authenticate": "Bearer"},
)
# Log le token (premiers caractères seulement pour la sécurité)
print(f"🔐 AUTH: Token received, length={len(token)}, preview={token[:20]}...")
try:
token_data = verify_token(token, credentials_exception)
print(f"✅ AUTH: Token valid for user_id={token_data.user_id}")
except HTTPException as e:
print(f"❌ AUTH: Token validation failed - {e.detail}")
raise
user = db.query(User).filter(User.id == token_data.user_id).first()
if user is None:
print(f"❌ AUTH: User not found for id={token_data.user_id}")
raise credentials_exception
print(f"✅ AUTH: User authenticated: {user.username}")
return user
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:

View File

@@ -50,6 +50,8 @@ services:
MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE}
ALLOWED_IMAGE_TYPES: ${ALLOWED_IMAGE_TYPES}
ALLOWED_VIDEO_TYPES: ${ALLOWED_VIDEO_TYPES}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
volumes:
- ${UPLOAD_PATH:-./uploads}:/app/uploads
- ./backend:/app
@@ -71,11 +73,12 @@ services:
VITE_API_URL: ${VITE_API_URL}
VITE_APP_URL: ${VITE_APP_URL}
VITE_UPLOAD_URL: ${VITE_UPLOAD_URL}
ENVIRONMENT: ${ENVIRONMENT}
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "8082:5173"
- "8082:8082"
networks:
- lediscord_network
restart: unless-stopped

View File

@@ -21,7 +21,7 @@ RUN npm run build
# Stage 2 : Image finale avec les deux modes
FROM node:20-alpine
RUN apk add --no-cache nginx
RUN apk add --no-cache nginx && mkdir -p /run/nginx
WORKDIR /app
@@ -35,33 +35,40 @@ COPY . .
COPY --from=builder /app/dist /usr/share/nginx/html
# Config nginx
RUN echo 'server { \
listen 8080; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
RUN echo 'worker_processes auto; \
events { worker_connections 1024; } \
http { \
include /etc/nginx/mime.types; \
default_type application/octet-stream; \
sendfile on; \
keepalive_timeout 65; \
gzip on; \
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; \
server { \
listen 8082; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
} \
location /assets { \
expires 1y; \
add_header Cache-Control "public, immutable"; \
} \
} \
location /assets { \
expires 1y; \
add_header Cache-Control "public, immutable"; \
} \
}' > /etc/nginx/conf.d/default.conf
}' > /etc/nginx/nginx.conf
# Script d'entrée
COPY <<EOF /entrypoint.sh
#!/bin/sh
if [ "\$MODE" = "dev" ]; then
echo "🔧 Mode DEVELOPPEMENT"
exec npm run dev -- --host 0.0.0.0 --port 8080
else
echo "🚀 Mode PRODUCTION"
exec nginx -g "daemon off;"
fi
EOF
RUN echo '#!/bin/sh' > /entrypoint.sh && \
echo 'if [ "$ENVIRONMENT" = "development" ]; then' >> /entrypoint.sh && \
echo ' echo "🔧 Mode DEVELOPPEMENT"' >> /entrypoint.sh && \
echo ' exec npm run dev -- --host 0.0.0.0 --port 8082' >> /entrypoint.sh && \
echo 'else' >> /entrypoint.sh && \
echo ' echo "🚀 Mode PRODUCTION"' >> /entrypoint.sh && \
echo ' exec nginx -g "daemon off;"' >> /entrypoint.sh && \
echo 'fi' >> /entrypoint.sh && \
chmod +x /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8080
EXPOSE 8082
CMD ["/entrypoint.sh"]

View File

@@ -49,3 +49,5 @@ npm run build
- Les mises à jour sont automatiques (`registerType: 'autoUpdate'`)
- Les icônes doivent être générées avant le premier build

View File

@@ -67,8 +67,9 @@ if (!self.define) {
});
};
}
define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
define(['./workbox-52524643'], (function (workbox) { 'use strict';
importScripts("/sw-custom.js");
self.skipWaiting();
workbox.clientsClaim();
@@ -80,14 +81,8 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
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({
@@ -106,7 +101,13 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
statuses: [0, 200]
})]
}), 'GET');
workbox.registerRoute(/^https?:\/\/.*\/api\/.*/i, new workbox.NetworkFirst({
workbox.registerRoute(({
url,
request
}) => {
const urlString = url.href || url.toString();
return /^https?:\/\/.*\/api\/.*/i.test(urlString) && request.method === "GET";
}, new workbox.NetworkFirst({
"cacheName": "api-cache",
"networkTimeoutSeconds": 10,
plugins: [new workbox.ExpirationPlugin({
@@ -114,7 +115,7 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
maxAgeSeconds: 300
})]
}), 'GET');
workbox.registerRoute(/^https?:\/\/.*\/uploads\/.*/i, new workbox.CacheFirst({
workbox.registerRoute(/^https?:\/\/.*\/uploads\/.*/i, new workbox.StaleWhileRevalidate({
"cacheName": "uploads-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 100,

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -78,3 +78,5 @@
]
}

View File

@@ -0,0 +1,119 @@
// Service Worker personnalisé pour gérer les notifications push
// Ce fichier sera fusionné avec le service worker généré par vite-plugin-pwa
// Écouter les événements de notification
self.addEventListener('notificationclick', (event) => {
console.log('Notification clicked:', event.notification)
event.notification.close()
// Récupérer le lien depuis les données de la notification
const link = event.notification.data?.link || event.notification.data?.url || '/'
// Ouvrir ou focus la fenêtre/clients
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Si une fenêtre est déjà ouverte, la focus
for (let i = 0; i < clientList.length; i++) {
const client = clientList[i]
if (client.url && 'focus' in client) {
// Naviguer vers le lien si nécessaire
if (link && !client.url.includes(link.split('/')[1])) {
return client.navigate(link).then(() => client.focus())
}
return client.focus()
}
}
// Sinon, ouvrir une nouvelle fenêtre
if (clients.openWindow) {
return clients.openWindow(link || '/')
}
})
)
})
// Écouter les messages du client pour afficher des notifications
self.addEventListener('message', (event) => {
console.log('Service Worker received message:', event.data)
if (event.data && event.data.type === 'SHOW_NOTIFICATION') {
const { title, options } = event.data
// Préparer les options de notification
const notificationOptions = {
icon: '/icon-192x192.png',
badge: '/icon-96x96.png',
tag: 'lediscord-notification',
requireInteraction: false,
data: {
link: options.link || options.data?.link || '/',
notificationId: options.data?.notificationId || options.notificationId
},
body: options.body || options.message || '',
...options
}
// Retirer vibrate si présent (iOS ne le supporte pas)
// Le client devrait déjà l'avoir retiré, mais on s'assure ici aussi
if (notificationOptions.vibrate) {
// Vérifier si on est sur iOS (approximatif via user agent du client)
// Note: dans le SW on n'a pas accès direct à navigator.userAgent
// mais on peut retirer vibrate de toute façon car iOS l'ignore
delete notificationOptions.vibrate
}
event.waitUntil(
self.registration.showNotification(title, notificationOptions).catch(error => {
console.error('Error showing notification in service worker:', error)
})
)
}
})
// Écouter les push events (pour les vraies push notifications depuis le serveur)
self.addEventListener('push', (event) => {
console.log('Push event received:', event)
let notificationData = {
title: 'LeDiscord',
body: 'Vous avez une nouvelle notification',
icon: '/icon-192x192.png',
badge: '/icon-96x96.png',
tag: 'lediscord-notification',
data: {
link: '/'
}
}
// Si des données sont envoyées avec le push
if (event.data) {
try {
const data = event.data.json()
notificationData = {
...notificationData,
title: data.title || notificationData.title,
body: data.body || data.message || notificationData.body,
data: {
link: data.link || '/',
notificationId: data.notificationId
}
}
} catch (e) {
console.error('Error parsing push data:', e)
}
}
// Retirer vibrate pour compatibilité iOS
if (notificationData.vibrate) {
delete notificationData.vibrate
}
event.waitUntil(
self.registration.showNotification(notificationData.title, notificationData).catch(error => {
console.error('Error showing push notification:', error)
})
)
})

View File

@@ -8,15 +8,24 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import DefaultLayout from '@/layouts/DefaultLayout.vue'
import AuthLayout from '@/layouts/AuthLayout.vue'
import EnvironmentDebug from '@/components/EnvironmentDebug.vue'
const route = useRoute()
const authStore = useAuthStore()
const layout = computed(() => {
return route.meta.layout === 'auth' ? AuthLayout : DefaultLayout
})
// Restaurer la session au démarrage de l'app
onMounted(async () => {
if (authStore.token && !authStore.user) {
await authStore.fetchCurrentUser()
}
})
</script>

View File

@@ -150,3 +150,5 @@ function dismissPrompt() {
}
</style>

View File

@@ -0,0 +1,308 @@
<template>
<transition
enter-active-class="transition ease-out duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4"
@click.self="close"
>
<div
class="bg-white rounded-2xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto"
@click.stop
>
<!-- Header -->
<div class="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-2xl">
<h3 class="text-xl font-bold text-gray-900">Installer l'application</h3>
<button
@click="close"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<X class="w-6 h-6" />
</button>
</div>
<!-- Content -->
<div class="px-4 sm:px-6 py-4 sm:py-6">
<!-- iOS Instructions -->
<div v-if="instructionType === 'ios'" class="space-y-4 sm:space-y-6">
<!-- Step 1 -->
<div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0">
<div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
1
</div>
</div>
<div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Appuyez sur le bouton de partage</h4>
<p class="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3">
En bas de l'écran Safari, cherchez l'icône carrée avec une flèche vers le haut ⬆️
</p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 sm:p-4">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</div>
<p class="text-xs sm:text-sm text-blue-700">
<strong>Astuce :</strong> Si vous ne voyez pas la barre, faites défiler vers le haut pour la faire apparaître
</p>
</div>
</div>
</div>
</div>
<!-- Step 2 -->
<div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0">
<div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
2
</div>
</div>
<div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Cherchez "Sur l'écran d'accueil"</h4>
<p class="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3">
Faites défiler le menu vers le bas jusqu'à trouver cette option
</p>
<div class="bg-gradient-to-br from-primary-50 to-purple-50 rounded-lg p-3 sm:p-4 border-2 border-primary-200">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</div>
<div class="flex-1">
<p class="font-semibold text-primary-700 text-xs sm:text-sm">Sur l'écran d'accueil</p>
<p class="text-xs text-primary-600">L'icône ressemble à un + dans un carré</p>
</div>
</div>
</div>
</div>
</div>
<!-- Step 3 -->
<div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0">
<div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
3
</div>
</div>
<div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Appuyez sur "Ajouter"</h4>
<p class="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3">
En haut à droite de l'écran
</p>
<div class="bg-green-50 rounded-lg p-3 sm:p-4 border-2 border-green-200">
<div class="flex items-center space-x-3">
<CheckCircle class="w-5 h-5 sm:w-6 sm:h-6 text-green-600 flex-shrink-0" />
<p class="text-xs sm:text-sm text-green-700 font-medium">L'application apparaîtra sur votre écran d'accueil !</p>
</div>
</div>
</div>
</div>
<!-- Note importante iOS -->
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 sm:p-4 mt-4">
<div class="flex items-start space-x-2">
<span class="text-lg"></span>
<div class="text-xs sm:text-sm text-amber-800">
<p class="font-medium mb-1">Important pour iOS :</p>
<p>Les notifications push ne fonctionnent que si l'app est installée sur l'écran d'accueil (iOS 16.4+)</p>
</div>
</div>
</div>
</div>
<!-- Android Instructions -->
<div v-else-if="instructionType === 'android'" class="space-y-4 sm:space-y-6">
<!-- Step 1 -->
<div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0">
<div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
1
</div>
</div>
<div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Appuyez sur le menu ⋮</h4>
<p class="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3">
Les 3 points verticaux en haut à droite de Chrome
</p>
</div>
</div>
<!-- Step 2 -->
<div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0">
<div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
2
</div>
</div>
<div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">"Installer l'application"</h4>
<p class="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3">
Ou "Ajouter à l'écran d'accueil"
</p>
</div>
</div>
<!-- Step 3 -->
<div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0">
<div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
3
</div>
</div>
<div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Confirmez avec "Installer"</h4>
<div class="bg-green-50 rounded-lg p-3 sm:p-4 border-2 border-green-200">
<div class="flex items-center space-x-3">
<CheckCircle class="w-5 h-5 sm:w-6 sm:h-6 text-green-600 flex-shrink-0" />
<p class="text-xs sm:text-sm text-green-700 font-medium">L'app s'installera sur votre téléphone !</p>
</div>
</div>
</div>
</div>
</div>
<!-- Desktop Instructions (Windows/Mac) -->
<div v-else class="space-y-4 sm:space-y-6">
<!-- Step 1 -->
<div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0">
<div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
1
</div>
</div>
<div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Cherchez l'icône d'installation</h4>
<p class="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3">
Dans la barre d'adresse de Chrome/Edge, cherchez l'icône 📥 ou
</p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 sm:p-4">
<p class="text-xs sm:text-sm text-blue-700">
<strong>Chrome :</strong> Icône avec un écran et une flèche à droite de la barre d'adresse
</p>
<p class="text-xs sm:text-sm text-blue-700 mt-1">
<strong>Edge :</strong> "Installer LeDiscord" dans le menu ⋯
</p>
</div>
</div>
</div>
<!-- Step 2 -->
<div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0">
<div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
2
</div>
</div>
<div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Cliquez sur "Installer"</h4>
<div class="bg-green-50 rounded-lg p-3 sm:p-4 border-2 border-green-200">
<div class="flex items-center space-x-3">
<CheckCircle class="w-5 h-5 sm:w-6 sm:h-6 text-green-600 flex-shrink-0" />
<p class="text-xs sm:text-sm text-green-700 font-medium">L'app s'ouvrira dans sa propre fenêtre !</p>
</div>
</div>
</div>
</div>
<!-- Note si pas dispo -->
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 sm:p-4 mt-4">
<div class="flex items-start space-x-2">
<span class="text-lg">💡</span>
<div class="text-xs sm:text-sm text-amber-800">
<p>Si vous ne voyez pas l'option d'installation, rafraîchissez la page ou essayez avec Chrome/Edge.</p>
</div>
</div>
</div>
</div>
<!-- Benefits -->
<div class="mt-6 pt-6 border-t border-gray-200">
<p class="text-sm font-semibold text-gray-900 mb-3">Une fois installée, vous pourrez :</p>
<div class="space-y-2">
<div class="flex items-center space-x-2 text-sm text-gray-600">
<CheckCircle class="w-5 h-5 text-green-500 flex-shrink-0" />
<span>Accéder rapidement à l'app depuis l'écran d'accueil</span>
</div>
<div class="flex items-center space-x-2 text-sm text-gray-600">
<CheckCircle class="w-5 h-5 text-green-500 flex-shrink-0" />
<span>Recevoir des notifications push</span>
</div>
<div class="flex items-center space-x-2 text-sm text-gray-600">
<CheckCircle class="w-5 h-5 text-green-500 flex-shrink-0" />
<span>Utiliser l'app hors ligne (contenu mis en cache)</span>
</div>
<div class="flex items-center space-x-2 text-sm text-gray-600">
<CheckCircle class="w-5 h-5 text-green-500 flex-shrink-0" />
<span>Rester connecté automatiquement</span>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="sticky bottom-0 bg-gray-50 border-t border-gray-200 px-6 py-4 rounded-b-2xl">
<button
@click="close"
class="w-full bg-primary-600 hover:bg-primary-700 text-white font-medium py-3 px-4 rounded-lg transition-colors shadow-lg hover:shadow-xl"
>
J'ai compris
</button>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { computed } from 'vue'
import { X, ArrowDown, CheckCircle } from 'lucide-vue-next'
const props = defineProps({
show: {
type: Boolean,
default: false
},
isIOS: {
type: Boolean,
default: false
},
isAndroid: {
type: Boolean,
default: false
},
isWindows: {
type: Boolean,
default: false
},
isMac: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close'])
// Déterminer quel type d'instructions afficher
const instructionType = computed(() => {
if (props.isIOS) return 'ios'
if (props.isAndroid) return 'android'
if (props.isWindows || props.isMac) return 'desktop'
return 'android' // Par défaut
})
function close() {
emit('close')
}
</script>

View File

@@ -129,6 +129,7 @@ import { ref, computed } from 'vue'
import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router'
import axios from '@/utils/axios'
import { uploadFormData } from '@/utils/axios'
import { useAuthStore } from '@/stores/auth'
import { Plus, X, Send, Ticket } from 'lucide-vue-next'
import LoadingLogo from '@/components/LoadingLogo.vue'
@@ -189,22 +190,7 @@ async function submitTicket() {
// Debug: afficher les données envoyées
console.log('DEBUG - Ticket form data:')
console.log(' title:', ticketForm.value.title)
console.log(' description:', ticketForm.value.description)
console.log(' ticket_type:', ticketForm.value.ticket_type)
console.log(' priority:', ticketForm.value.priority)
console.log(' screenshot:', screenshotInput.value?.files[0])
// Debug: afficher le FormData
for (let [key, value] of formData.entries()) {
console.log(`DEBUG - FormData entry: ${key} = ${value}`)
}
await axios.post('/api/tickets/', formData, {
headers: {
'Content-Type': 'multipart/form-data',
}
})
await uploadFormData('/api/tickets/', formData)
toast.success('Ticket envoyé avec succès !')
closeModal()

View File

@@ -97,8 +97,7 @@ import { useToast } from 'vue-toastification'
import { MessageSquare, User } from 'lucide-vue-next'
import Mentions from '@/components/Mentions.vue'
import MentionInput from '@/components/MentionInput.vue'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import { getMediaUrl } from '@/utils/axios'
import axios from '@/utils/axios'
@@ -130,7 +129,7 @@ const commentMentions = ref([])
const currentUser = computed(() => authStore.user)
function formatDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
return formatRelativeDateInFrenchTimezone(date)
}
function getAvatarUrl(avatarUrl) {

View File

@@ -129,9 +129,9 @@
<!-- Installer l'app -->
<button
@click="handleInstallApp"
:disabled="isPWAInstalled || !canInstall"
:disabled="isPWAInstalled"
class="block w-full text-left px-4 py-2 text-sm transition-colors"
:class="isPWAInstalled || !canInstall
:class="isPWAInstalled
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-700 hover:bg-gray-100'"
>
@@ -204,6 +204,13 @@
<!-- Ticket Floating Button -->
<TicketFloatingButton />
<!-- PWA Install Tutorial -->
<PWAInstallTutorial
:show="showPWAInstructions"
:is-ios="isIOS"
@close="showPWAInstructions = false"
/>
<!-- Notifications Panel -->
<transition
enter-active-class="transition ease-out duration-200"
@@ -270,8 +277,7 @@
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { formatDateInFrenchTimezone } from '@/utils/dateUtils'
import { getMediaUrl } from '@/utils/axios'
import {
Home,
@@ -288,6 +294,7 @@ import {
} from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue'
import TicketFloatingButton from '@/components/TicketFloatingButton.vue'
import PWAInstallTutorial from '@/components/PWAInstallTutorial.vue'
const authStore = useAuthStore()
const router = useRouter()
@@ -307,13 +314,17 @@ const isMobileMenuOpen = ref(false)
const deferredPrompt = ref(null)
const isPWAInstalled = ref(false)
const canInstall = ref(false)
const isMobile = ref(false)
const showPWAInstructions = ref(false)
const isIOS = computed(() => /iPhone|iPad|iPod/i.test(navigator.userAgent))
const user = computed(() => authStore.user)
const notifications = computed(() => authStore.notifications)
const unreadNotifications = computed(() => authStore.unreadCount)
function formatDate(date) {
return format(new Date(date), 'dd MMM à HH:mm', { locale: fr })
return formatDateInFrenchTimezone(date, 'dd MMM à HH:mm')
}
async function logout() {
@@ -335,19 +346,36 @@ async function handleNotificationClick(notification) {
await authStore.markNotificationRead(notification.id)
}
if (notification.link) {
router.push(notification.link)
}
showNotifications.value = false
if (notification.link) {
// Gérer les liens de posts différemment car il n'y a pas de route /posts/:id
if (notification.link.startsWith('/posts/')) {
const postId = notification.link.split('/posts/')[1]
// Naviguer vers /posts et passer l'ID en query pour scroll
router.push({ path: '/posts', query: { highlight: postId } })
} else {
router.push(notification.link)
}
}
}
// PWA Installation logic
function checkIfMobile() {
isMobile.value = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ||
window.innerWidth < 768
}
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://')
// Sur mobile, permettre l'installation même sans beforeinstallprompt
if (isMobile.value && !isPWAInstalled.value) {
canInstall.value = true
}
}
function handleBeforeInstallPrompt(e) {
@@ -359,48 +387,81 @@ function handleBeforeInstallPrompt(e) {
}
async function handleInstallApp() {
if (!deferredPrompt.value || isPWAInstalled.value) {
if (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')
if (deferredPrompt.value) {
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)
}
// Réinitialiser
deferredPrompt.value = null
canInstall.value = false
} catch (error) {
console.error('Erreur lors de l\'installation:', error)
} else if (isMobile.value) {
// Sur mobile sans beforeinstallprompt, afficher le tutoriel
showUserMenu.value = false
showPWAInstructions.value = true
}
}
onMounted(async () => {
await authStore.fetchCurrentUser()
checkIfMobile()
// Restaurer la session si un token existe
if (authStore.token) {
await authStore.fetchCurrentUser()
}
if (authStore.isAuthenticated) {
await fetchNotifications()
await authStore.fetchUnreadCount()
// Démarrer le polling des notifications
const notificationService = (await import('@/services/notificationService')).default
notificationService.startPolling()
notificationService.setupServiceWorkerListener()
// Demander la permission pour les notifications push
await notificationService.requestNotificationPermission()
try {
const notificationService = (await import('@/services/notificationService')).default
notificationService.startPolling()
notificationService.setupServiceWorkerListener()
// Demander la permission pour les notifications push
// Sur iOS, attendre un peu pour s'assurer que la PWA est bien détectée
if (typeof notificationService.isIOS === 'function' && notificationService.isIOS()) {
console.log('🍎 iOS détecté, vérification de la PWA...')
// Attendre que la page soit complètement chargée et que la PWA soit détectée
setTimeout(async () => {
const isInstalled = notificationService.isPWAInstalled()
console.log('🍎 PWA installée:', isInstalled)
if (isInstalled) {
console.log('🍎 Demande de permission pour les notifications...')
await notificationService.requestNotificationPermission()
} else {
console.warn('🍎 PWA non installée - Les notifications ne fonctionneront pas sur iOS')
console.warn('🍎 Instructions: Ajouter l\'app à l\'écran d\'accueil, puis l\'ouvrir depuis l\'écran d\'accueil')
}
}, 2000) // Attendre 2 secondes pour iOS
} else {
await notificationService.requestNotificationPermission()
}
} catch (error) {
console.error('Erreur lors du chargement du service de notifications:', error)
}
}
// Vérifier si PWA est installée
@@ -411,13 +472,19 @@ onMounted(async () => {
// Écouter les changements de display mode
window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPWAInstalled)
window.addEventListener('resize', checkIfMobile)
})
onBeforeUnmount(async () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('resize', checkIfMobile)
// Arrêter le polling des notifications
const notificationService = (await import('@/services/notificationService')).default
notificationService.stopPolling()
try {
const notificationService = (await import('@/services/notificationService')).default
notificationService.stopPolling()
} catch (error) {
console.error('Erreur lors de l\'arrêt du service de notifications:', error)
}
})
</script>

View File

@@ -37,6 +37,29 @@ app.use(Toast, toastOptions)
// Maintenant installer le router
app.use(router)
// Handler d'erreur global pour capturer les erreurs non catchées
window.addEventListener('error', (event) => {
console.error('❌ Erreur JavaScript globale:', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error,
stack: event.error?.stack
})
})
// Handler pour les promesses rejetées non catchées
window.addEventListener('unhandledrejection', (event) => {
console.error('❌ Promesse rejetée non catchée:', {
reason: event.reason,
promise: event.promise,
stack: event.reason?.stack
})
// Empêcher le message d'erreur par défaut dans la console
event.preventDefault()
})
// Attendre que le router soit prêt avant de monter l'app
router.isReady().then(() => {
app.mount('#app')

View File

@@ -1,13 +1,82 @@
import { useAuthStore } from '@/stores/auth'
import axios from '@/utils/axios'
// Utilitaire pour convertir la clé VAPID
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
class NotificationService {
constructor() {
this.pollingInterval = null
this.pollInterval = 30000 // 30 secondes
this.isPolling = false
this.vapidPublicKey = null
}
// Détecter iOS
isIOS() {
return /iPhone|iPad|iPod/i.test(navigator.userAgent)
}
// Vérifier si la PWA est installée (nécessaire pour iOS)
isPWAInstalled() {
return window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true ||
document.referrer.includes('android-app://')
}
// Vérifier si les notifications sont supportées sur cette plateforme
isNotificationSupported() {
if (!('Notification' in window)) {
console.warn('Notifications API not supported')
return false
}
if (!('serviceWorker' in navigator)) {
console.warn('Service Worker API not supported')
return false
}
if (!('PushManager' in window)) {
console.warn('Push API not supported')
return false
}
// Sur iOS, les notifications push ne fonctionnent que si la PWA est installée (iOS 16.4+)
if (this.isIOS()) {
if (!this.isPWAInstalled()) {
console.warn('iOS: Notifications push require PWA to be installed (added to home screen)')
return false
}
// Vérifier la version iOS (approximatif via user agent)
const iosVersion = navigator.userAgent.match(/OS (\d+)_(\d+)/)
if (iosVersion) {
const major = parseInt(iosVersion[1], 10)
const minor = parseInt(iosVersion[2], 10)
if (major < 16 || (major === 16 && minor < 4)) {
console.warn('iOS: Push notifications require iOS 16.4 or later')
return false
}
}
}
return true
}
startPolling() {
// Le polling est conservé comme fallback pour les notifications in-app
// ou si le push n'est pas supporté/activé
if (this.isPolling) return
const authStore = useAuthStore()
@@ -40,26 +109,9 @@ class NotificationService {
}
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 }
})
}
}
// Juste mettre à jour le store (compteur, liste)
// Les notifications push sont gérées par le service worker
await authStore.fetchNotifications()
} catch (error) {
console.error('Error polling notifications:', error)
}
@@ -73,73 +125,128 @@ class NotificationService {
}
}
// Récupérer la clé publique VAPID depuis le backend
async getVapidPublicKey() {
if (this.vapidPublicKey) return this.vapidPublicKey
try {
const response = await axios.get('/api/push/vapid-public-key')
this.vapidPublicKey = response.data.public_key
return this.vapidPublicKey
} catch (error) {
console.error('Error fetching VAPID public key:', error)
return null
}
}
// S'abonner aux notifications push
async subscribeToPush() {
if (!this.isNotificationSupported()) return false
try {
const registration = await navigator.serviceWorker.ready
const publicKey = await this.getVapidPublicKey()
if (!publicKey) {
console.error('No VAPID public key available')
return false
}
const convertedVapidKey = urlBase64ToUint8Array(publicKey)
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey
})
console.log('✅ Push subscription successful:', subscription)
// Envoyer l'abonnement au backend
await axios.post('/api/push/subscribe', {
endpoint: subscription.endpoint,
keys: {
p256dh: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('p256dh')))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
auth: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('auth')))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
})
return true
} catch (error) {
console.error('Error subscribing to push:', error)
return false
}
}
// Gestion des notifications push PWA
async requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('Ce navigateur ne supporte pas les notifications')
console.log('🔔 requestNotificationPermission appelée')
if (!this.isNotificationSupported()) {
return false
}
if (Notification.permission === 'granted') {
console.log('✅ Notification permission already granted')
// S'assurer qu'on est bien abonné au push
this.subscribeToPush()
return true
}
if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission()
return permission === 'granted'
if (Notification.permission === 'denied') {
console.warn('❌ Notification permission denied by user')
return false
}
return false
// Sur iOS, s'assurer que la PWA est installée avant de demander
if (this.isIOS() && !this.isPWAInstalled()) {
console.warn('⚠️ iOS: Cannot request notification permission - PWA must be installed first')
return false
}
try {
console.log('🔔 Demande de permission...')
const permission = await Notification.requestPermission()
const granted = permission === 'granted'
if (granted) {
console.log('✅ Notification permission granted')
await this.subscribeToPush()
} else {
console.warn('❌ Notification permission denied:', permission)
}
return granted
} catch (error) {
console.error('❌ Error requesting notification permission:', error)
return false
}
}
async showPushNotification(title, options = {}) {
if (!('Notification' in window)) return
// Cette méthode est maintenant principalement utilisée pour les tests
// ou les notifications locales générées par le client
if (!this.isNotificationSupported()) return null
if (Notification.permission !== 'granted') return null
const hasPermission = await this.requestNotificationPermission()
if (!hasPermission) return
// Si on est dans un service worker, utiliser la notification API du SW
// Toujours utiliser le service worker pour les notifications push
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.ready
if (!registration.active) return null
await registration.showNotification(title, {
icon: '/icon-192x192.png',
badge: '/icon-96x96.png',
tag: 'lediscord-notification',
requireInteraction: false,
vibrate: [200, 100, 200],
...options
})
return
return true
} catch (error) {
console.error('Error showing notification via service worker:', error)
console.error('Error showing notification:', 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
return null
}
// Écouter les messages du service worker pour les notifications push
@@ -147,12 +254,29 @@ class NotificationService {
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)
// Notification reçue via message (fallback)
console.log('Notification message received:', event.data)
}
})
}
}
// Fonction de test pour vérifier que les notifications fonctionnent
async testNotification() {
console.log('🧪 Test de notification...')
const result = await this.showPushNotification('Test LeDiscord', {
body: 'Si vous voyez cette notification, les notifications push fonctionnent !',
link: '/'
})
if (result) {
console.log('✅ Test réussi - notification affichée')
return true
} else {
console.warn('⚠️ Test échoué - notification non affichée')
return false
}
}
}
export default new NotificationService()

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from '@/utils/axios'
import { uploadFormData } from '@/utils/axios'
import router from '@/router'
import { useToast } from 'vue-toastification'
@@ -9,7 +10,7 @@ export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token'))
const toast = useToast()
const isAuthenticated = computed(() => !!token.value)
const isAuthenticated = computed(() => !!token.value && !!user.value)
const isAdmin = computed(() => user.value?.is_admin || false)
if (token.value) {
@@ -112,19 +113,13 @@ export const useAuthStore = defineStore('auth', () => {
const formData = new FormData()
formData.append('file', file)
const response = await axios.post('/api/users/me/avatar', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
user.value = response.data
const data = await uploadFormData('/api/users/me/avatar', formData)
user.value = data
toast.success('Avatar mis à jour')
return { success: true, data: response.data }
return { success: true, data }
} catch (error) {
console.error('Error uploading avatar:', error)
toast.error('Erreur lors de l\'upload de l\'avatar')
return { success: false, error: error.response?.data?.detail || 'Erreur inconnue' }
return { success: false, error: error.message || 'Erreur inconnue' }
}
}

View File

@@ -53,11 +53,30 @@ instance.interceptors.request.use(
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
// Log détaillé en développement
if (import.meta.env.DEV) {
console.log(`📤 Requête ${config.method?.toUpperCase()} vers: ${config.url}`, {
hasToken: !!token,
tokenLength: token.length,
tokenPreview: token.substring(0, 20) + '...'
})
}
} else {
// Log si pas de token
if (import.meta.env.DEV) {
console.warn(`⚠️ Requête ${config.method?.toUpperCase()} vers: ${config.url} - Pas de token`)
}
}
// Log des requêtes en développement
if (import.meta.env.DEV) {
console.log(`📤 Requête ${config.method?.toUpperCase()} vers: ${config.url}`)
// Augmenter le timeout pour les requêtes POST/PUT avec FormData (uploads)
if ((config.method === 'POST' || config.method === 'PUT') && config.data instanceof FormData) {
config.timeout = 120000 // 2 minutes pour les uploads
// IMPORTANT: Supprimer le Content-Type pour laisser le navigateur définir le multipart/form-data avec la boundary
// Axios peut avoir mis 'application/json' par défaut ou on peut l'avoir mis manuellement
if (config.headers && config.headers['Content-Type']) {
delete config.headers['Content-Type']
}
}
return config
@@ -86,16 +105,47 @@ instance.interceptors.response.use(
statusText: error.response?.statusText,
url: error.config?.url,
method: error.config?.method,
data: error.response?.data
data: error.response?.data,
headers: error.response?.headers,
requestHeaders: error.config?.headers
})
// Log supplémentaire pour les erreurs 401/403/422
if ([401, 403, 422].includes(error.response?.status)) {
const token = localStorage.getItem('token')
console.error(`🔍 Diagnostic erreur ${error.response?.status}:`, {
hasToken: !!token,
tokenLength: token?.length,
url: error.config?.url,
method: error.config?.method,
validationErrors: error.response?.data?.detail
})
}
if (error.response?.status === 401) {
// Ne pas rediriger si on est déjà sur une page d'auth
const currentRoute = router.currentRoute.value
const errorDetail = error.response?.data?.detail || ''
const errorDetailLower = errorDetail.toLowerCase()
// Vérifier si c'est une vraie erreur d'authentification
const isRealAuthError = errorDetailLower.includes('credential') ||
errorDetailLower.includes('token') ||
errorDetailLower.includes('not authenticated') ||
errorDetailLower.includes('could not validate') ||
errorDetailLower.includes('expired')
console.warn(`🔒 401 reçu - Auth error: ${isRealAuthError}, Detail: ${errorDetail}`)
// Ne pas rediriger si on est déjà sur une page d'auth
if (!currentRoute.path.includes('/login') && !currentRoute.path.includes('/register')) {
localStorage.removeItem('token')
router.push('/login')
toast.error('Session expirée, veuillez vous reconnecter')
if (isRealAuthError) {
localStorage.removeItem('token')
router.push('/login')
toast.error('Session expirée, veuillez vous reconnecter')
} else {
// Erreur 401 non liée à l'auth (rare mais possible)
toast.error('Erreur d\'autorisation')
}
}
} else if (error.response?.status === 403) {
toast.error('Accès non autorisé')
@@ -164,3 +214,146 @@ export function getApiUrl() {
export function getAppUrl() {
return import.meta.env.VITE_APP_URL || window.location.origin
}
// Fonction utilitaire pour les requêtes POST JSON via fetch natif
// (contourne les problèmes d'axios sur certains navigateurs mobiles/PWA)
export async function postJson(endpoint, data = {}) {
const token = localStorage.getItem('token')
const apiUrl = getApiUrl()
console.log(`📤 POST JSON vers: ${apiUrl}${endpoint}`)
try {
const response = await fetch(`${apiUrl}${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
console.log(`📥 POST response status: ${response.status}`)
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Erreur inconnue' }))
console.error('❌ POST error:', errorData)
const toast = useToast()
if (response.status === 401) {
const errorDetail = (errorData.detail || '').toLowerCase()
const isRealAuthError = errorDetail.includes('credential') ||
errorDetail.includes('token') ||
errorDetail.includes('not authenticated') ||
errorDetail.includes('could not validate') ||
errorDetail.includes('expired')
if (isRealAuthError) {
localStorage.removeItem('token')
router.push('/login')
toast.error('Session expirée, veuillez vous reconnecter')
} else {
toast.error('Erreur d\'autorisation')
}
} else if (response.status === 403) {
toast.error('Accès non autorisé')
} else if (response.status === 422) {
toast.error('Données invalides')
} else {
toast.error(errorData.detail || 'Erreur serveur')
}
const error = new Error(errorData.detail || 'Erreur')
error.response = { status: response.status, data: errorData }
throw error
}
return await response.json()
} catch (error) {
if (!error.response) {
console.error('❌ Network error:', error)
const toast = useToast()
toast.error('Erreur de connexion')
}
throw error
}
}
// Fonction utilitaire pour upload de FormData via fetch natif
// (contourne les problèmes d'axios avec FormData sur certains navigateurs/mobiles)
export async function uploadFormData(endpoint, formData, options = {}) {
const token = localStorage.getItem('token')
const apiUrl = getApiUrl()
const toast = useToast()
// Timeout par défaut de 5 minutes pour les uploads
const timeout = options.timeout || 300000
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
console.log(`📤 Upload FormData vers: ${apiUrl}${endpoint}`)
try {
const response = await fetch(`${apiUrl}${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
// Ne PAS mettre Content-Type, fetch le gère automatiquement avec FormData
},
body: formData,
signal: controller.signal
})
clearTimeout(timeoutId)
console.log(`📥 Upload response status: ${response.status}`)
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Erreur inconnue' }))
console.error('❌ Upload error:', errorData)
// Gestion des erreurs d'authentification
if (response.status === 401) {
// Vérifier si c'est vraiment une erreur d'auth ou juste un problème réseau
const isAuthError = errorData.detail?.toLowerCase().includes('credential') ||
errorData.detail?.toLowerCase().includes('token') ||
errorData.detail?.toLowerCase().includes('not authenticated')
if (isAuthError) {
localStorage.removeItem('token')
router.push('/login')
toast.error('Session expirée, veuillez vous reconnecter')
} else {
toast.error('Erreur d\'authentification lors de l\'upload')
}
} else if (response.status === 413) {
toast.error('Fichier trop volumineux')
} else if (response.status === 422) {
toast.error('Données invalides: ' + (errorData.detail || 'Vérifiez le formulaire'))
} else {
toast.error(errorData.detail || 'Erreur lors de l\'upload')
}
const error = new Error(errorData.detail || 'Erreur lors de l\'upload')
error.response = { status: response.status, data: errorData }
throw error
}
const data = await response.json()
console.log('✅ Upload réussi')
return data
} catch (error) {
clearTimeout(timeoutId)
if (error.name === 'AbortError') {
console.error('❌ Upload timeout')
toast.error('Délai d\'attente dépassé. Le fichier est peut-être trop volumineux.')
throw new Error('Timeout lors de l\'upload')
}
// Erreur réseau
if (!error.response) {
console.error('❌ Network error during upload:', error)
toast.error('Erreur de connexion lors de l\'upload')
}
throw error
}
}

View File

@@ -0,0 +1,79 @@
import { format, formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz'
// Fuseau horaire français
const FRENCH_TIMEZONE = 'Europe/Paris'
/**
* Convertit une date UTC en date du fuseau horaire français
*/
function toFrenchTimezone(date) {
if (!date) return null
const dateObj = date instanceof Date ? date : new Date(date)
return utcToZonedTime(dateObj, FRENCH_TIMEZONE)
}
/**
* Formate une date dans le fuseau horaire français
*/
export function formatDateInFrenchTimezone(date, formatStr = 'dd MMM à HH:mm') {
if (!date) return ''
const frenchDate = toFrenchTimezone(date)
return format(frenchDate, formatStr, { locale: fr })
}
/**
* Formate une date relative dans le fuseau horaire français
* Note: On s'assure que la date est correctement parsée comme UTC
*/
export function formatRelativeDateInFrenchTimezone(date) {
if (!date) return ''
// Convertir la date en objet Date si ce n'est pas déjà le cas
let dateObj = date instanceof Date ? date : new Date(date)
// Si la date est une string sans "Z" à la fin, elle est interprétée comme locale
// On doit s'assurer qu'elle est traitée comme UTC
if (typeof date === 'string' && !date.endsWith('Z') && !date.includes('+') && !date.includes('-', 10)) {
// Si c'est une date ISO sans timezone, on l'interprète comme UTC
dateObj = new Date(date + 'Z')
}
// Calculer la distance depuis maintenant
return formatDistanceToNow(dateObj, { addSuffix: true, locale: fr })
}
/**
* Formate une date complète dans le fuseau horaire français
*/
export function formatFullDateInFrenchTimezone(date) {
return formatDateInFrenchTimezone(date, 'EEEE d MMMM yyyy à HH:mm')
}
/**
* Formate une date courte dans le fuseau horaire français
*/
export function formatShortDateInFrenchTimezone(date) {
return formatDateInFrenchTimezone(date, 'dd/MM/yyyy')
}
/**
* Formate une date pour un input datetime-local dans le fuseau horaire français
*/
export function formatDateForInputInFrenchTimezone(date) {
if (!date) return ''
const frenchDate = toFrenchTimezone(date)
return format(frenchDate, "yyyy-MM-dd'T'HH:mm", { locale: fr })
}
/**
* Convertit une date du fuseau horaire français vers UTC pour l'envoyer au backend
*/
export function convertFrenchTimezoneToUTC(date) {
if (!date) return null
const dateObj = date instanceof Date ? date : new Date(date)
return zonedTimeToUtc(dateObj, FRENCH_TIMEZONE)
}

View File

@@ -950,8 +950,9 @@
<img
:src="getMediaUrl(selectedTicket.screenshot_path)"
:alt="'Screenshot du ticket ' + selectedTicket.title"
class="max-w-full h-auto rounded-lg shadow-sm ticket-screenshot cursor-pointer"
class="w-full sm:max-w-2xl h-auto rounded-lg shadow-sm ticket-screenshot cursor-pointer"
@click="openImageModal(selectedTicket.screenshot_path)"
@error="(e) => console.error('Image load error:', e.target.src)"
>
<p class="text-xs text-gray-500 mt-2">Cliquez sur l'image pour l'agrandir</p>
</div>
@@ -1009,7 +1010,7 @@
>
<div
v-if="showImageModal"
class="fixed inset-0 bg-black bg-opacity-90 z-60 flex items-center justify-center p-4"
class="fixed inset-0 bg-black bg-opacity-90 z-[70] flex items-center justify-center p-4"
@click="showImageModal = false"
>
<div class="relative max-w-4xl max-h-[90vh]">
@@ -1239,8 +1240,7 @@ import {
TestTube,
Plus
} from 'lucide-vue-next'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import LoadingLogo from '@/components/LoadingLogo.vue'
const authStore = useAuthStore()
@@ -1710,7 +1710,7 @@ function getPriorityBadgeClass(priority) {
}
function formatDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
return formatRelativeDateInFrenchTimezone(date)
}
// Gestion des tickets
@@ -1850,7 +1850,11 @@ function openImageModal(imageUrl) {
function getMediaUrl(path) {
if (!path) return ''
return path.startsWith('http') ? path : `${import.meta.env.VITE_API_URL || 'http://localhost:8002'}${path}`
if (path.startsWith('http')) return path
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8002'
// Ensure path starts with /
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${baseUrl}${normalizedPath}`
}
// Nouvelles fonctions pour les filtres et actions rapides

View File

@@ -475,6 +475,17 @@
<X class="w-6 h-6" />
</button>
<!-- Download Button -->
<a
:href="getMediaUrl(selectedMedia.file_path)"
:download="selectedMedia.caption || 'media'"
target="_blank"
class="absolute top-6 right-20 z-10 bg-black bg-opacity-70 text-white rounded-full w-12 h-12 flex items-center justify-center hover:bg-opacity-90 transition-colors shadow-lg"
title="Télécharger"
>
<Download class="w-6 h-6" />
</a>
<!-- Navigation Buttons -->
@@ -566,9 +577,8 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { format, formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import {
ArrowLeft,
Image,
@@ -583,7 +593,8 @@ import {
Eye,
Heart,
ChevronLeft,
ChevronRight
ChevronRight,
Download
} from 'lucide-vue-next'
import LoadingLogo from '@/components/LoadingLogo.vue'
@@ -618,7 +629,7 @@ const totalSize = computed(() =>
)
function formatDate(date) {
return format(new Date(date), 'dd MMMM yyyy', { locale: fr })
return formatDateInFrenchTimezone(date, 'dd MMMM yyyy')
}
function formatBytes(bytes) {
@@ -717,9 +728,7 @@ async function uploadMedia() {
formData.append('files', media.file)
})
await axios.post(`/api/albums/${album.value.id}/media`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
await uploadFormData(`/api/albums/${album.value.id}/media`, formData)
// Refresh album data
await fetchAlbum()

View File

@@ -333,10 +333,9 @@
import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { formatDistanceToNow, format } from 'date-fns'
import { fr } from 'date-fns/locale'
import axios, { postJson } from '@/utils/axios'
import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone } from '@/utils/dateUtils'
import {
Plus,
Image,
@@ -384,11 +383,11 @@ const uploadSuccess = ref([])
const isDragOver = ref(false)
function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
return formatRelativeDateInFrenchTimezone(date)
}
function formatDate(date) {
return format(new Date(date), 'dd/MM/yyyy', { locale: fr })
return formatShortDateInFrenchTimezone(date)
}
function formatFileSize(bytes) {
@@ -631,8 +630,7 @@ async function createAlbum() {
}
uploadStatus.value = 'Création de l\'album...'
const albumResponse = await axios.post('/api/albums', albumData)
const album = albumResponse.data
const album = await postJson('/api/albums', albumData)
// Upload media files in batches for better performance
const batchSize = 5 // Upload 5 files at a time
@@ -654,15 +652,11 @@ async function createAlbum() {
}
})
await axios.post(`/api/albums/${album.id}/media`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
// Update progress for this batch
const batchProgress = (progressEvent.loaded / progressEvent.total) * 100
const overallProgress = ((batchIndex * batchSize + batch.length) / newAlbum.value.media.length) * 100
uploadProgress.value = Math.min(overallProgress, 100)
}
})
await uploadFormData(`/api/albums/${album.id}/media`, formData)
// Update progress
const overallProgress = ((batchIndex * batchSize + batch.length) / newAlbum.value.media.length) * 100
uploadProgress.value = Math.min(overallProgress, 100)
// Mark batch as successful
batch.forEach((media, index) => {

View File

@@ -342,8 +342,7 @@ import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { format, formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { formatFullDateInFrenchTimezone, formatRelativeDateInFrenchTimezone, formatDateForInputInFrenchTimezone, convertFrenchTimezoneToUTC } from '@/utils/dateUtils'
import {
ArrowLeft,
Calendar,
@@ -379,11 +378,11 @@ const canEdit = computed(() =>
)
function formatDate(date) {
return format(new Date(date), 'EEEE d MMMM yyyy à HH:mm', { locale: fr })
return formatFullDateInFrenchTimezone(date)
}
function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
return formatRelativeDateInFrenchTimezone(date)
}
function getParticipationClass(status) {
@@ -444,7 +443,7 @@ async function fetchEvent() {
editForm.value = {
title: event.value.title,
description: event.value.description || '',
date: format(new Date(event.value.date), "yyyy-MM-dd'T'HH:mm", { locale: fr }),
date: formatDateForInputInFrenchTimezone(event.value.date),
location: event.value.location || ''
}
} catch (error) {
@@ -470,7 +469,7 @@ async function updateEvent() {
try {
const response = await axios.put(`/api/events/${event.value.id}`, {
...editForm.value,
date: new Date(editForm.value.date).toISOString()
date: convertFrenchTimezoneToUTC(new Date(editForm.value.date)).toISOString()
})
event.value = response.data
showEditModal.value = false

View File

@@ -221,23 +221,25 @@
>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="label">Date et heure</label>
<label class="label text-sm">Date et heure</label>
<input
v-model="newEvent.date"
type="datetime-local"
required
class="input"
class="input text-sm w-full"
style="min-width: 0;"
>
</div>
<div>
<label class="label">Date de fin (optionnel)</label>
<label class="label text-sm">Date de fin (optionnel)</label>
<input
v-model="newEvent.end_date"
type="datetime-local"
class="input"
class="input text-sm w-full"
style="min-width: 0;"
>
</div>
</div>
@@ -331,10 +333,9 @@
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router'
import axios from '@/utils/axios'
import axios, { postJson } from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { formatDistanceToNow, format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone, convertFrenchTimezoneToUTC } from '@/utils/dateUtils'
import {
Plus,
Calendar,
@@ -380,11 +381,11 @@ const filteredEvents = computed(() => {
})
function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
return formatRelativeDateInFrenchTimezone(date)
}
function formatDate(date) {
return format(new Date(date), 'dd/MM/yyyy', { locale: fr })
return formatShortDateInFrenchTimezone(date)
}
function openEvent(event) {
@@ -468,7 +469,13 @@ async function quickParticipation(eventId, status) {
}
async function createEvent() {
if (!newEvent.value.title || !newEvent.value.date) return
console.log('🔵 createEvent appelée')
console.log('🔵 newEvent.value:', newEvent.value)
if (!newEvent.value.title || !newEvent.value.date) {
console.warn('⚠️ Validation échouée: titre ou date manquant')
return
}
// Vérifier que des invités sont sélectionnés pour les événements privés
if (newEvent.value.is_private && (!newEvent.value.invited_user_ids || newEvent.value.invited_user_ids.length === 0)) {
@@ -477,18 +484,25 @@ async function createEvent() {
}
creating.value = true
console.log('🔵 creating.value = true')
try {
const eventData = {
title: newEvent.value.title,
description: newEvent.value.description,
date: new Date(newEvent.value.date).toISOString(),
date: convertFrenchTimezoneToUTC(new Date(newEvent.value.date)).toISOString(),
location: newEvent.value.location,
end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null,
end_date: newEvent.value.end_date ? convertFrenchTimezoneToUTC(new Date(newEvent.value.end_date)).toISOString() : null,
is_private: newEvent.value.is_private,
invited_user_ids: newEvent.value.is_private ? newEvent.value.invited_user_ids : null
}
await axios.post('/api/events', eventData)
console.log('🔵 eventData préparé:', eventData)
console.log('🔵 Envoi de la requête postJson...')
const data = await postJson('/api/events', eventData)
console.log('✅ Réponse reçue:', data)
// Refresh events list
await fetchEvents()
@@ -497,9 +511,18 @@ async function createEvent() {
resetForm()
toast.success(newEvent.value.is_private ? 'Événement privé créé avec succès' : 'Événement créé avec succès')
} catch (error) {
console.error('❌ Erreur dans createEvent:', error)
console.error('❌ Détails de l\'erreur:', {
message: error.message,
response: error.response,
request: error.request,
config: error.config
})
toast.error('Erreur lors de la création de l\'événement')
} finally {
creating.value = false
console.log('🔵 creating.value = false')
}
creating.value = false
}
function resetForm() {

View File

@@ -220,8 +220,7 @@ import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { format, formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { formatFullDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import {
Calendar,
TrendingUp,
@@ -248,11 +247,11 @@ const recentPosts = computed(() => stats.value.recent_posts || 0)
const activeMembers = computed(() => stats.value.total_users || 0)
function formatDate(date) {
return format(new Date(date), 'EEEE d MMMM à HH:mm', { locale: fr })
return formatFullDateInFrenchTimezone(date)
}
function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
return formatRelativeDateInFrenchTimezone(date)
}
async function fetchDashboardData() {

View File

@@ -103,8 +103,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import axios from '@/utils/axios'
import LoadingLogo from '@/components/LoadingLogo.vue'
@@ -154,7 +153,7 @@ function getCategoryBadgeClass(category) {
}
function formatDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
return formatRelativeDateInFrenchTimezone(date)
}
async function fetchInformations() {

View File

@@ -140,7 +140,7 @@
<img
:src="getMediaUrl(ticket.screenshot_path)"
:alt="ticket.title"
class="max-w-md rounded-lg border border-gray-200 cursor-pointer hover:opacity-90 transition-opacity"
class="w-full sm:max-w-md rounded-lg border border-gray-200 cursor-pointer hover:opacity-90 transition-opacity"
@click="viewScreenshot(ticket.screenshot_path)"
>
</div>
@@ -306,8 +306,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import { Save } from 'lucide-vue-next'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
@@ -419,7 +418,7 @@ function getPriorityBadgeClass(priority) {
}
function formatDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
return formatRelativeDateInFrenchTimezone(date)
}
function resetTicketForm() {

View File

@@ -99,7 +99,9 @@
<div
v-for="post in posts"
:key="post.id"
class="card p-4 sm:p-6"
:id="`post-${post.id}`"
class="card p-4 sm:p-6 transition-all duration-300"
:class="{ 'ring-2 ring-primary-500 bg-primary-50': route.query.highlight == post.id }"
>
<!-- Post Header -->
<div class="flex items-start space-x-3 mb-4">
@@ -282,13 +284,13 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import axios, { postJson } from '@/utils/axios'
import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import {
Plus,
User,
@@ -305,6 +307,8 @@ import MentionInput from '@/components/MentionInput.vue'
const authStore = useAuthStore()
const toast = useToast()
const route = useRoute()
const router = useRouter()
const user = computed(() => authStore.user)
const posts = ref([])
@@ -316,6 +320,7 @@ const showImageUpload = ref(false)
const offset = ref(0)
const hasMorePosts = ref(true)
const dateRefreshInterval = ref(null)
const newPost = ref({
content: '',
@@ -323,8 +328,13 @@ const newPost = ref({
mentioned_user_ids: []
})
// Force refresh pour les dates relatives
const dateRefreshKey = ref(0)
function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
// Utiliser dateRefreshKey pour forcer le recalcul
dateRefreshKey.value
return formatRelativeDateInFrenchTimezone(date)
}
function handleMentionsChanged(mentions) {
@@ -345,14 +355,22 @@ async function createPost() {
creating.value = true
try {
const response = await axios.post('/api/posts', {
const data = await postJson('/api/posts', {
content: newPost.value.content,
image_url: newPost.value.image_url,
mentioned_user_ids: newPost.value.mentioned_user_ids
})
// Add new post to the beginning of the list
posts.value.unshift(response.data)
posts.value.unshift(data)
// Forcer le rafraîchissement de la date immédiatement
dateRefreshKey.value++
// Rafraîchir à nouveau après 1 seconde pour s'assurer que ça s'affiche correctement
setTimeout(() => {
dateRefreshKey.value++
}, 1000)
// Reset form
newPost.value = {
@@ -447,11 +465,8 @@ async function handleImageChange(event) {
const formData = new FormData()
formData.append('file', file)
const response = await axios.post('/api/posts/upload-image', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
newPost.value.image_url = response.data.image_url
const data = await uploadFormData('/api/posts/upload-image', formData)
newPost.value.image_url = data.image_url
} catch (error) {
toast.error('Erreur lors de l\'upload de l\'image')
}
@@ -467,13 +482,35 @@ async function fetchPosts() {
loading.value = true
try {
const response = await axios.get(`/api/posts?limit=10&offset=${offset.value}`)
// S'assurer que les dates sont correctement parsées comme UTC
const postsData = response.data.map(post => {
// Si la date created_at est une string sans timezone, l'interpréter comme UTC
if (post.created_at && typeof post.created_at === 'string' && !post.created_at.endsWith('Z') && !post.created_at.includes('+') && !post.created_at.includes('-', 10)) {
post.created_at = post.created_at + 'Z'
}
// Même chose pour les commentaires
if (post.comments) {
post.comments = post.comments.map(comment => {
if (comment.created_at && typeof comment.created_at === 'string' && !comment.created_at.endsWith('Z') && !comment.created_at.includes('+') && !comment.created_at.includes('-', 10)) {
comment.created_at = comment.created_at + 'Z'
}
return comment
})
}
return post
})
if (offset.value === 0) {
posts.value = response.data
posts.value = postsData
} else {
posts.value.push(...response.data)
posts.value.push(...postsData)
}
hasMorePosts.value = response.data.length === 10
// Forcer le rafraîchissement des dates après le chargement
dateRefreshKey.value++
} catch (error) {
toast.error('Erreur lors du chargement des publications')
}
@@ -514,8 +551,47 @@ function canDeleteComment(comment) {
return user.value && (comment.author_id === user.value.id || user.value.is_admin)
}
onMounted(() => {
fetchPosts()
fetchUsers()
// Fonction pour scroller vers un post spécifique
async function scrollToPost(postId) {
await nextTick()
const postElement = document.getElementById(`post-${postId}`)
if (postElement) {
postElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Retirer le highlight après 3 secondes
setTimeout(() => {
if (route.query.highlight) {
router.replace({ query: {} })
}
}, 3000)
}
}
// Watcher pour le highlight dans la query
watch(() => route.query.highlight, async (postId) => {
if (postId && posts.value.length > 0) {
await scrollToPost(parseInt(postId))
}
}, { immediate: true })
onMounted(async () => {
await fetchPosts()
await fetchUsers()
// Si on a un highlight dans la query, scroller vers le post
if (route.query.highlight) {
await nextTick()
await scrollToPost(parseInt(route.query.highlight))
}
// Rafraîchir les dates relatives toutes les 30 secondes
dateRefreshInterval.value = setInterval(() => {
dateRefreshKey.value++
}, 30000)
})
onBeforeUnmount(() => {
if (dateRefreshInterval.value) {
clearInterval(dateRefreshInterval.value)
}
})
</script>

View File

@@ -159,8 +159,7 @@ import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { formatDateInFrenchTimezone } from '@/utils/dateUtils'
import { User, Camera } from 'lucide-vue-next'
const authStore = useAuthStore()
@@ -179,7 +178,7 @@ const form = ref({
function formatDate(date) {
if (!date) return ''
return format(new Date(date), 'MMMM yyyy', { locale: fr })
return formatDateInFrenchTimezone(date, 'MMMM yyyy')
}
function resetForm() {

View File

@@ -68,7 +68,10 @@
>
</div>
<div>
<label for="username" class="label text-sm sm:text-base">Nom d'utilisateur</label>
<label for="username" class="label text-sm sm:text-base">
Identifiant unique
<span class="text-xs text-gray-500 font-normal ml-1">(non modifiable)</span>
</label>
<input
id="username"
v-model="form.username"
@@ -76,22 +79,26 @@
required
minlength="3"
class="input text-sm sm:text-base"
placeholder="nom_utilisateur"
placeholder="mon_pseudo"
@blur="touchedFields.username = true"
>
</div>
</div>
<div>
<label for="full_name" class="label text-sm sm:text-base">Nom complet</label>
<label for="full_name" class="label text-sm sm:text-base">
Pseudo affiché
<span class="text-xs text-gray-500 font-normal ml-1">(modifiable plus tard)</span>
</label>
<input
id="full_name"
v-model="form.full_name"
type="text"
required
class="input text-sm sm:text-base"
placeholder="Prénom Nom"
placeholder="Ton surnom / prénom"
@blur="touchedFields.full_name = true"
>
<p class="text-xs text-gray-500 mt-1">C'est ce qui sera affiché aux autres membres</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
<div>
@@ -267,7 +274,7 @@
Une fois connecté, tu pourras installer l'app depuis le menu de ton profil
</p>
<button
v-if="deferredPrompt"
v-if="deferredPrompt || isMobile"
@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"
>
@@ -275,11 +282,11 @@
<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>
<span>{{ deferredPrompt ? 'Installer l\'app maintenant' : 'Voir les instructions' }}</span>
</div>
</button>
<p v-else class="text-xs sm:text-sm text-gray-500">
Voilà, c'est quand même mieux comme ça !
Le bouton d'installation apparaîtra automatiquement quand tu seras connecté
</p>
</div>
</div>
@@ -332,6 +339,16 @@
</router-link>
</p>
</div>
<!-- PWA Install Tutorial -->
<PWAInstallTutorial
:show="showPWAInstructions"
:is-ios="isIOS"
:is-android="isAndroid"
:is-windows="isWindows"
:is-mac="isMac"
@close="showPWAInstructions = false"
/>
</div>
</template>
@@ -342,6 +359,7 @@ import { useAuthStore } from '@/stores/auth'
import StepTransition from '@/components/StepTransition.vue'
import PasswordStrength from '@/components/PasswordStrength.vue'
import FormValidation from '@/components/FormValidation.vue'
import PWAInstallTutorial from '@/components/PWAInstallTutorial.vue'
const authStore = useAuthStore()
const router = useRouter()
@@ -439,33 +457,52 @@ async function handleRegister() {
}
// PWA Installation
const isMobile = ref(false)
const showPWAInstructions = ref(false)
const isIOS = computed(() => /iPhone|iPad|iPod/i.test(navigator.userAgent))
const isAndroid = computed(() => /Android/i.test(navigator.userAgent))
const isWindows = computed(() => /Windows/i.test(navigator.userAgent))
const isMac = computed(() => /Macintosh|Mac OS/i.test(navigator.userAgent) && !isIOS.value)
function checkIfMobile() {
isMobile.value = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ||
window.innerWidth < 768
}
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')
if (deferredPrompt.value) {
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)
}
deferredPrompt.value = null
} catch (error) {
console.error('Erreur lors de l\'installation:', error)
} else {
// Si pas de beforeinstallprompt, afficher les instructions (mobile ou desktop)
showPWAInstructions.value = true
}
}
onMounted(() => {
checkIfMobile()
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('resize', checkIfMobile)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('resize', checkIfMobile)
})
</script>

View File

@@ -123,8 +123,7 @@ import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { format, formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { formatDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import { User, ArrowLeft, ArrowRight, MessageSquare, Video, Image, Calendar, Activity } from 'lucide-vue-next'
import LoadingLogo from '@/components/LoadingLogo.vue'
@@ -140,12 +139,12 @@ const recentActivity = ref([])
function formatDate(date) {
if (!date) return ''
return format(new Date(date), 'MMMM yyyy', { locale: fr })
return formatDateInFrenchTimezone(date, 'MMMM yyyy')
}
function formatRelativeDate(date) {
if (!date) return ''
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
return formatRelativeDateInFrenchTimezone(date)
}
function getActivityIcon(type) {

View File

@@ -228,8 +228,7 @@ import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { format, formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { formatDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import {
ArrowLeft,
User,
@@ -269,11 +268,11 @@ const canEdit = computed(() =>
)
function formatDate(date) {
return format(new Date(date), 'dd MMMM yyyy', { locale: fr })
return formatDateInFrenchTimezone(date, 'dd MMMM yyyy')
}
function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
return formatRelativeDateInFrenchTimezone(date)
}
function formatDuration(seconds) {

View File

@@ -267,9 +267,8 @@ import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import {
Plus,
Film,
@@ -312,7 +311,7 @@ function formatDuration(seconds) {
}
function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
return formatRelativeDateInFrenchTimezone(date)
}
function openVlog(vlog) {
@@ -436,16 +435,13 @@ async function createVlog() {
formData.append('thumbnail', newVlog.value.thumbnailFile)
}
const response = await axios.post('/api/vlogs/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
vlogs.value.unshift(response.data)
const data = await uploadFormData('/api/vlogs/upload', formData)
vlogs.value.unshift(data)
showCreateModal.value = false
resetForm()
toast.success('Vlog créé avec succès')
} catch (error) {
toast.error('Erreur lors de la création du vlog')
toast.error(error.message || 'Erreur lors de la création du vlog')
}
creating.value = false
}

View File

@@ -179,6 +179,8 @@ module.exports = defineConfig(({ command, mode }) => {
navigateFallback: null,
skipWaiting: true,
clientsClaim: true,
// Intégrer le service worker personnalisé
importScripts: ['/sw-custom.js'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
@@ -209,7 +211,11 @@ module.exports = defineConfig(({ command, mode }) => {
}
},
{
urlPattern: /^https?:\/\/.*\/api\/.*/i,
// Intercepter uniquement les requêtes GET pour l'API
urlPattern: ({ url, request }) => {
const urlString = url.href || url.toString()
return /^https?:\/\/.*\/api\/.*/i.test(urlString) && request.method === 'GET'
},
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
@@ -222,7 +228,7 @@ module.exports = defineConfig(({ command, mode }) => {
},
{
urlPattern: /^https?:\/\/.*\/uploads\/.*/i,
handler: 'CacheFirst',
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'uploads-cache',
expiration: {