Compare commits

..

8 Commits

Author SHA1 Message Date
96c321e108 Merge pull request 'fix+feat(everything): lot of things' (#3) from develop into prod
Some checks failed
Deploy to Production / build-and-deploy (push) Failing after 1m3s
Reviewed-on: https://git.local.evan.casa/evan/LeDiscord/pulls/3
2026-01-25 22:16:10 +01:00
d9ab8230a6 Merge pull request 'develop' (#1) from develop into prod
All checks were successful
Deploy to Production / build-and-deploy (push) Successful in 1m7s
Reviewed-on: https://git.local.evan.casa/evan/LeDiscord/pulls/1
2026-01-25 21:06:06 +01:00
EvanChal
87de65e6b2 ci/cd update 6
Some checks failed
Deploy to Production / build-and-deploy (push) Failing after 19s
2025-12-24 03:46:58 +01:00
EvanChal
7325f25b5f ci/cd update 5
Some checks failed
Deploy to Production / build-and-deploy (push) Failing after 2s
2025-12-24 03:45:04 +01:00
EvanChal
362b19fd66 ci/cd update 4
Some checks failed
Deploy to Production / build-and-deploy (push) Failing after 36s
2025-12-24 03:41:20 +01:00
EvanChal
7f8897d76d ci/cd update 3
Some checks failed
Deploy to Production / build-and-deploy (push) Failing after 21s
2025-12-24 03:35:04 +01:00
EvanChal
2bd07d09d3 ci/cd update 2
Some checks failed
Deploy to Production / build-and-deploy (push) Has been cancelled
2025-12-24 03:34:27 +01:00
EvanChal
d3446aa428 ci/cd update
Some checks failed
Deploy to Production / build-and-deploy (push) Has been cancelled
2025-12-24 03:32:09 +01:00
60 changed files with 396 additions and 11472 deletions

View File

@@ -9,35 +9,41 @@ jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker:24-dind image: catthehacker/ubuntu:act-latest
options: --privileged
steps: steps:
- name: Install git
run: apk add --no-cache git
- name: Checkout code - name: Checkout code
run: | uses: actions/checkout@v3
git clone --depth 1 --branch prod https://${{ secrets.REGISTRY_USERNAME }}:${{ secrets.REGISTRY_TOKEN }}@git.local.evan.casa/evan/lediscord.git .
- name: Login to Registry - name: Configure DNS
run: |
echo "nameserver 192.168.1.50" > /etc/resolv.conf
echo "nameserver 8.8.8.8" >> /etc/resolv.conf
echo "DNS configured:"
cat /etc/resolv.conf
# Ajouter l'IP du registry à /etc/hosts si disponible
if [ -n "${{ secrets.REGISTRY_IP }}" ]; then
echo "${{ secrets.REGISTRY_IP }} ${{ secrets.REGISTRY_URL }}" >> /etc/hosts
echo "Added to /etc/hosts: ${{ secrets.REGISTRY_IP }} ${{ secrets.REGISTRY_URL }}"
fi
# Tester la résolution DNS
echo "Testing DNS resolution..."
getent hosts ${{ secrets.REGISTRY_URL }} || echo "DNS resolution test"
- name: Login to Gitea Registry
run: | run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ secrets.REGISTRY_URL }} -u ${{ secrets.REGISTRY_USERNAME }} --password-stdin echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ secrets.REGISTRY_URL }} -u ${{ secrets.REGISTRY_USERNAME }} --password-stdin
- name: Build and push backend - name: Build and push
run: | run: |
echo "Building backend image..."
docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend ./backend docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend ./backend
for i in 1 2 3 4 5; do docker tag ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:latest-backend
docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend && break || sleep 10 echo "Pushing backend images..."
done docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend
docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:latest-backend
- name: Build and push frontend echo "Building frontend image..."
run: |
docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend ./frontend docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend ./frontend
for i in 1 2 3 4 5; do docker tag ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:latest-frontend
docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend && break || sleep 10 echo "Pushing frontend images..."
done docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend
docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:latest-frontend
- name: Done
run: |
echo "🚀 Images pushed! Run: nomad job run -force lediscord.nomad"

View File

@@ -42,9 +42,17 @@ COPY . .
# Permissions # Permissions
RUN chmod -R 755 /app RUN chmod -R 755 /app
RUN chmod +x entrypoint.sh
EXPOSE 8000 EXPOSE 8000
# Lancer les migrations puis démarrer l'application # Hot-reload + respect des en-têtes proxy (utile si tu testes derrière Traefik en dev)
CMD ["./entrypoint.sh"] # 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=*"]

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import mimetypes
from config.settings import settings from config.settings import settings
from config.database import engine, Base 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, push from api.routers import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings as settings_router, information, tickets
from utils.init_db import init_database from utils.init_db import init_database
from utils.settings_service import SettingsService from utils.settings_service import SettingsService
from config.database import SessionLocal from config.database import SessionLocal
@@ -203,21 +203,6 @@ app.add_middleware(
expose_headers=["Content-Range", "Accept-Ranges"], 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 # Endpoint personnalisé pour servir les vidéos avec support Range
@app.get("/uploads/{file_path:path}") @app.get("/uploads/{file_path:path}")
async def serve_media_with_range(request: Request, file_path: str): async def serve_media_with_range(request: Request, file_path: str):
@@ -309,7 +294,6 @@ app.include_router(vlogs.router, prefix="/api/vlogs", tags=["Vlogs"])
app.include_router(stats.router, prefix="/api/stats", tags=["Statistics"]) app.include_router(stats.router, prefix="/api/stats", tags=["Statistics"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"]) 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(settings_router.router, prefix="/api/settings", tags=["Settings"])
app.include_router(information.router, prefix="/api/information", tags=["Information"]) app.include_router(information.router, prefix="/api/information", tags=["Information"])
app.include_router(tickets.router, prefix="/api/tickets", tags=["Tickets"]) app.include_router(tickets.router, prefix="/api/tickets", tags=["Tickets"])

View File

@@ -55,11 +55,6 @@ class Settings:
if not ADMIN_PASSWORD: if not ADMIN_PASSWORD:
raise ValueError("ADMIN_PASSWORD variable is required") 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
APP_NAME: str = os.getenv("APP_NAME", "LeDiscord") APP_NAME: str = os.getenv("APP_NAME", "LeDiscord")
APP_URL: str = os.getenv("APP_URL", "http://localhost:5173") APP_URL: str = os.getenv("APP_URL", "http://localhost:5173")

View File

@@ -1,29 +0,0 @@
#!/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,5 +41,3 @@ alembic revision -m "Description de la migration"
- Testez toujours les migrations en développement avant de les appliquer en production - 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` - En cas de problème, vous pouvez toujours revenir en arrière avec `alembic downgrade`

View File

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

View File

@@ -1,54 +0,0 @@
"""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

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

View File

@@ -27,16 +27,3 @@ class Notification(Base):
# Relationships # Relationships
user = relationship("User", back_populates="notifications") 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,7 +27,6 @@ class User(Base):
mentions = relationship("PostMention", back_populates="mentioned_user", cascade="all, delete-orphan") mentions = relationship("PostMention", back_populates="mentioned_user", cascade="all, delete-orphan")
vlogs = relationship("Vlog", back_populates="author", cascade="all, delete-orphan") vlogs = relationship("Vlog", back_populates="author", cascade="all, delete-orphan")
notifications = relationship("Notification", back_populates="user", 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_likes = relationship("VlogLike", back_populates="user", cascade="all, delete-orphan")
vlog_comments = relationship("VlogComment", 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") media_likes = relationship("MediaLike", back_populates="user", cascade="all, delete-orphan")

View File

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

View File

@@ -15,14 +15,3 @@ class NotificationResponse(BaseModel):
class Config: class Config:
from_attributes = True 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

@@ -1,70 +0,0 @@
#!/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,5 +40,3 @@ case "$1" in
;; ;;
esac esac

View File

@@ -1,136 +0,0 @@
"""
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 typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status, Request from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from config.settings import settings from config.settings import settings
@@ -11,9 +11,7 @@ from models.user import User
from schemas.user import TokenData from schemas.user import TokenData
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 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: def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hashed password.""" """Verify a plain password against a hashed password."""
@@ -47,42 +45,17 @@ def verify_token(token: str, credentials_exception) -> TokenData:
except JWTError: except JWTError:
raise credentials_exception raise credentials_exception
async def get_current_user( async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
token: Optional[str] = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> User:
"""Get the current authenticated user.""" """Get the current authenticated user."""
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"}, 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() user = db.query(User).filter(User.id == token_data.user_id).first()
if user is None: if user is None:
print(f"❌ AUTH: User not found for id={token_data.user_id}")
raise credentials_exception raise credentials_exception
print(f"✅ AUTH: User authenticated: {user.username}")
return user return user
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,119 +0,0 @@
// 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,24 +8,15 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import DefaultLayout from '@/layouts/DefaultLayout.vue' import DefaultLayout from '@/layouts/DefaultLayout.vue'
import AuthLayout from '@/layouts/AuthLayout.vue' import AuthLayout from '@/layouts/AuthLayout.vue'
import EnvironmentDebug from '@/components/EnvironmentDebug.vue' import EnvironmentDebug from '@/components/EnvironmentDebug.vue'
const route = useRoute() const route = useRoute()
const authStore = useAuthStore()
const layout = computed(() => { const layout = computed(() => {
return route.meta.layout === 'auth' ? AuthLayout : DefaultLayout 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> </script>

View File

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

View File

@@ -1,308 +0,0 @@
<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,7 +129,6 @@ import { ref, computed } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { uploadFormData } from '@/utils/axios'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { Plus, X, Send, Ticket } from 'lucide-vue-next' import { Plus, X, Send, Ticket } from 'lucide-vue-next'
import LoadingLogo from '@/components/LoadingLogo.vue' import LoadingLogo from '@/components/LoadingLogo.vue'
@@ -190,7 +189,22 @@ async function submitTicket() {
// Debug: afficher les données envoyées // Debug: afficher les données envoyées
console.log('DEBUG - Ticket form data:') console.log('DEBUG - Ticket form data:')
await uploadFormData('/api/tickets/', formData) 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',
}
})
toast.success('Ticket envoyé avec succès !') toast.success('Ticket envoyé avec succès !')
closeModal() closeModal()

View File

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

View File

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

View File

@@ -37,29 +37,6 @@ app.use(Toast, toastOptions)
// Maintenant installer le router // Maintenant installer le router
app.use(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 // Attendre que le router soit prêt avant de monter l'app
router.isReady().then(() => { router.isReady().then(() => {
app.mount('#app') app.mount('#app')

View File

@@ -1,82 +1,13 @@
import { useAuthStore } from '@/stores/auth' 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 { class NotificationService {
constructor() { constructor() {
this.pollingInterval = null this.pollingInterval = null
this.pollInterval = 30000 // 30 secondes this.pollInterval = 30000 // 30 secondes
this.isPolling = false 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() { 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 if (this.isPolling) return
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -109,9 +40,26 @@ class NotificationService {
} }
try { try {
// Juste mettre à jour le store (compteur, liste) const result = await authStore.fetchNotifications()
// Les notifications push sont gérées par le service worker
await authStore.fetchNotifications() // Si de nouvelles notifications non lues ont été détectées
if (result && result.hasNewNotifications && result.newCount > result.previousCount) {
// Trouver les nouvelles notifications non lues (les plus récentes en premier)
const newUnreadNotifications = authStore.notifications
.filter(n => !n.is_read)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, result.newCount - result.previousCount)
if (newUnreadNotifications.length > 0) {
// Afficher une notification push pour la plus récente
const latestNotification = newUnreadNotifications[0]
await this.showPushNotification(latestNotification.title, {
body: latestNotification.message,
link: latestNotification.link || '/',
data: { notificationId: latestNotification.id }
})
}
}
} catch (error) { } catch (error) {
console.error('Error polling notifications:', error) console.error('Error polling notifications:', error)
} }
@@ -125,128 +73,73 @@ 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 // Gestion des notifications push PWA
async requestNotificationPermission() { async requestNotificationPermission() {
console.log('🔔 requestNotificationPermission appelée') if (!('Notification' in window)) {
console.log('Ce navigateur ne supporte pas les notifications')
if (!this.isNotificationSupported()) {
return false return false
} }
if (Notification.permission === 'granted') { if (Notification.permission === 'granted') {
console.log('✅ Notification permission already granted')
// S'assurer qu'on est bien abonné au push
this.subscribeToPush()
return true return true
} }
if (Notification.permission === 'denied') { if (Notification.permission !== 'denied') {
console.warn('❌ Notification permission denied by user')
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 permission = await Notification.requestPermission()
const granted = permission === 'granted' return 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
} }
return false
} }
async showPushNotification(title, options = {}) { async showPushNotification(title, options = {}) {
// Cette méthode est maintenant principalement utilisée pour les tests if (!('Notification' in window)) return
// ou les notifications locales générées par le client
if (!this.isNotificationSupported()) return null const hasPermission = await this.requestNotificationPermission()
if (Notification.permission !== 'granted') return null if (!hasPermission) return
// Toujours utiliser le service worker pour les notifications push // Si on est dans un service worker, utiliser la notification API du SW
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
try { try {
const registration = await navigator.serviceWorker.ready const registration = await navigator.serviceWorker.ready
if (!registration.active) return null
await registration.showNotification(title, { await registration.showNotification(title, {
icon: '/icon-192x192.png', icon: '/icon-192x192.png',
badge: '/icon-96x96.png', badge: '/icon-96x96.png',
tag: 'lediscord-notification', tag: 'lediscord-notification',
requireInteraction: false,
vibrate: [200, 100, 200],
...options ...options
}) })
return true return
} catch (error) { } catch (error) {
console.error('Error showing notification:', error) console.error('Error showing notification via service worker:', error)
} }
} }
return null
// Fallback: notification native du navigateur
const notification = new Notification(title, {
icon: '/icon-192x192.png',
badge: '/icon-96x96.png',
tag: 'lediscord-notification',
requireInteraction: false,
...options
})
notification.onclick = () => {
window.focus()
notification.close()
if (options.link) {
window.location.href = options.link
}
}
// Fermer automatiquement après 5 secondes
setTimeout(() => {
notification.close()
}, 5000)
return notification
} }
// Écouter les messages du service worker pour les notifications push // Écouter les messages du service worker pour les notifications push
@@ -254,29 +147,12 @@ class NotificationService {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => { navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'NOTIFICATION') { if (event.data && event.data.type === 'NOTIFICATION') {
// Notification reçue via message (fallback) const { title, options } = event.data
console.log('Notification message received:', event.data) this.showPushNotification(title, options)
} }
}) })
} }
} }
// 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() export default new NotificationService()

View File

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

View File

@@ -53,30 +53,11 @@ instance.interceptors.request.use(
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (token) { if (token) {
config.headers.Authorization = `Bearer ${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`)
}
} }
// Augmenter le timeout pour les requêtes POST/PUT avec FormData (uploads) // Log des requêtes en développement
if ((config.method === 'POST' || config.method === 'PUT') && config.data instanceof FormData) { if (import.meta.env.DEV) {
config.timeout = 120000 // 2 minutes pour les uploads console.log(`📤 Requête ${config.method?.toUpperCase()} vers: ${config.url}`)
// 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 return config
@@ -105,47 +86,16 @@ instance.interceptors.response.use(
statusText: error.response?.statusText, statusText: error.response?.statusText,
url: error.config?.url, url: error.config?.url,
method: error.config?.method, 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) { if (error.response?.status === 401) {
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 // Ne pas rediriger si on est déjà sur une page d'auth
const currentRoute = router.currentRoute.value
if (!currentRoute.path.includes('/login') && !currentRoute.path.includes('/register')) { if (!currentRoute.path.includes('/login') && !currentRoute.path.includes('/register')) {
if (isRealAuthError) { localStorage.removeItem('token')
localStorage.removeItem('token') router.push('/login')
router.push('/login') toast.error('Session expirée, veuillez vous reconnecter')
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) { } else if (error.response?.status === 403) {
toast.error('Accès non autorisé') toast.error('Accès non autorisé')
@@ -214,146 +164,3 @@ export function getApiUrl() {
export function getAppUrl() { export function getAppUrl() {
return import.meta.env.VITE_APP_URL || window.location.origin 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

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

View File

@@ -475,17 +475,6 @@
<X class="w-6 h-6" /> <X class="w-6 h-6" />
</button> </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 --> <!-- Navigation Buttons -->
@@ -577,8 +566,9 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { getMediaUrl, uploadFormData } from '@/utils/axios' import { getMediaUrl } from '@/utils/axios'
import { formatDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils' import { format, formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { import {
ArrowLeft, ArrowLeft,
Image, Image,
@@ -593,8 +583,7 @@ import {
Eye, Eye,
Heart, Heart,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight
Download
} from 'lucide-vue-next' } from 'lucide-vue-next'
import LoadingLogo from '@/components/LoadingLogo.vue' import LoadingLogo from '@/components/LoadingLogo.vue'
@@ -629,7 +618,7 @@ const totalSize = computed(() =>
) )
function formatDate(date) { function formatDate(date) {
return formatDateInFrenchTimezone(date, 'dd MMMM yyyy') return format(new Date(date), 'dd MMMM yyyy', { locale: fr })
} }
function formatBytes(bytes) { function formatBytes(bytes) {
@@ -728,7 +717,9 @@ async function uploadMedia() {
formData.append('files', media.file) formData.append('files', media.file)
}) })
await uploadFormData(`/api/albums/${album.value.id}/media`, formData) await axios.post(`/api/albums/${album.value.id}/media`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
// Refresh album data // Refresh album data
await fetchAlbum() await fetchAlbum()

View File

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

View File

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

View File

@@ -221,25 +221,23 @@
> >
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="label text-sm">Date et heure</label> <label class="label">Date et heure</label>
<input <input
v-model="newEvent.date" v-model="newEvent.date"
type="datetime-local" type="datetime-local"
required required
class="input text-sm w-full" class="input"
style="min-width: 0;"
> >
</div> </div>
<div> <div>
<label class="label text-sm">Date de fin (optionnel)</label> <label class="label">Date de fin (optionnel)</label>
<input <input
v-model="newEvent.end_date" v-model="newEvent.end_date"
type="datetime-local" type="datetime-local"
class="input text-sm w-full" class="input"
style="min-width: 0;"
> >
</div> </div>
</div> </div>
@@ -333,9 +331,10 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios, { postJson } from '@/utils/axios' import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl } from '@/utils/axios'
import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone, convertFrenchTimezoneToUTC } from '@/utils/dateUtils' import { formatDistanceToNow, format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { import {
Plus, Plus,
Calendar, Calendar,
@@ -381,11 +380,11 @@ const filteredEvents = computed(() => {
}) })
function formatRelativeDate(date) { function formatRelativeDate(date) {
return formatRelativeDateInFrenchTimezone(date) return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
} }
function formatDate(date) { function formatDate(date) {
return formatShortDateInFrenchTimezone(date) return format(new Date(date), 'dd/MM/yyyy', { locale: fr })
} }
function openEvent(event) { function openEvent(event) {
@@ -469,13 +468,7 @@ async function quickParticipation(eventId, status) {
} }
async function createEvent() { async function createEvent() {
console.log('🔵 createEvent appelée') if (!newEvent.value.title || !newEvent.value.date) return
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 // 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)) { if (newEvent.value.is_private && (!newEvent.value.invited_user_ids || newEvent.value.invited_user_ids.length === 0)) {
@@ -484,25 +477,18 @@ async function createEvent() {
} }
creating.value = true creating.value = true
console.log('🔵 creating.value = true')
try { try {
const eventData = { const eventData = {
title: newEvent.value.title, title: newEvent.value.title,
description: newEvent.value.description, description: newEvent.value.description,
date: convertFrenchTimezoneToUTC(new Date(newEvent.value.date)).toISOString(), date: new Date(newEvent.value.date).toISOString(),
location: newEvent.value.location, location: newEvent.value.location,
end_date: newEvent.value.end_date ? convertFrenchTimezoneToUTC(new Date(newEvent.value.end_date)).toISOString() : null, end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null,
is_private: newEvent.value.is_private, is_private: newEvent.value.is_private,
invited_user_ids: newEvent.value.is_private ? newEvent.value.invited_user_ids : null invited_user_ids: newEvent.value.is_private ? newEvent.value.invited_user_ids : null
} }
console.log('🔵 eventData préparé:', eventData) await axios.post('/api/events', 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 // Refresh events list
await fetchEvents() await fetchEvents()
@@ -511,18 +497,9 @@ async function createEvent() {
resetForm() resetForm()
toast.success(newEvent.value.is_private ? 'Événement privé créé avec succès' : 'Événement créé avec succès') toast.success(newEvent.value.is_private ? 'Événement privé créé avec succès' : 'Événement créé avec succès')
} catch (error) { } 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') 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() { function resetForm() {

View File

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

View File

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

View File

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

View File

@@ -99,9 +99,7 @@
<div <div
v-for="post in posts" v-for="post in posts"
:key="post.id" :key="post.id"
:id="`post-${post.id}`" class="card p-4 sm:p-6"
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 --> <!-- Post Header -->
<div class="flex items-start space-x-3 mb-4"> <div class="flex items-start space-x-3 mb-4">
@@ -284,13 +282,13 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import axios, { postJson } from '@/utils/axios' import axios from '@/utils/axios'
import { getMediaUrl, uploadFormData } from '@/utils/axios' import { getMediaUrl } from '@/utils/axios'
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils' import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { import {
Plus, Plus,
User, User,
@@ -307,8 +305,6 @@ import MentionInput from '@/components/MentionInput.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const toast = useToast() const toast = useToast()
const route = useRoute()
const router = useRouter()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const posts = ref([]) const posts = ref([])
@@ -320,7 +316,6 @@ const showImageUpload = ref(false)
const offset = ref(0) const offset = ref(0)
const hasMorePosts = ref(true) const hasMorePosts = ref(true)
const dateRefreshInterval = ref(null)
const newPost = ref({ const newPost = ref({
content: '', content: '',
@@ -328,13 +323,8 @@ const newPost = ref({
mentioned_user_ids: [] mentioned_user_ids: []
}) })
// Force refresh pour les dates relatives
const dateRefreshKey = ref(0)
function formatRelativeDate(date) { function formatRelativeDate(date) {
// Utiliser dateRefreshKey pour forcer le recalcul return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
dateRefreshKey.value
return formatRelativeDateInFrenchTimezone(date)
} }
function handleMentionsChanged(mentions) { function handleMentionsChanged(mentions) {
@@ -355,22 +345,14 @@ async function createPost() {
creating.value = true creating.value = true
try { try {
const data = await postJson('/api/posts', { const response = await axios.post('/api/posts', {
content: newPost.value.content, content: newPost.value.content,
image_url: newPost.value.image_url, image_url: newPost.value.image_url,
mentioned_user_ids: newPost.value.mentioned_user_ids mentioned_user_ids: newPost.value.mentioned_user_ids
}) })
// Add new post to the beginning of the list // Add new post to the beginning of the list
posts.value.unshift(data) posts.value.unshift(response.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 // Reset form
newPost.value = { newPost.value = {
@@ -465,8 +447,11 @@ async function handleImageChange(event) {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
const data = await uploadFormData('/api/posts/upload-image', formData) const response = await axios.post('/api/posts/upload-image', formData, {
newPost.value.image_url = data.image_url headers: { 'Content-Type': 'multipart/form-data' }
})
newPost.value.image_url = response.data.image_url
} catch (error) { } catch (error) {
toast.error('Erreur lors de l\'upload de l\'image') toast.error('Erreur lors de l\'upload de l\'image')
} }
@@ -482,35 +467,13 @@ async function fetchPosts() {
loading.value = true loading.value = true
try { try {
const response = await axios.get(`/api/posts?limit=10&offset=${offset.value}`) 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) { if (offset.value === 0) {
posts.value = postsData posts.value = response.data
} else { } else {
posts.value.push(...postsData) posts.value.push(...response.data)
} }
hasMorePosts.value = response.data.length === 10 hasMorePosts.value = response.data.length === 10
// Forcer le rafraîchissement des dates après le chargement
dateRefreshKey.value++
} catch (error) { } catch (error) {
toast.error('Erreur lors du chargement des publications') toast.error('Erreur lors du chargement des publications')
} }
@@ -551,47 +514,8 @@ function canDeleteComment(comment) {
return user.value && (comment.author_id === user.value.id || user.value.is_admin) return user.value && (comment.author_id === user.value.id || user.value.is_admin)
} }
// Fonction pour scroller vers un post spécifique onMounted(() => {
async function scrollToPost(postId) { fetchPosts()
await nextTick() fetchUsers()
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> </script>

View File

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

View File

@@ -68,10 +68,7 @@
> >
</div> </div>
<div> <div>
<label for="username" class="label text-sm sm:text-base"> <label for="username" class="label text-sm sm:text-base">Nom d'utilisateur</label>
Identifiant unique
<span class="text-xs text-gray-500 font-normal ml-1">(non modifiable)</span>
</label>
<input <input
id="username" id="username"
v-model="form.username" v-model="form.username"
@@ -79,26 +76,22 @@
required required
minlength="3" minlength="3"
class="input text-sm sm:text-base" class="input text-sm sm:text-base"
placeholder="mon_pseudo" placeholder="nom_utilisateur"
@blur="touchedFields.username = true" @blur="touchedFields.username = true"
> >
</div> </div>
</div> </div>
<div> <div>
<label for="full_name" class="label text-sm sm:text-base"> <label for="full_name" class="label text-sm sm:text-base">Nom complet</label>
Pseudo affiché
<span class="text-xs text-gray-500 font-normal ml-1">(modifiable plus tard)</span>
</label>
<input <input
id="full_name" id="full_name"
v-model="form.full_name" v-model="form.full_name"
type="text" type="text"
required required
class="input text-sm sm:text-base" class="input text-sm sm:text-base"
placeholder="Ton surnom / prénom" placeholder="Prénom Nom"
@blur="touchedFields.full_name = true" @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>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
<div> <div>
@@ -274,7 +267,7 @@
Une fois connecté, tu pourras installer l'app depuis le menu de ton profil Une fois connecté, tu pourras installer l'app depuis le menu de ton profil
</p> </p>
<button <button
v-if="deferredPrompt || isMobile" v-if="deferredPrompt"
@click="handleInstallApp" @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" 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"
> >
@@ -282,11 +275,11 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
<span>{{ deferredPrompt ? 'Installer l\'app maintenant' : 'Voir les instructions' }}</span> <span>Installer l'app maintenant</span>
</div> </div>
</button> </button>
<p v-else class="text-xs sm:text-sm text-gray-500"> <p v-else class="text-xs sm:text-sm text-gray-500">
Le bouton d'installation apparaîtra automatiquement quand tu seras connecté Voilà, c'est quand même mieux comme ça !
</p> </p>
</div> </div>
</div> </div>
@@ -339,16 +332,6 @@
</router-link> </router-link>
</p> </p>
</div> </div>
<!-- PWA Install Tutorial -->
<PWAInstallTutorial
:show="showPWAInstructions"
:is-ios="isIOS"
:is-android="isAndroid"
:is-windows="isWindows"
:is-mac="isMac"
@close="showPWAInstructions = false"
/>
</div> </div>
</template> </template>
@@ -359,7 +342,6 @@ import { useAuthStore } from '@/stores/auth'
import StepTransition from '@/components/StepTransition.vue' import StepTransition from '@/components/StepTransition.vue'
import PasswordStrength from '@/components/PasswordStrength.vue' import PasswordStrength from '@/components/PasswordStrength.vue'
import FormValidation from '@/components/FormValidation.vue' import FormValidation from '@/components/FormValidation.vue'
import PWAInstallTutorial from '@/components/PWAInstallTutorial.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
@@ -457,52 +439,33 @@ async function handleRegister() {
} }
// PWA Installation // 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) { function handleBeforeInstallPrompt(e) {
e.preventDefault() e.preventDefault()
deferredPrompt.value = e deferredPrompt.value = e
} }
async function handleInstallApp() { async function handleInstallApp() {
if (deferredPrompt.value) { if (!deferredPrompt.value) return
try {
deferredPrompt.value.prompt()
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') { try {
console.log('✅ PWA installée avec succès') deferredPrompt.value.prompt()
} const { outcome } = await deferredPrompt.value.userChoice
deferredPrompt.value = null if (outcome === 'accepted') {
} catch (error) { console.log('✅ PWA installée avec succès')
console.error('Erreur lors de l\'installation:', error)
} }
} else {
// Si pas de beforeinstallprompt, afficher les instructions (mobile ou desktop) deferredPrompt.value = null
showPWAInstructions.value = true } catch (error) {
console.error('Erreur lors de l\'installation:', error)
} }
} }
onMounted(() => { onMounted(() => {
checkIfMobile()
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('resize', checkIfMobile)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('resize', checkIfMobile)
}) })
</script> </script>

View File

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

View File

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

View File

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

View File

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