Compare commits
9 Commits
develop
...
e0d2db3c42
| Author | SHA1 | Date | |
|---|---|---|---|
| e0d2db3c42 | |||
| 96c321e108 | |||
| d9ab8230a6 | |||
|
|
87de65e6b2 | ||
|
|
7325f25b5f | ||
|
|
362b19fd66 | ||
|
|
7f8897d76d | ||
|
|
2bd07d09d3 | ||
|
|
d3446aa428 |
@@ -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
|
||||
@@ -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=*"]
|
||||
|
||||
@@ -135,5 +135,3 @@ backend/
|
||||
- [Documentation Alembic](https://alembic.sqlalchemy.org/)
|
||||
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -113,5 +113,3 @@ formatter = generic
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,9 +254,6 @@ 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(
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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`
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -25,5 +25,3 @@ def upgrade() -> None:
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -13,18 +13,13 @@ 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:
|
||||
# ### 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
|
||||
@@ -34,13 +29,7 @@ def upgrade() -> None:
|
||||
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')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -40,5 +40,3 @@ case "$1" in
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && mkdir -p /etc/nginx/conf.d /run/nginx
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -35,17 +35,8 @@ 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; \
|
||||
RUN echo 'server { \
|
||||
listen 8080; \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
location / { \
|
||||
@@ -55,20 +46,22 @@ http { \
|
||||
expires 1y; \
|
||||
add_header Cache-Control "public, immutable"; \
|
||||
} \
|
||||
} \
|
||||
}' > /etc/nginx/nginx.conf
|
||||
}' > /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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -78,5 +78,3 @@
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -150,5 +150,3 @@ function dismissPrompt() {
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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,11 +359,10 @@ function handleBeforeInstallPrompt(e) {
|
||||
}
|
||||
|
||||
async function handleInstallApp() {
|
||||
if (isPWAInstalled.value) {
|
||||
if (!deferredPrompt.value || isPWAInstalled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (deferredPrompt.value) {
|
||||
try {
|
||||
showUserMenu.value = false
|
||||
|
||||
@@ -414,54 +385,22 @@ async function handleInstallApp() {
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'installation:', error)
|
||||
}
|
||||
} else if (isMobile.value) {
|
||||
// Sur mobile sans beforeinstallprompt, afficher le tutoriel
|
||||
showUserMenu.value = false
|
||||
showPWAInstructions.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
checkIfMobile()
|
||||
|
||||
// Restaurer la session si un token existe
|
||||
if (authStore.token) {
|
||||
await authStore.fetchCurrentUser()
|
||||
}
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
await fetchNotifications()
|
||||
await authStore.fetchUnreadCount()
|
||||
|
||||
// Démarrer le polling des notifications
|
||||
try {
|
||||
const notificationService = (await import('@/services/notificationService')).default
|
||||
notificationService.startPolling()
|
||||
notificationService.setupServiceWorkerListener()
|
||||
|
||||
// Demander la permission pour les notifications push
|
||||
// Sur iOS, attendre un peu pour s'assurer que la PWA est bien détectée
|
||||
if (typeof notificationService.isIOS === 'function' && notificationService.isIOS()) {
|
||||
console.log('🍎 iOS détecté, vérification de la PWA...')
|
||||
// Attendre que la page soit complètement chargée et que la PWA soit détectée
|
||||
setTimeout(async () => {
|
||||
const isInstalled = notificationService.isPWAInstalled()
|
||||
console.log('🍎 PWA installée:', isInstalled)
|
||||
|
||||
if (isInstalled) {
|
||||
console.log('🍎 Demande de permission pour les notifications...')
|
||||
await notificationService.requestNotificationPermission()
|
||||
} else {
|
||||
console.warn('🍎 PWA non installée - Les notifications ne fonctionneront pas sur iOS')
|
||||
console.warn('🍎 Instructions: Ajouter l\'app à l\'écran d\'accueil, puis l\'ouvrir depuis l\'écran d\'accueil')
|
||||
}
|
||||
}, 2000) // Attendre 2 secondes pour iOS
|
||||
} else {
|
||||
await notificationService.requestNotificationPermission()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement du service de notifications:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier si PWA est installée
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -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')
|
||||
|
||||
@@ -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 permission === 'granted'
|
||||
}
|
||||
|
||||
return granted
|
||||
} catch (error) {
|
||||
console.error('❌ Error requesting notification permission:', error)
|
||||
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()
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,26 +439,14 @@ 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) {
|
||||
if (!deferredPrompt.value) return
|
||||
|
||||
try {
|
||||
deferredPrompt.value.prompt()
|
||||
const { outcome } = await deferredPrompt.value.userChoice
|
||||
@@ -489,20 +459,13 @@ async function handleInstallApp() {
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'installation:', error)
|
||||
}
|
||||
} else {
|
||||
// Si pas de beforeinstallprompt, afficher les instructions (mobile ou desktop)
|
||||
showPWAInstructions.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkIfMobile()
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.addEventListener('resize', checkIfMobile)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.removeEventListener('resize', checkIfMobile)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user