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:
runs-on: ubuntu-latest
container:
image: docker:24-dind
options: --privileged
image: catthehacker/ubuntu:act-latest
steps:
- name: Install git
run: apk add --no-cache git
- name: Checkout code
run: |
git clone --depth 1 --branch prod https://${{ secrets.REGISTRY_USERNAME }}:${{ secrets.REGISTRY_TOKEN }}@git.local.evan.casa/evan/lediscord.git .
uses: actions/checkout@v3
- 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: |
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: |
echo "Building backend image..."
docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend ./backend
for i in 1 2 3 4 5; do
docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend && break || sleep 10
done
- name: Build and push frontend
run: |
docker tag ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:latest-backend
echo "Pushing backend images..."
docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend
docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:latest-backend
echo "Building frontend image..."
docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend ./frontend
for i in 1 2 3 4 5; do
docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend && break || sleep 10
done
- name: Done
run: |
echo "🚀 Images pushed! Run: nomad job run -force lediscord.nomad"
docker tag ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:latest-frontend
echo "Pushing frontend images..."
docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend
docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:latest-frontend

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -150,5 +150,3 @@ function dismissPrompt() {
}
</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 { useRouter } from 'vue-router'
import axios from '@/utils/axios'
import { uploadFormData } from '@/utils/axios'
import { useAuthStore } from '@/stores/auth'
import { Plus, X, Send, Ticket } from 'lucide-vue-next'
import LoadingLogo from '@/components/LoadingLogo.vue'
@@ -190,7 +189,22 @@ async function submitTicket() {
// Debug: afficher les données envoyées
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 !')
closeModal()

View File

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

View File

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

View File

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

View File

@@ -1,82 +1,13 @@
import { useAuthStore } from '@/stores/auth'
import axios from '@/utils/axios'
// Utilitaire pour convertir la clé VAPID
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
class NotificationService {
constructor() {
this.pollingInterval = null
this.pollInterval = 30000 // 30 secondes
this.isPolling = false
this.vapidPublicKey = null
}
// Détecter iOS
isIOS() {
return /iPhone|iPad|iPod/i.test(navigator.userAgent)
}
// Vérifier si la PWA est installée (nécessaire pour iOS)
isPWAInstalled() {
return window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true ||
document.referrer.includes('android-app://')
}
// Vérifier si les notifications sont supportées sur cette plateforme
isNotificationSupported() {
if (!('Notification' in window)) {
console.warn('Notifications API not supported')
return false
}
if (!('serviceWorker' in navigator)) {
console.warn('Service Worker API not supported')
return false
}
if (!('PushManager' in window)) {
console.warn('Push API not supported')
return false
}
// Sur iOS, les notifications push ne fonctionnent que si la PWA est installée (iOS 16.4+)
if (this.isIOS()) {
if (!this.isPWAInstalled()) {
console.warn('iOS: Notifications push require PWA to be installed (added to home screen)')
return false
}
// Vérifier la version iOS (approximatif via user agent)
const iosVersion = navigator.userAgent.match(/OS (\d+)_(\d+)/)
if (iosVersion) {
const major = parseInt(iosVersion[1], 10)
const minor = parseInt(iosVersion[2], 10)
if (major < 16 || (major === 16 && minor < 4)) {
console.warn('iOS: Push notifications require iOS 16.4 or later')
return false
}
}
}
return true
}
startPolling() {
// Le polling est conservé comme fallback pour les notifications in-app
// ou si le push n'est pas supporté/activé
if (this.isPolling) return
const authStore = useAuthStore()
@@ -109,9 +40,26 @@ class NotificationService {
}
try {
// Juste mettre à jour le store (compteur, liste)
// Les notifications push sont gérées par le service worker
await authStore.fetchNotifications()
const result = await authStore.fetchNotifications()
// Si de nouvelles notifications non lues ont été détectées
if (result && result.hasNewNotifications && result.newCount > result.previousCount) {
// Trouver les nouvelles notifications non lues (les plus récentes en premier)
const newUnreadNotifications = authStore.notifications
.filter(n => !n.is_read)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, result.newCount - result.previousCount)
if (newUnreadNotifications.length > 0) {
// Afficher une notification push pour la plus récente
const latestNotification = newUnreadNotifications[0]
await this.showPushNotification(latestNotification.title, {
body: latestNotification.message,
link: latestNotification.link || '/',
data: { notificationId: latestNotification.id }
})
}
}
} catch (error) {
console.error('Error polling notifications:', error)
}
@@ -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
async requestNotificationPermission() {
console.log('🔔 requestNotificationPermission appelée')
if (!this.isNotificationSupported()) {
if (!('Notification' in window)) {
console.log('Ce navigateur ne supporte pas les notifications')
return false
}
if (Notification.permission === 'granted') {
console.log('✅ Notification permission already granted')
// S'assurer qu'on est bien abonné au push
this.subscribeToPush()
return true
}
if (Notification.permission === 'denied') {
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...')
if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission()
const granted = permission === 'granted'
if (granted) {
console.log('✅ Notification permission granted')
await this.subscribeToPush()
} else {
console.warn('❌ Notification permission denied:', permission)
}
return granted
} catch (error) {
console.error('❌ Error requesting notification permission:', error)
return false
return permission === 'granted'
}
return false
}
async showPushNotification(title, options = {}) {
// Cette méthode est maintenant principalement utilisée pour les tests
// ou les notifications locales générées par le client
if (!('Notification' in window)) return
if (!this.isNotificationSupported()) return null
if (Notification.permission !== 'granted') return null
const hasPermission = await this.requestNotificationPermission()
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) {
try {
const registration = await navigator.serviceWorker.ready
if (!registration.active) return null
await registration.showNotification(title, {
icon: '/icon-192x192.png',
badge: '/icon-96x96.png',
tag: 'lediscord-notification',
requireInteraction: false,
vibrate: [200, 100, 200],
...options
})
return true
return
} 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
@@ -254,29 +147,12 @@ class NotificationService {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'NOTIFICATION') {
// Notification reçue via message (fallback)
console.log('Notification message received:', event.data)
const { title, options } = 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()

View File

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

View File

@@ -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
:src="getMediaUrl(selectedTicket.screenshot_path)"
: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)"
@error="(e) => console.error('Image load error:', e.target.src)"
>
<p class="text-xs text-gray-500 mt-2">Cliquez sur l'image pour l'agrandir</p>
</div>
@@ -1010,7 +1009,7 @@
>
<div
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"
>
<div class="relative max-w-4xl max-h-[90vh]">
@@ -1240,7 +1239,8 @@ import {
TestTube,
Plus
} 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'
const authStore = useAuthStore()
@@ -1710,7 +1710,7 @@ function getPriorityBadgeClass(priority) {
}
function formatDate(date) {
return formatRelativeDateInFrenchTimezone(date)
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
}
// Gestion des tickets
@@ -1850,11 +1850,7 @@ function openImageModal(imageUrl) {
function getMediaUrl(path) {
if (!path) return ''
if (path.startsWith('http')) return path
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8002'
// Ensure path starts with /
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${baseUrl}${normalizedPath}`
return path.startsWith('http') ? path : `${import.meta.env.VITE_API_URL || 'http://localhost:8002'}${path}`
}
// Nouvelles fonctions pour les filtres et actions rapides

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -140,7 +140,7 @@
<img
:src="getMediaUrl(ticket.screenshot_path)"
: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)"
>
</div>
@@ -306,7 +306,8 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
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 axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
@@ -418,7 +419,7 @@ function getPriorityBadgeClass(priority) {
}
function formatDate(date) {
return formatRelativeDateInFrenchTimezone(date)
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
}
function resetTicketForm() {

View File

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

View File

@@ -159,7 +159,8 @@ import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification'
import axios 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'
const authStore = useAuthStore()
@@ -178,7 +179,7 @@ const form = ref({
function formatDate(date) {
if (!date) return ''
return formatDateInFrenchTimezone(date, 'MMMM yyyy')
return format(new Date(date), 'MMMM yyyy', { locale: fr })
}
function resetForm() {

View File

@@ -68,10 +68,7 @@
>
</div>
<div>
<label for="username" class="label text-sm sm:text-base">
Identifiant unique
<span class="text-xs text-gray-500 font-normal ml-1">(non modifiable)</span>
</label>
<label for="username" class="label text-sm sm:text-base">Nom d'utilisateur</label>
<input
id="username"
v-model="form.username"
@@ -79,26 +76,22 @@
required
minlength="3"
class="input text-sm sm:text-base"
placeholder="mon_pseudo"
placeholder="nom_utilisateur"
@blur="touchedFields.username = true"
>
</div>
</div>
<div>
<label for="full_name" class="label text-sm sm:text-base">
Pseudo affiché
<span class="text-xs text-gray-500 font-normal ml-1">(modifiable plus tard)</span>
</label>
<label for="full_name" class="label text-sm sm:text-base">Nom complet</label>
<input
id="full_name"
v-model="form.full_name"
type="text"
required
class="input text-sm sm:text-base"
placeholder="Ton surnom / prénom"
placeholder="Prénom Nom"
@blur="touchedFields.full_name = true"
>
<p class="text-xs text-gray-500 mt-1">C'est ce qui sera affiché aux autres membres</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
<div>
@@ -274,7 +267,7 @@
Une fois connecté, tu pourras installer l'app depuis le menu de ton profil
</p>
<button
v-if="deferredPrompt || isMobile"
v-if="deferredPrompt"
@click="handleInstallApp"
class="w-full sm:w-auto bg-primary-600 hover:bg-primary-700 text-white font-medium py-3 px-6 rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105 text-sm sm:text-base"
>
@@ -282,11 +275,11 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span>{{ deferredPrompt ? 'Installer l\'app maintenant' : 'Voir les instructions' }}</span>
<span>Installer l'app maintenant</span>
</div>
</button>
<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>
</div>
</div>
@@ -339,16 +332,6 @@
</router-link>
</p>
</div>
<!-- PWA Install Tutorial -->
<PWAInstallTutorial
:show="showPWAInstructions"
:is-ios="isIOS"
:is-android="isAndroid"
:is-windows="isWindows"
:is-mac="isMac"
@close="showPWAInstructions = false"
/>
</div>
</template>
@@ -359,7 +342,6 @@ import { useAuthStore } from '@/stores/auth'
import StepTransition from '@/components/StepTransition.vue'
import PasswordStrength from '@/components/PasswordStrength.vue'
import FormValidation from '@/components/FormValidation.vue'
import PWAInstallTutorial from '@/components/PWAInstallTutorial.vue'
const authStore = useAuthStore()
const router = useRouter()
@@ -457,52 +439,33 @@ async function handleRegister() {
}
// PWA Installation
const isMobile = ref(false)
const showPWAInstructions = ref(false)
const isIOS = computed(() => /iPhone|iPad|iPod/i.test(navigator.userAgent))
const isAndroid = computed(() => /Android/i.test(navigator.userAgent))
const isWindows = computed(() => /Windows/i.test(navigator.userAgent))
const isMac = computed(() => /Macintosh|Mac OS/i.test(navigator.userAgent) && !isIOS.value)
function checkIfMobile() {
isMobile.value = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ||
window.innerWidth < 768
}
function handleBeforeInstallPrompt(e) {
e.preventDefault()
deferredPrompt.value = e
}
async function handleInstallApp() {
if (deferredPrompt.value) {
try {
deferredPrompt.value.prompt()
const { outcome } = await deferredPrompt.value.userChoice
if (!deferredPrompt.value) return
if (outcome === 'accepted') {
console.log('✅ PWA installée avec succès')
}
try {
deferredPrompt.value.prompt()
const { outcome } = await deferredPrompt.value.userChoice
deferredPrompt.value = null
} catch (error) {
console.error('Erreur lors de l\'installation:', error)
if (outcome === 'accepted') {
console.log('✅ PWA installée avec succès')
}
} else {
// Si pas de beforeinstallprompt, afficher les instructions (mobile ou desktop)
showPWAInstructions.value = true
deferredPrompt.value = null
} catch (error) {
console.error('Erreur lors de l\'installation:', error)
}
}
onMounted(() => {
checkIfMobile()
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('resize', checkIfMobile)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('resize', checkIfMobile)
})
</script>

View File

@@ -123,7 +123,8 @@ import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification'
import axios 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 LoadingLogo from '@/components/LoadingLogo.vue'
@@ -139,12 +140,12 @@ const recentActivity = ref([])
function formatDate(date) {
if (!date) return ''
return formatDateInFrenchTimezone(date, 'MMMM yyyy')
return format(new Date(date), 'MMMM yyyy', { locale: fr })
}
function formatRelativeDate(date) {
if (!date) return ''
return formatRelativeDateInFrenchTimezone(date)
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
}
function getActivityIcon(type) {

View File

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

View File

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

View File

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