fix
This commit is contained in:
@@ -6,18 +6,63 @@ 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(current_user: User = Depends(get_current_active_user)):
|
||||
async def get_vapid_public_key_endpoint(current_user: User = Depends(get_current_active_user)):
|
||||
"""Get the VAPID public key for push notifications."""
|
||||
if not settings.VAPID_PUBLIC_KEY:
|
||||
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": settings.VAPID_PUBLIC_KEY}
|
||||
return {"public_key": public_key}
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe_push(
|
||||
@@ -66,3 +111,60 @@ async def unsubscribe_push(
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -203,6 +203,21 @@ app.add_middleware(
|
||||
expose_headers=["Content-Range", "Accept-Ranges"],
|
||||
)
|
||||
|
||||
|
||||
# Middleware de debug pour les requêtes POST (à désactiver en production stable)
|
||||
@app.middleware("http")
|
||||
async def debug_auth_middleware(request: Request, call_next):
|
||||
"""Log les informations d'authentification pour debug."""
|
||||
if request.method == "POST" and "/api/" in request.url.path:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
has_auth = bool(auth_header)
|
||||
auth_preview = auth_header[:30] + "..." if len(auth_header) > 30 else auth_header
|
||||
print(f"🔍 DEBUG POST {request.url.path}: auth_header={'present' if has_auth else 'MISSING'}, preview={auth_preview}")
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
|
||||
# Endpoint personnalisé pour servir les vidéos avec support Range
|
||||
@app.get("/uploads/{file_path:path}")
|
||||
async def serve_media_with_range(request: Request, file_path: str):
|
||||
|
||||
70
backend/scripts/generate_vapid_keys.py
Normal file
70
backend/scripts/generate_vapid_keys.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script pour générer des clés VAPID pour les notifications push.
|
||||
|
||||
Usage:
|
||||
python scripts/generate_vapid_keys.py
|
||||
|
||||
Les clés générées doivent être ajoutées aux variables d'environnement:
|
||||
VAPID_PUBLIC_KEY=<clé_publique>
|
||||
VAPID_PRIVATE_KEY=<clé_privée>
|
||||
VAPID_CLAIMS_EMAIL=mailto:admin@example.com
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
except ImportError:
|
||||
print("❌ cryptography n'est pas installé.")
|
||||
print(" Installez-le avec: pip install cryptography")
|
||||
exit(1)
|
||||
|
||||
|
||||
def generate_vapid_keys():
|
||||
"""Génère une paire de clés VAPID au format base64url."""
|
||||
# Générer une clé privée EC P-256
|
||||
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
|
||||
# Extraire la clé privée brute (32 octets)
|
||||
private_numbers = private_key.private_numbers()
|
||||
private_bytes = private_numbers.private_value.to_bytes(32, byteorder='big')
|
||||
private_key_b64 = base64.urlsafe_b64encode(private_bytes).rstrip(b'=').decode('ascii')
|
||||
|
||||
# Extraire la clé publique non compressée (65 octets = 0x04 + X + Y)
|
||||
public_key = private_key.public_key()
|
||||
public_bytes = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
public_key_b64 = base64.urlsafe_b64encode(public_bytes).rstrip(b'=').decode('ascii')
|
||||
|
||||
return public_key_b64, private_key_b64
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🔐 Génération des clés VAPID pour Web Push...\n")
|
||||
|
||||
public_key, private_key = generate_vapid_keys()
|
||||
|
||||
print("=" * 70)
|
||||
print("Clés VAPID générées avec succès !")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("Ajoutez ces variables à votre fichier .env ou configuration:")
|
||||
print()
|
||||
print(f'VAPID_PUBLIC_KEY={public_key}')
|
||||
print()
|
||||
print(f'VAPID_PRIVATE_KEY={private_key}')
|
||||
print()
|
||||
print('VAPID_CLAIMS_EMAIL=mailto:admin@lediscord.com')
|
||||
print()
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("⚠️ IMPORTANT:")
|
||||
print(" - Gardez la clé privée SECRÈTE")
|
||||
print(" - Les clés doivent être les mêmes en dev et prod")
|
||||
print(" - Après changement de clés, les utilisateurs devront se réabonner")
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from config.settings import settings
|
||||
@@ -11,7 +11,9 @@ from models.user import User
|
||||
from schemas.user import TokenData
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
# OAuth2 scheme avec auto_error=False pour pouvoir logger les erreurs
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a plain password against a hashed password."""
|
||||
@@ -45,17 +47,42 @@ def verify_token(token: str, credentials_exception) -> TokenData:
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
|
||||
async def get_current_user(
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""Get the current authenticated user."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Log si pas de token
|
||||
if token is None:
|
||||
print("❌ AUTH: No token provided in Authorization header")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated - no token provided",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Log le token (premiers caractères seulement pour la sécurité)
|
||||
print(f"🔐 AUTH: Token received, length={len(token)}, preview={token[:20]}...")
|
||||
|
||||
try:
|
||||
token_data = verify_token(token, credentials_exception)
|
||||
print(f"✅ AUTH: Token valid for user_id={token_data.user_id}")
|
||||
except HTTPException as e:
|
||||
print(f"❌ AUTH: Token validation failed - {e.detail}")
|
||||
raise
|
||||
|
||||
user = db.query(User).filter(User.id == token_data.user_id).first()
|
||||
if user is None:
|
||||
print(f"❌ AUTH: User not found for id={token_data.user_id}")
|
||||
raise credentials_exception
|
||||
|
||||
print(f"✅ AUTH: User authenticated: {user.username}")
|
||||
return user
|
||||
|
||||
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -28,56 +28,58 @@
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="px-6 py-6">
|
||||
<div v-if="isIOS" class="space-y-6">
|
||||
<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-4">
|
||||
<div class="flex items-start space-x-3 sm:space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||
<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-1">
|
||||
<h4 class="font-semibold text-gray-900 mb-2">Appuyez sur le bouton de partage</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
En bas de l'écran, dans la barre d'outils Safari
|
||||
<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-gray-50 rounded-lg p-4 border-2 border-dashed border-gray-200">
|
||||
<div class="flex items-center justify-center space-x-2">
|
||||
<div class="w-12 h-12 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
<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>
|
||||
<ArrowDown class="w-5 h-5 text-gray-400" />
|
||||
<p class="text-xs text-gray-500">Bouton de partage</p>
|
||||
<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-4">
|
||||
<div class="flex items-start space-x-3 sm:space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||
<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-1">
|
||||
<h4 class="font-semibold text-gray-900 mb-2">Sélectionnez "Sur l'écran d'accueil"</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Faites défiler le menu de partage vers le haut
|
||||
<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-4 border-2 border-primary-200">
|
||||
<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-6 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 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
<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-sm">Sur l'écran d'accueil</p>
|
||||
<p class="text-xs text-primary-600">Ajouter à l'écran d'accueil</p>
|
||||
<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>
|
||||
@@ -85,97 +87,138 @@
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex items-start space-x-3 sm:space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||
<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-1">
|
||||
<h4 class="font-semibold text-gray-900 mb-2">Confirmez l'installation</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Appuyez sur "Ajouter" dans la popup de confirmation
|
||||
<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-4 border-2 border-green-200">
|
||||
<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-6 h-6 text-green-600" />
|
||||
<p class="text-sm text-green-700 font-medium">L'application apparaîtra sur votre écran d'accueil !</p>
|
||||
<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>
|
||||
|
||||
<!-- Android -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Desktop Instructions (Windows/Mac) -->
|
||||
<div v-else class="space-y-4 sm:space-y-6">
|
||||
<!-- Step 1 -->
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex items-start space-x-3 sm:space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||
<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-1">
|
||||
<h4 class="font-semibold text-gray-900 mb-2">Appuyez sur le menu</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
En haut à droite de votre navigateur (⋮)
|
||||
<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 class="bg-gray-50 rounded-lg p-4 border-2 border-dashed border-gray-200">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-12 h-12 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex items-start space-x-3 sm:space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||
<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-1">
|
||||
<h4 class="font-semibold text-gray-900 mb-2">Sélectionnez "Ajouter à l'écran d'accueil"</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Ou "Installer l'application" selon votre navigateur
|
||||
</p>
|
||||
<div class="bg-gradient-to-br from-primary-50 to-purple-50 rounded-lg p-4 border-2 border-primary-200">
|
||||
<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">
|
||||
<div class="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 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 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold text-primary-700 text-sm">Ajouter à l'écran d'accueil</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||
3
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 pt-1">
|
||||
<h4 class="font-semibold text-gray-900 mb-2">Confirmez l'installation</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Appuyez sur "Ajouter" ou "Installer" dans la popup
|
||||
</p>
|
||||
<div class="bg-green-50 rounded-lg p-4 border-2 border-green-200">
|
||||
<div class="flex items-center space-x-3">
|
||||
<CheckCircle class="w-6 h-6 text-green-600" />
|
||||
<p class="text-sm text-green-700 font-medium">L'application sera installée !</p>
|
||||
</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>
|
||||
@@ -231,11 +274,31 @@ const props = defineProps({
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -123,23 +123,28 @@ instance.interceptors.response.use(
|
||||
}
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
// Ne pas déconnecter automatiquement sur les requêtes POST/PUT avec FormData
|
||||
// car les erreurs peuvent être dues à des problèmes réseau temporaires sur mobile
|
||||
const isFormDataUpload = error.config?.data instanceof FormData ||
|
||||
(error.config?.method === 'POST' || error.config?.method === 'PUT')
|
||||
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')) {
|
||||
// Pour les uploads, ne déconnecter que si c'est vraiment une erreur d'authentification
|
||||
// (pas juste une erreur réseau qui se manifeste comme 401)
|
||||
if (!isFormDataUpload || error.response?.data?.detail?.includes('credentials') || error.response?.data?.detail?.includes('token')) {
|
||||
if (isRealAuthError) {
|
||||
localStorage.removeItem('token')
|
||||
router.push('/login')
|
||||
toast.error('Session expirée, veuillez vous reconnecter')
|
||||
} else {
|
||||
// Pour les uploads, juste afficher une erreur sans déconnecter
|
||||
toast.error('Erreur lors de l\'upload. Veuillez réessayer.')
|
||||
// Erreur 401 non liée à l'auth (rare mais possible)
|
||||
toast.error('Erreur d\'autorisation')
|
||||
}
|
||||
}
|
||||
} else if (error.response?.status === 403) {
|
||||
@@ -210,27 +215,145 @@ export function getAppUrl() {
|
||||
return import.meta.env.VITE_APP_URL || window.location.origin
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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
|
||||
body: formData,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
console.log(`📥 Upload response status: ${response.status}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
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
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,8 +950,9 @@
|
||||
<img
|
||||
:src="getMediaUrl(selectedTicket.screenshot_path)"
|
||||
:alt="'Screenshot du ticket ' + selectedTicket.title"
|
||||
class="max-w-full h-auto rounded-lg shadow-sm ticket-screenshot cursor-pointer"
|
||||
class="w-full sm:max-w-2xl h-auto rounded-lg shadow-sm ticket-screenshot cursor-pointer"
|
||||
@click="openImageModal(selectedTicket.screenshot_path)"
|
||||
@error="(e) => console.error('Image load error:', e.target.src)"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">Cliquez sur l'image pour l'agrandir</p>
|
||||
</div>
|
||||
@@ -1009,7 +1010,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="showImageModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-90 z-60 flex items-center justify-center p-4"
|
||||
class="fixed inset-0 bg-black bg-opacity-90 z-[70] flex items-center justify-center p-4"
|
||||
@click="showImageModal = false"
|
||||
>
|
||||
<div class="relative max-w-4xl max-h-[90vh]">
|
||||
@@ -1849,7 +1850,11 @@ function openImageModal(imageUrl) {
|
||||
|
||||
function getMediaUrl(path) {
|
||||
if (!path) return ''
|
||||
return path.startsWith('http') ? path : `${import.meta.env.VITE_API_URL || 'http://localhost:8002'}${path}`
|
||||
if (path.startsWith('http')) return path
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8002'
|
||||
// Ensure path starts with /
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
return `${baseUrl}${normalizedPath}`
|
||||
}
|
||||
|
||||
// Nouvelles fonctions pour les filtres et actions rapides
|
||||
|
||||
@@ -475,6 +475,17 @@
|
||||
<X class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<!-- Download Button -->
|
||||
<a
|
||||
:href="getMediaUrl(selectedMedia.file_path)"
|
||||
:download="selectedMedia.caption || 'media'"
|
||||
target="_blank"
|
||||
class="absolute top-6 right-20 z-10 bg-black bg-opacity-70 text-white rounded-full w-12 h-12 flex items-center justify-center hover:bg-opacity-90 transition-colors shadow-lg"
|
||||
title="Télécharger"
|
||||
>
|
||||
<Download class="w-6 h-6" />
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
@@ -582,7 +593,8 @@ import {
|
||||
Eye,
|
||||
Heart,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
ChevronRight,
|
||||
Download
|
||||
} from 'lucide-vue-next'
|
||||
import LoadingLogo from '@/components/LoadingLogo.vue'
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from '@/utils/axios'
|
||||
import axios, { postJson } from '@/utils/axios'
|
||||
import { getMediaUrl, uploadFormData } from '@/utils/axios'
|
||||
import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||
import {
|
||||
@@ -630,8 +630,7 @@ async function createAlbum() {
|
||||
}
|
||||
|
||||
uploadStatus.value = 'Création de l\'album...'
|
||||
const albumResponse = await axios.post('/api/albums', albumData)
|
||||
const album = albumResponse.data
|
||||
const album = await postJson('/api/albums', albumData)
|
||||
|
||||
// Upload media files in batches for better performance
|
||||
const batchSize = 5 // Upload 5 files at a time
|
||||
|
||||
@@ -221,23 +221,25 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">Date et heure</label>
|
||||
<label class="label text-sm">Date et heure</label>
|
||||
<input
|
||||
v-model="newEvent.date"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="input"
|
||||
class="input text-sm w-full"
|
||||
style="min-width: 0;"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Date de fin (optionnel)</label>
|
||||
<label class="label text-sm">Date de fin (optionnel)</label>
|
||||
<input
|
||||
v-model="newEvent.end_date"
|
||||
type="datetime-local"
|
||||
class="input"
|
||||
class="input text-sm w-full"
|
||||
style="min-width: 0;"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,7 +333,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from '@/utils/axios'
|
||||
import axios, { postJson } from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone, convertFrenchTimezoneToUTC } from '@/utils/dateUtils'
|
||||
import {
|
||||
@@ -496,11 +498,11 @@ async function createEvent() {
|
||||
}
|
||||
|
||||
console.log('🔵 eventData préparé:', eventData)
|
||||
console.log('🔵 Envoi de la requête axios.post...')
|
||||
console.log('🔵 Envoi de la requête postJson...')
|
||||
|
||||
const response = await axios.post('/api/events', eventData)
|
||||
const data = await postJson('/api/events', eventData)
|
||||
|
||||
console.log('✅ Réponse reçue:', response)
|
||||
console.log('✅ Réponse reçue:', data)
|
||||
|
||||
// Refresh events list
|
||||
await fetchEvents()
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
<img
|
||||
:src="getMediaUrl(ticket.screenshot_path)"
|
||||
:alt="ticket.title"
|
||||
class="max-w-md rounded-lg border border-gray-200 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
class="w-full sm:max-w-md rounded-lg border border-gray-200 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
@click="viewScreenshot(ticket.screenshot_path)"
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -288,7 +288,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import axios, { postJson } from '@/utils/axios'
|
||||
import { getMediaUrl, uploadFormData } from '@/utils/axios'
|
||||
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||
import {
|
||||
@@ -355,14 +355,14 @@ async function createPost() {
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const response = await axios.post('/api/posts', {
|
||||
const data = await postJson('/api/posts', {
|
||||
content: newPost.value.content,
|
||||
image_url: newPost.value.image_url,
|
||||
mentioned_user_ids: newPost.value.mentioned_user_ids
|
||||
})
|
||||
|
||||
// Add new post to the beginning of the list
|
||||
posts.value.unshift(response.data)
|
||||
posts.value.unshift(data)
|
||||
|
||||
// Forcer le rafraîchissement de la date immédiatement
|
||||
dateRefreshKey.value++
|
||||
|
||||
@@ -68,7 +68,10 @@
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="username" class="label text-sm sm:text-base">Nom d'utilisateur</label>
|
||||
<label for="username" class="label text-sm sm:text-base">
|
||||
Identifiant unique
|
||||
<span class="text-xs text-gray-500 font-normal ml-1">(non modifiable)</span>
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
@@ -76,22 +79,26 @@
|
||||
required
|
||||
minlength="3"
|
||||
class="input text-sm sm:text-base"
|
||||
placeholder="nom_utilisateur"
|
||||
placeholder="mon_pseudo"
|
||||
@blur="touchedFields.username = true"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="full_name" class="label text-sm sm:text-base">Nom complet</label>
|
||||
<label for="full_name" class="label text-sm sm:text-base">
|
||||
Pseudo affiché
|
||||
<span class="text-xs text-gray-500 font-normal ml-1">(modifiable plus tard)</span>
|
||||
</label>
|
||||
<input
|
||||
id="full_name"
|
||||
v-model="form.full_name"
|
||||
type="text"
|
||||
required
|
||||
class="input text-sm sm:text-base"
|
||||
placeholder="Prénom Nom"
|
||||
placeholder="Ton surnom / prénom"
|
||||
@blur="touchedFields.full_name = true"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1">C'est ce qui sera affiché aux autres membres</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div>
|
||||
@@ -337,6 +344,9 @@
|
||||
<PWAInstallTutorial
|
||||
:show="showPWAInstructions"
|
||||
:is-ios="isIOS"
|
||||
:is-android="isAndroid"
|
||||
:is-windows="isWindows"
|
||||
:is-mac="isMac"
|
||||
@close="showPWAInstructions = false"
|
||||
/>
|
||||
</div>
|
||||
@@ -451,6 +461,9 @@ 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) ||
|
||||
@@ -476,8 +489,8 @@ async function handleInstallApp() {
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'installation:', error)
|
||||
}
|
||||
} else if (isMobile.value) {
|
||||
// Sur mobile sans beforeinstallprompt, afficher les instructions
|
||||
} else {
|
||||
// Si pas de beforeinstallprompt, afficher les instructions (mobile ou desktop)
|
||||
showPWAInstructions.value = true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user