38 Commits
main ... prod

Author SHA1 Message Date
EvanChal
7ca168c34c fix
All checks were successful
Deploy to Development / build-and-deploy (push) Successful in 22s
Deploy to Production / build-and-deploy (push) Successful in 4s
2026-01-28 22:31:17 +01:00
EvanChal
8ff0f22682 fix(bcrypt version)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 49s
2026-01-28 20:59:58 +01:00
EvanChal
0eea2f1a1d fix(alembic-migration)
All checks were successful
Deploy to Development / build-and-deploy (push) Successful in 6s
Deploy to Production / build-and-deploy (push) Successful in 4s
2026-01-28 20:53:53 +01:00
EvanChal
d68af8d5a1 fix(dockerfile)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 33s
2026-01-28 20:47:34 +01:00
EvanChal
97ae75c9bf fix(requirement): alemic was missing
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m23s
2026-01-27 02:53:57 +01:00
EvanChal
f33dfd5ab7 fix(notification+vlog upload)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 46s
Deploy to Production / build-and-deploy (push) Successful in 1m47s
2026-01-27 02:39:51 +01:00
EvanChal
658b7a9dda fix(front): try to correct crash on ios
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m39s
2026-01-26 23:03:49 +01:00
EvanChal
08810440e0 fix(date): correction on date utils
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m51s
2026-01-26 22:43:35 +01:00
EvanChal
d63f2f9f51 fix(pwa): added PWAInstallTutorial to handle pwa install instructions
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 36s
Deploy to Production / build-and-deploy (push) Successful in 1m48s
2026-01-26 22:08:57 +01:00
EvanChal
02a54f5625 fix(pwa+timezone)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m32s
2026-01-25 23:54:42 +01:00
EvanChal
e32b1ce04e fix(pwa)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 54s
2026-01-25 23:24:25 +01:00
EvanChal
f63b204c5c fix(migration)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 29s
2026-01-25 23:14:26 +01:00
EvanChal
127aef60e3 fix(migration)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m3s
2026-01-25 23:07:42 +01:00
EvanChal
cb11a74d33 fix(dockefile)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 48s
2026-01-25 23:04:14 +01:00
EvanChal
a3a1d3306d fix(dockerfile-fontend)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 8s
Deploy to Production / build-and-deploy (push) Successful in 1m38s
2026-01-25 22:52:08 +01:00
EvanChal
4756c34fd1 fix(dockerfile-fontend)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m46s
2026-01-25 22:47:36 +01:00
EvanChal
2b172c5d34 fix(ci/cd)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Successful in 1m48s
2026-01-25 22:43:37 +01:00
EvanChal
e4b9e354ef fix(ci/cd)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 8s
Deploy to Production / build-and-deploy (push) Failing after 2s
2026-01-25 22:42:38 +01:00
EvanChal
634b850a89 fix(ci/cd)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Failing after 1s
2026-01-25 22:41:28 +01:00
EvanChal
69a07c9e07 fix(ci/cd)
Some checks failed
Deploy to Production / build-and-deploy (push) Waiting to run
Deploy to Development / build-and-deploy (push) Has been cancelled
2026-01-25 22:39:07 +01:00
EvanChal
8f8e1046c1 fix(ci/cd)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Has been cancelled
2026-01-25 22:34:09 +01:00
EvanChal
d1bfa488a6 fix(ci/cd)
Some checks failed
Deploy to Production / test (push) Waiting to run
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / build-and-deploy (push) Has been cancelled
2026-01-25 22:32:07 +01:00
EvanChal
ec30db4c2d fix(ci/cd)
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
Deploy to Production / test (push) Failing after 0s
Deploy to Production / build-and-deploy (push) Has been skipped
2026-01-25 22:25:55 +01:00
EvanChal
a47a70e608 fix(dockerfile)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 14s
2026-01-25 22:22:49 +01:00
EvanChal
dfeaecce73 fix+feat(everything): lot of things
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 20s
2026-01-25 22:14:48 +01:00
EvanChal
5bbe05000e feat(front+back): pwa added, register parkour update with it, and jeux added in coming soon
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 20s
2026-01-25 19:28:21 +01:00
EvanChal
0020c13bfd fix(video-player): fix the video player to permit the nagivation through the video (it was because de fast api server refused range request)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 2m15s
2026-01-25 18:08:38 +01:00
EvanChal
3fbf372dae update ci/cd 11
Some checks failed
Deploy to Development / build-and-deploy (push) Successful in 25s
Deploy to Production / test (push) Waiting to run
Deploy to Production / build-and-deploy (push) Has been cancelled
2025-12-24 00:31:13 +01:00
EvanChal
480ae1e40e update ci/cd 10
All checks were successful
Deploy to Development / build-and-deploy (push) Successful in 15s
2025-12-24 00:29:01 +01:00
EvanChal
857b136124 update ci/cd 9
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 14s
2025-12-24 00:25:59 +01:00
EvanChal
93c9935465 update ci/cd 8
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 16s
2025-12-24 00:19:53 +01:00
EvanChal
7cd468a859 update ci/cd 7
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 1m40s
2025-12-24 00:14:56 +01:00
EvanChal
eebd26bb7d update ci/cd 6
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 2s
2025-12-24 00:13:54 +01:00
EvanChal
414eb824f8 update ci/cd 5
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 2s
2025-12-24 00:12:05 +01:00
EvanChal
2319a8c0cb update ci/cd 4
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 4s
2025-12-24 00:10:29 +01:00
EvanChal
6cf379f01b update ci/cd 3
Some checks failed
Deploy to Development / build-and-deploy (push) Has been cancelled
2025-12-24 00:00:54 +01:00
EvanChal
b436e19087 update ci/cd 2
Some checks are pending
Deploy to Development / build-and-deploy (push) Waiting to run
2025-12-23 23:28:28 +01:00
EvanChal
ae3821a68e update ci/cd
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 9s
2025-12-23 23:25:15 +01:00
87 changed files with 26664 additions and 803 deletions

View File

@@ -8,49 +8,23 @@ on:
jobs: jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up Docker Buildx - name: Login to Gitea Registry
uses: docker/setup-buildx-action@v2 run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ secrets.REGISTRY_URL }} -u ${{ secrets.REGISTRY_USERNAME }} --password-stdin
- name: Login to Gitea Container Registry - name: Build and push
uses: docker/login-action@v2 run: |
with: echo "Building backend image..."
registry: ${{ secrets.GITEA_REGISTRY }} docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:develop-backend ./backend
username: ${{ secrets.GITEA_USERNAME }} echo "Pushing backend image..."
password: ${{ secrets.GITEA_TOKEN }} docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:develop-backend
echo "Building frontend image..."
- name: Build and push backend image docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:develop-frontend ./frontend
uses: docker/build-push-action@v4 echo "Pushing frontend image..."
with: docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:develop-frontend
context: ./backend
file: ./backend/Dockerfile
push: true
tags: ${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-backend:develop
cache-from: type=registry,ref=${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-backend:develop
cache-to: type=inline
- name: Build and push frontend image
uses: docker/build-push-action@v4
with:
context: ./frontend
file: ./frontend/Dockerfile
push: true
tags: ${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-frontend:develop
cache-from: type=registry,ref=${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-frontend:develop
cache-to: type=inline
- name: Deploy to development server
uses: appleboy/ssh-action@v0.1.7
with:
host: ${{ secrets.DEV_HOST }}
username: ${{ secrets.DEV_USERNAME }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
cd /path/to/lediscord
docker-compose pull
docker-compose up -d --build
docker-compose restart

View File

@@ -6,104 +6,38 @@ on:
- prod - prod
jobs: jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: lediscord_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install backend dependencies
run: |
cd backend
pip install -r requirements.txt
- name: Run backend tests
run: |
cd backend
python -m pytest tests/ -v || true
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/lediscord_test
JWT_SECRET_KEY: test-secret-key
CORS_ORIGINS: http://localhost:3000
ADMIN_EMAIL: test@test.com
ADMIN_PASSWORD: test123
ENVIRONMENT: test
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: |
cd frontend
npm ci
- name: Build frontend
run: |
cd frontend
npm run build
build-and-deploy: build-and-deploy:
needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: docker:24-dind
options: --privileged
steps: steps:
- name: Install git
run: apk add --no-cache git
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 run: |
git clone --depth 1 --branch prod https://${{ secrets.REGISTRY_USERNAME }}:${{ secrets.REGISTRY_TOKEN }}@git.local.evan.casa/evan/lediscord.git .
- name: Set up Docker Buildx - name: Login to Registry
uses: docker/setup-buildx-action@v2 run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ secrets.REGISTRY_URL }} -u ${{ secrets.REGISTRY_USERNAME }} --password-stdin
- name: Login to Gitea Container Registry - name: Build and push backend
uses: docker/login-action@v2 run: |
with: docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend ./backend
registry: ${{ secrets.GITEA_REGISTRY }} for i in 1 2 3 4 5; do
username: ${{ secrets.GITEA_USERNAME }} docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-backend && break || sleep 10
password: ${{ secrets.GITEA_TOKEN }} done
- name: Build and push backend image - name: Build and push frontend
uses: docker/build-push-action@v4 run: |
with: docker build -t ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend ./frontend
context: ./backend for i in 1 2 3 4 5; do
file: ./backend/Dockerfile docker push ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/lediscord:prod-frontend && break || sleep 10
push: true done
tags: |
${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-backend:prod
${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-backend:latest
cache-from: type=registry,ref=${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-backend:prod
cache-to: type=inline
- name: Build and push frontend image - name: Done
uses: docker/build-push-action@v4 run: |
with: echo "🚀 Images pushed! Run: nomad job run -force lediscord.nomad"
context: ./frontend
file: ./frontend/Dockerfile
push: true
tags: |
${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-frontend:prod
${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-frontend:latest
cache-from: type=registry,ref=${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_USERNAME }}/lediscord-frontend:prod
cache-to: type=inline

View File

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

139
backend/MIGRATIONS.md Normal file
View File

@@ -0,0 +1,139 @@
# Guide des Migrations Alembic
Ce projet utilise Alembic pour gérer les migrations de base de données de manière versionnée et contrôlée.
## 🚀 Démarrage rapide
### Première utilisation
Si c'est la première fois que vous utilisez Alembic sur ce projet :
```bash
# Depuis le conteneur backend
docker compose exec backend alembic revision --autogenerate -m "Initial migration"
# Appliquer la migration
docker compose exec backend alembic upgrade head
```
### Commandes courantes
```bash
# Créer une nouvelle migration (autogenerate depuis les modèles)
docker compose exec backend alembic revision --autogenerate -m "Description de la migration"
# Appliquer toutes les migrations en attente
docker compose exec backend alembic upgrade head
# Revenir en arrière d'une migration
docker compose exec backend alembic downgrade -1
# Voir la migration actuelle
docker compose exec backend alembic current
# Voir l'historique des migrations
docker compose exec backend alembic history
```
## 📋 Workflow de développement
### 1. Modifier un modèle
Éditez les fichiers dans `backend/models/` pour ajouter/modifier des colonnes, tables, etc.
### 2. Créer une migration
```bash
docker compose exec backend alembic revision --autogenerate -m "Ajout du champ X à la table Y"
```
Alembic va automatiquement détecter les changements dans vos modèles SQLAlchemy.
### 3. Vérifier la migration
Ouvrez le fichier généré dans `backend/migrations/versions/` et vérifiez que les changements sont corrects.
### 4. Appliquer la migration
```bash
docker compose exec backend alembic upgrade head
```
### 5. Tester
Vérifiez que votre application fonctionne correctement avec les nouvelles migrations.
## ⚠️ Notes importantes
- **Ne modifiez jamais manuellement** les fichiers de migration existants qui ont déjà été appliqués
- **Toujours tester** les migrations en développement avant de les appliquer en production
- **Sauvegardez votre base de données** avant d'appliquer des migrations en production
- Les migrations sont **versionnées** : chaque migration a un ID unique et un historique
## 🔄 Migration manuelle (sans autogenerate)
Si vous avez besoin de créer une migration manuelle (pour des données, des index complexes, etc.) :
```bash
docker compose exec backend alembic revision -m "Description"
```
Puis éditez le fichier généré dans `backend/migrations/versions/` pour ajouter votre logique.
## 🐛 Dépannage
### Migration en conflit
Si vous avez des conflits de migration :
```bash
# Voir l'état actuel
docker compose exec backend alembic current
# Voir l'historique
docker compose exec backend alembic history
# Revenir à une version spécifique
docker compose exec backend alembic downgrade <revision_id>
```
### Migration qui échoue
Si une migration échoue :
1. Vérifiez les logs : `docker compose logs backend`
2. Vérifiez l'état de la base : `docker compose exec backend alembic current`
3. Si nécessaire, corrigez la migration et réessayez
### Réinitialiser les migrations (⚠️ DANGEREUX)
**ATTENTION** : Cela supprime toutes les données !
```bash
# Supprimer toutes les tables
docker compose exec backend alembic downgrade base
# Recréer depuis le début
docker compose exec backend alembic upgrade head
```
## 📁 Structure des fichiers
```
backend/
├── alembic.ini # Configuration Alembic
├── migrations/
│ ├── env.py # Configuration de l'environnement
│ ├── script.py.mako # Template pour les migrations
│ ├── versions/ # Fichiers de migration (générés)
│ └── README.md # Documentation
└── models/ # Modèles SQLAlchemy
```
## 🔗 Ressources
- [Documentation Alembic](https://alembic.sqlalchemy.org/)
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)

117
backend/alembic.ini Normal file
View File

@@ -0,0 +1,117 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# post_write_hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# post_write_hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -9,6 +9,7 @@ from models.notification import Notification, NotificationType
from schemas.event import EventCreate, EventUpdate, EventResponse, ParticipationUpdate from schemas.event import EventCreate, EventUpdate, EventResponse, ParticipationUpdate
from utils.security import get_current_active_user from utils.security import get_current_active_user
from utils.email import send_event_notification from utils.email import send_event_notification
from utils.push_service import send_push_to_user
router = APIRouter() router = APIRouter()
@@ -19,15 +20,58 @@ async def create_event(
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
"""Create a new event.""" """Create a new event."""
event_dict = event_data.dict(exclude={'invited_user_ids'})
event = Event( event = Event(
**event_data.dict(), **event_dict,
creator_id=current_user.id creator_id=current_user.id
) )
db.add(event) db.add(event)
db.commit() db.commit()
db.refresh(event) db.refresh(event)
# Create participations for all active users # Gérer les invitations selon le type d'événement
if event.is_private:
# Événement privé : inviter uniquement les utilisateurs sélectionnés
invited_user_ids = event_data.invited_user_ids or []
# Toujours inclure le créateur
if current_user.id not in invited_user_ids:
invited_user_ids.append(current_user.id)
for user_id in invited_user_ids:
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
if user:
participation = EventParticipation(
event_id=event.id,
user_id=user.id,
status=ParticipationStatus.PENDING
)
db.add(participation)
# 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
)
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)
except:
pass
else:
# Événement public : inviter tous les utilisateurs actifs
users = db.query(User).filter(User.is_active == True).all() users = db.query(User).filter(User.is_active == True).all()
for user in users: for user in users:
participation = EventParticipation( participation = EventParticipation(
@@ -39,20 +83,27 @@ async def create_event(
# Create notification # Create notification
if user.id != current_user.id: if user.id != current_user.id:
notif_title = f"Nouvel événement: {event.title}"
notif_message = f"{current_user.full_name} a créé un nouvel événement"
notif_link = f"/events/{event.id}"
notification = Notification( notification = Notification(
user_id=user.id, user_id=user.id,
type=NotificationType.EVENT_INVITATION, type=NotificationType.EVENT_INVITATION,
title=f"Nouvel événement: {event.title}", title=notif_title,
message=f"{current_user.full_name} a créé un nouvel événement", message=notif_message,
link=f"/events/{event.id}" link=notif_link
) )
db.add(notification) db.add(notification)
# Send email notification (async task would be better) # Send push notification
send_push_to_user(db, user.id, notif_title, notif_message, notif_link)
# Send email notification
try: try:
send_event_notification(user.email, event) send_event_notification(user.email, event)
except: except:
pass # Don't fail if email sending fails pass
db.commit() db.commit()
return format_event_response(event, db) return format_event_response(event, db)
@@ -75,7 +126,23 @@ async def get_events(
# If upcoming is None, return all events # If upcoming is None, return all events
events = query.order_by(Event.date.desc()).all() events = query.order_by(Event.date.desc()).all()
return [format_event_response(event, db) for event in events]
# Filtrer les événements privés : ne montrer que ceux où l'utilisateur est invité
filtered_events = []
for event in events:
if event.is_private:
# Vérifier si l'utilisateur est invité (a une participation)
participation = db.query(EventParticipation).filter(
EventParticipation.event_id == event.id,
EventParticipation.user_id == current_user.id
).first()
if participation or event.creator_id == current_user.id or current_user.is_admin:
filtered_events.append(event)
else:
# Événement public : visible par tous
filtered_events.append(event)
return [format_event_response(event, db) for event in filtered_events]
@router.get("/upcoming", response_model=List[EventResponse]) @router.get("/upcoming", response_model=List[EventResponse])
async def get_upcoming_events( async def get_upcoming_events(
@@ -86,7 +153,21 @@ async def get_upcoming_events(
events = db.query(Event).options(joinedload(Event.creator)).filter( events = db.query(Event).options(joinedload(Event.creator)).filter(
Event.date >= datetime.utcnow() Event.date >= datetime.utcnow()
).order_by(Event.date).all() ).order_by(Event.date).all()
return [format_event_response(event, db) for event in events]
# Filtrer les événements privés
filtered_events = []
for event in events:
if event.is_private:
participation = db.query(EventParticipation).filter(
EventParticipation.event_id == event.id,
EventParticipation.user_id == current_user.id
).first()
if participation or event.creator_id == current_user.id or current_user.is_admin:
filtered_events.append(event)
else:
filtered_events.append(event)
return [format_event_response(event, db) for event in filtered_events]
@router.get("/past", response_model=List[EventResponse]) @router.get("/past", response_model=List[EventResponse])
async def get_past_events( async def get_past_events(
@@ -97,7 +178,21 @@ async def get_past_events(
events = db.query(Event).options(joinedload(Event.creator)).filter( events = db.query(Event).options(joinedload(Event.creator)).filter(
Event.date < datetime.utcnow() Event.date < datetime.utcnow()
).order_by(Event.date.desc()).all() ).order_by(Event.date.desc()).all()
return [format_event_response(event, db) for event in events]
# Filtrer les événements privés
filtered_events = []
for event in events:
if event.is_private:
participation = db.query(EventParticipation).filter(
EventParticipation.event_id == event.id,
EventParticipation.user_id == current_user.id
).first()
if participation or event.creator_id == current_user.id or current_user.is_admin:
filtered_events.append(event)
else:
filtered_events.append(event)
return [format_event_response(event, db) for event in filtered_events]
@router.get("/{event_id}", response_model=EventResponse) @router.get("/{event_id}", response_model=EventResponse)
async def get_event( async def get_event(
@@ -112,6 +207,19 @@ async def get_event(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Event not found" detail="Event not found"
) )
# Vérifier l'accès pour les événements privés
if event.is_private:
participation = db.query(EventParticipation).filter(
EventParticipation.event_id == event.id,
EventParticipation.user_id == current_user.id
).first()
if not participation and event.creator_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this private event"
)
return format_event_response(event, db) return format_event_response(event, db)
@router.put("/{event_id}", response_model=EventResponse) @router.put("/{event_id}", response_model=EventResponse)
@@ -166,6 +274,81 @@ async def delete_event(
db.commit() db.commit()
return {"message": "Event deleted successfully"} return {"message": "Event deleted successfully"}
@router.post("/{event_id}/invite", response_model=EventResponse)
async def invite_users_to_event(
event_id: int,
user_ids: List[int],
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Invite users to a private event."""
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Event not found"
)
# Vérifier que l'événement est privé et que l'utilisateur est le créateur ou admin
if not event.is_private:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This endpoint is only for private events"
)
if event.creator_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the event creator can invite users"
)
# Inviter les utilisateurs
for user_id in user_ids:
# Vérifier si l'utilisateur existe et est actif
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
if not user:
continue
# Vérifier si l'utilisateur n'est pas déjà invité
existing_participation = db.query(EventParticipation).filter(
EventParticipation.event_id == event_id,
EventParticipation.user_id == user_id
).first()
if not existing_participation:
participation = EventParticipation(
event_id=event.id,
user_id=user.id,
status=ParticipationStatus.PENDING
)
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
)
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)
except:
pass
db.commit()
return format_event_response(event, db)
@router.put("/{event_id}/participation", response_model=EventResponse) @router.put("/{event_id}/participation", response_model=EventResponse)
async def update_participation( async def update_participation(
event_id: int, event_id: int,
@@ -174,6 +357,25 @@ async def update_participation(
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
"""Update user participation status for an event.""" """Update user participation status for an event."""
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Event not found"
)
# Pour les événements privés, vérifier que l'utilisateur est invité
if event.is_private:
participation_check = db.query(EventParticipation).filter(
EventParticipation.event_id == event_id,
EventParticipation.user_id == current_user.id
).first()
if not participation_check and event.creator_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not invited to this private event"
)
participation = db.query(EventParticipation).filter( participation = db.query(EventParticipation).filter(
EventParticipation.event_id == event_id, EventParticipation.event_id == event_id,
EventParticipation.user_id == current_user.id EventParticipation.user_id == current_user.id
@@ -236,6 +438,7 @@ def format_event_response(event: Event, db: Session) -> dict:
"end_date": event.end_date, "end_date": event.end_date,
"creator_id": event.creator_id, "creator_id": event.creator_id,
"cover_image": event.cover_image, "cover_image": event.cover_image,
"is_private": event.is_private if event.is_private is not None else False,
"created_at": event.created_at, "created_at": event.created_at,
"updated_at": event.updated_at, "updated_at": event.updated_at,
"creator_name": creator.full_name if creator else "Unknown", "creator_name": creator.full_name if creator else "Unknown",

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

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

View File

@@ -6,12 +6,14 @@ import uuid
from pathlib import Path from pathlib import Path
from config.database import get_db from config.database import get_db
from config.settings import settings from config.settings import settings
from models.vlog import Vlog, VlogLike, VlogComment from models.vlog import Vlog, VlogLike, VlogComment, VlogView
from models.user import User from models.user import User
from models.notification import Notification, NotificationType
from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate
from utils.security import get_current_active_user from utils.security import get_current_active_user
from utils.video_utils import generate_video_thumbnail, get_video_duration from utils.video_utils import generate_video_thumbnail, get_video_duration
from utils.settings_service import SettingsService from utils.settings_service import SettingsService
from utils.push_service import send_push_to_user
router = APIRouter() router = APIRouter()
@@ -56,8 +58,21 @@ async def get_vlog(
detail="Vlog not found" detail="Vlog not found"
) )
# Increment view count # Manage views and replays
view = db.query(VlogView).filter(
VlogView.vlog_id == vlog_id,
VlogView.user_id == current_user.id
).first()
if view:
# User has already viewed this vlog -> Count as replay
vlog.replays_count = (vlog.replays_count or 0) + 1
else:
# First time viewing -> Count as unique view
new_view = VlogView(vlog_id=vlog_id, user_id=current_user.id)
db.add(new_view)
vlog.views_count += 1 vlog.views_count += 1
db.commit() db.commit()
return format_vlog_response(vlog, db, current_user.id) return format_vlog_response(vlog, db, current_user.id)
@@ -149,10 +164,15 @@ async def toggle_vlog_like(
message = "Like removed" message = "Like removed"
else: else:
# Like # Like
try:
like = VlogLike(vlog_id=vlog_id, user_id=current_user.id) like = VlogLike(vlog_id=vlog_id, user_id=current_user.id)
db.add(like) db.add(like)
vlog.likes_count += 1 vlog.likes_count += 1
message = "Vlog liked" message = "Vlog liked"
except Exception:
# Handle potential race condition or constraint violation
db.rollback()
return {"message": "Already liked", "likes_count": vlog.likes_count}
db.commit() db.commit()
return {"message": message, "likes_count": vlog.likes_count} return {"message": message, "likes_count": vlog.likes_count}
@@ -235,6 +255,9 @@ async def upload_vlog_video(
"""Upload a vlog video.""" """Upload a vlog video."""
# Validate video file # Validate video file
if not SettingsService.is_file_type_allowed(db, video.content_type or "unknown"): if not SettingsService.is_file_type_allowed(db, video.content_type or "unknown"):
# Fallback check for common video types if content_type is generic application/octet-stream
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", allowed_types = SettingsService.get_setting(db, "allowed_video_types",
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"]) ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
raise HTTPException( raise HTTPException(
@@ -244,7 +267,7 @@ async def upload_vlog_video(
# Check file size # Check file size
video_content = await video.read() video_content = await video.read()
max_size = SettingsService.get_max_upload_size(db, video.content_type or "video/mp4") max_size = SettingsService.get_max_upload_size(db, video.content_type or "video/mp4", is_vlog=True)
if len(video_content) > max_size: if len(video_content) > max_size:
max_size_mb = max_size // (1024 * 1024) max_size_mb = max_size // (1024 * 1024)
raise HTTPException( raise HTTPException(
@@ -314,6 +337,29 @@ async def upload_vlog_video(
db.commit() db.commit()
db.refresh(vlog) db.refresh(vlog)
# 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,
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) return format_vlog_response(vlog, db, current_user.id)
def format_vlog_response(vlog: Vlog, db: Session, current_user_id: int) -> dict: def format_vlog_response(vlog: Vlog, db: Session, current_user_id: int) -> dict:
@@ -363,6 +409,7 @@ def format_vlog_response(vlog: Vlog, db: Session, current_user_id: int) -> dict:
"thumbnail_url": vlog.thumbnail_url, "thumbnail_url": vlog.thumbnail_url,
"duration": vlog.duration, "duration": vlog.duration,
"views_count": vlog.views_count, "views_count": vlog.views_count,
"replays_count": vlog.replays_count,
"likes_count": vlog.likes_count, "likes_count": vlog.likes_count,
"created_at": vlog.created_at, "created_at": vlog.created_at,
"updated_at": vlog.updated_at, "updated_at": vlog.updated_at,

View File

@@ -1,13 +1,14 @@
from fastapi import FastAPI from fastapi import FastAPI, Request, Response, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import os import os
from pathlib import Path from pathlib import Path
import mimetypes
from config.settings import settings from config.settings import settings
from config.database import engine, Base from config.database import engine, Base
from api.routers import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings as settings_router, information, tickets from api.routers import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings as settings_router, information, tickets, push
from utils.init_db import init_database from utils.init_db import init_database
from utils.settings_service import SettingsService from utils.settings_service import SettingsService
from config.database import SessionLocal from config.database import SessionLocal
@@ -174,8 +175,9 @@ def init_default_settings():
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup # Startup
print("Starting LeDiscord backend...") print("Starting LeDiscord backend...")
# Create tables # Note: Database migrations are handled by Alembic
Base.metadata.create_all(bind=engine) # Run migrations manually with: alembic upgrade head
# Base.metadata.create_all(bind=engine) # Disabled in favor of Alembic migrations
# Initialize database with admin user # Initialize database with admin user
init_database() init_database()
# Initialize default settings # Initialize default settings
@@ -198,10 +200,104 @@ app.add_middleware(
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
expose_headers=["Content-Range", "Accept-Ranges"],
) )
# Mount static files for uploads
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_PATH), name="uploads") # 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):
"""
Serve media files with proper Range request support for video scrubbing.
"""
file_full_path = Path(settings.UPLOAD_PATH) / file_path
# Vérifier que le fichier existe
if not file_full_path.exists() or not file_full_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
# Vérifier que le fichier est dans le répertoire uploads (sécurité)
try:
file_full_path.resolve().relative_to(Path(settings.UPLOAD_PATH).resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
# Obtenir la taille du fichier
file_size = file_full_path.stat().st_size
# Déterminer le content type
content_type, _ = mimetypes.guess_type(str(file_full_path))
if not content_type:
content_type = "application/octet-stream"
# Gérer les requêtes Range
range_header = request.headers.get("Range")
if range_header:
# Parser le header Range (format: bytes=start-end)
range_match = range_header.replace("bytes=", "").split("-")
start = int(range_match[0]) if range_match[0] else 0
end = int(range_match[1]) if range_match[1] and range_match[1] else file_size - 1
# Valider la plage
if start >= file_size or end >= file_size or start > end:
return Response(
status_code=416,
headers={
"Content-Range": f"bytes */{file_size}",
"Accept-Ranges": "bytes"
}
)
# Lire la plage demandée
chunk_size = end - start + 1
with open(file_full_path, "rb") as f:
f.seek(start)
chunk = f.read(chunk_size)
# Retourner la réponse 206 Partial Content
return Response(
content=chunk,
status_code=206,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk_size),
"Content-Type": content_type,
},
media_type=content_type
)
else:
# Pas de Range header, retourner le fichier complet
with open(file_full_path, "rb") as f:
content = f.read()
return Response(
content=content,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Content-Type": content_type,
},
media_type=content_type
)
# Note: StaticFiles mount retiré car notre endpoint personnalisé gère tous les fichiers
# avec support Range pour permettre le scrubbing vidéo
# Include routers # Include routers
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
@@ -213,6 +309,7 @@ app.include_router(vlogs.router, prefix="/api/vlogs", tags=["Vlogs"])
app.include_router(stats.router, prefix="/api/stats", tags=["Statistics"]) app.include_router(stats.router, prefix="/api/stats", tags=["Statistics"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(push.router, prefix="/api/push", tags=["Push Notifications"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"])
app.include_router(information.router, prefix="/api/information", tags=["Information"]) app.include_router(information.router, prefix="/api/information", tags=["Information"])
app.include_router(tickets.router, prefix="/api/tickets", tags=["Tickets"]) app.include_router(tickets.router, prefix="/api/tickets", tags=["Tickets"])

View File

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

29
backend/entrypoint.sh Executable file
View File

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

View File

@@ -0,0 +1,45 @@
# Migrations Alembic
Ce dossier contient les migrations de base de données gérées par Alembic.
## Commandes utiles
### Créer une nouvelle migration
```bash
alembic revision --autogenerate -m "Description de la migration"
```
### Appliquer les migrations
```bash
alembic upgrade head
```
### Revenir en arrière d'une migration
```bash
alembic downgrade -1
```
### Voir l'historique des migrations
```bash
alembic history
```
### Voir la migration actuelle
```bash
alembic current
```
### Créer une migration vide (sans autogenerate)
```bash
alembic revision -m "Description de la migration"
```
## Notes importantes
- Les migrations sont automatiquement détectées depuis les modèles SQLAlchemy dans `models/`
- Ne modifiez jamais manuellement les fichiers de migration existants
- 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`

104
backend/migrations/env.py Normal file
View File

@@ -0,0 +1,104 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
from config.database import Base
from config.settings import settings
# Import all models so Alembic can detect them
from models import (
User,
Event,
EventParticipation,
Album,
Media,
MediaLike,
Post,
PostMention,
PostLike,
PostComment,
Vlog,
VlogLike,
VlogComment,
Notification,
SystemSettings,
Information,
Ticket
)
# set the sqlalchemy.url from settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,29 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
from .user import User from .user import User
from .event import Event, EventParticipation from .event import Event, EventParticipation
from .album import Album, Media, MediaLike from .album import Album, Media, MediaLike
from .post import Post, PostMention from .post import Post, PostMention, PostLike, PostComment
from .vlog import Vlog, VlogLike, VlogComment from .vlog import Vlog, VlogLike, VlogComment
from .notification import Notification from .notification import Notification
from .settings import SystemSettings from .settings import SystemSettings
@@ -17,6 +17,8 @@ __all__ = [
"MediaLike", "MediaLike",
"Post", "Post",
"PostMention", "PostMention",
"PostLike",
"PostComment",
"Vlog", "Vlog",
"VlogLike", "VlogLike",
"VlogComment", "VlogComment",

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum as SQLEnum, Float from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum as SQLEnum, Float, Boolean
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
import enum import enum
@@ -23,6 +23,7 @@ class Event(Base):
end_date = Column(DateTime) end_date = Column(DateTime)
creator_id = Column(Integer, ForeignKey("users.id"), nullable=False) creator_id = Column(Integer, ForeignKey("users.id"), nullable=False)
cover_image = Column(String) cover_image = Column(String)
is_private = Column(Boolean, default=False) # Événement privé ou public
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
from config.database import Base from config.database import Base
@@ -14,6 +14,7 @@ class Vlog(Base):
thumbnail_url = Column(String) thumbnail_url = Column(String)
duration = Column(Integer) # in seconds duration = Column(Integer) # in seconds
views_count = Column(Integer, default=0) views_count = Column(Integer, default=0)
replays_count = Column(Integer, default=0)
likes_count = Column(Integer, default=0) likes_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -35,6 +36,10 @@ class VlogLike(Base):
vlog = relationship("Vlog", back_populates="likes") vlog = relationship("Vlog", back_populates="likes")
user = relationship("User", back_populates="vlog_likes") user = relationship("User", back_populates="vlog_likes")
__table_args__ = (
UniqueConstraint('vlog_id', 'user_id', name='_vlog_user_like_uc'),
)
class VlogComment(Base): class VlogComment(Base):
__tablename__ = "vlog_comments" __tablename__ = "vlog_comments"
@@ -48,3 +53,19 @@ class VlogComment(Base):
# Relationships # Relationships
vlog = relationship("Vlog", back_populates="comments") vlog = relationship("Vlog", back_populates="comments")
user = relationship("User", back_populates="vlog_comments") user = relationship("User", back_populates="vlog_comments")
class VlogView(Base):
__tablename__ = "vlog_views"
id = Column(Integer, primary_key=True, index=True)
vlog_id = Column(Integer, ForeignKey("vlogs.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
vlog = relationship("Vlog")
user = relationship("User")
__table_args__ = (
UniqueConstraint('vlog_id', 'user_id', name='_vlog_user_view_uc'),
)

View File

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

View File

@@ -14,6 +14,8 @@ class EventBase(BaseModel):
class EventCreate(EventBase): class EventCreate(EventBase):
cover_image: Optional[str] = None cover_image: Optional[str] = None
is_private: bool = False
invited_user_ids: Optional[List[int]] = None # Liste des IDs des utilisateurs invités (pour événements privés)
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200) title: Optional[str] = Field(None, min_length=1, max_length=200)
@@ -24,6 +26,7 @@ class EventUpdate(BaseModel):
date: Optional[datetime] = None date: Optional[datetime] = None
end_date: Optional[datetime] = None end_date: Optional[datetime] = None
cover_image: Optional[str] = None cover_image: Optional[str] = None
is_private: Optional[bool] = None
class ParticipationResponse(BaseModel): class ParticipationResponse(BaseModel):
user_id: int user_id: int
@@ -42,6 +45,7 @@ class EventResponse(EventBase):
creator_name: str creator_name: str
creator_avatar: Optional[str] = None creator_avatar: Optional[str] = None
cover_image: Optional[str] cover_image: Optional[str]
is_private: bool = False
created_at: datetime created_at: datetime
participations: List[ParticipationResponse] = [] participations: List[ParticipationResponse] = []
present_count: int = 0 present_count: int = 0

View File

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

View File

@@ -49,6 +49,7 @@ class VlogResponse(VlogBase):
thumbnail_url: Optional[str] thumbnail_url: Optional[str]
duration: Optional[int] duration: Optional[int]
views_count: int views_count: int
replays_count: int = 0
likes_count: int likes_count: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

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

44
backend/scripts/migrate.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/bin/bash
# Script helper pour gérer les migrations Alembic
set -e
case "$1" in
create)
if [ -z "$2" ]; then
echo "Usage: ./scripts/migrate.sh create 'message de migration'"
exit 1
fi
alembic revision --autogenerate -m "$2"
;;
upgrade)
alembic upgrade head
;;
downgrade)
if [ -z "$2" ]; then
alembic downgrade -1
else
alembic downgrade "$2"
fi
;;
current)
alembic current
;;
history)
alembic history
;;
*)
echo "Usage: ./scripts/migrate.sh {create|upgrade|downgrade|current|history}"
echo ""
echo "Commands:"
echo " create 'message' - Créer une nouvelle migration"
echo " upgrade - Appliquer toutes les migrations en attente"
echo " downgrade [rev] - Revenir en arrière (par défaut -1)"
echo " current - Afficher la migration actuelle"
echo " history - Afficher l'historique des migrations"
exit 1
;;
esac

View File

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

View File

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

View File

@@ -1,49 +1,53 @@
import os
from typing import Any, Dict, List
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models.settings import SystemSettings from models.settings import SystemSettings
from typing import Optional, Dict, Any
import json
class SettingsService: class SettingsService:
"""Service for managing system settings.""" """Service for managing system settings."""
@staticmethod @staticmethod
def get_setting(db: Session, key: str, default: Any = None) -> Any: def get_setting(db: Session, key: str, default: Any = None) -> Any:
"""Get a setting value by key.""" """Get a setting value by key, return default if not found."""
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first() setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
if not setting: if not setting:
return default return default
# Essayer de convertir en type approprié # Convert value based on expected type (basic handling)
value = setting.value if isinstance(default, int):
# Booléens
if value.lower() in ['true', 'false']:
return value.lower() == 'true'
# Nombres entiers
try: try:
return int(value) return int(setting.value)
except ValueError: except ValueError:
pass return default
elif isinstance(default, bool):
return setting.value.lower() == "true"
elif isinstance(default, list):
return setting.value.split(",")
# Nombres flottants return setting.value
try:
return float(value)
except ValueError:
pass
# JSON @staticmethod
if value.startswith('{') or value.startswith('['): def set_setting(db: Session, key: str, value: str, description: str = None, category: str = "general") -> SystemSettings:
try: """Set a setting value."""
return json.loads(value) setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
except json.JSONDecodeError: if setting:
pass setting.value = str(value)
if description:
setting.description = description
if category:
setting.category = category
else:
setting = SystemSettings(
key=key,
value=str(value),
description=description,
category=category
)
db.add(setting)
# Liste séparée par des virgules db.commit()
if ',' in value and not value.startswith('{') and not value.startswith('['): db.refresh(setting)
return [item.strip() for item in value.split(',')] return setting
return value
@staticmethod @staticmethod
def get_upload_limits(db: Session) -> Dict[str, Any]: def get_upload_limits(db: Session) -> Dict[str, Any]:
@@ -60,7 +64,7 @@ class SettingsService:
} }
@staticmethod @staticmethod
def get_max_upload_size(db: Session, content_type: str) -> int: def get_max_upload_size(db: Session, content_type: str, is_vlog: bool = False) -> int:
"""Get max upload size for a specific content type.""" """Get max upload size for a specific content type."""
if content_type.startswith('image/'): if content_type.startswith('image/'):
max_size_mb = SettingsService.get_setting(db, "max_image_size_mb", 10) max_size_mb = SettingsService.get_setting(db, "max_image_size_mb", 10)
@@ -68,9 +72,14 @@ class SettingsService:
print(f"DEBUG - Image upload limit: {max_size_mb}MB = {max_size_bytes} bytes") print(f"DEBUG - Image upload limit: {max_size_mb}MB = {max_size_bytes} bytes")
return max_size_bytes return max_size_bytes
elif content_type.startswith('video/'): elif content_type.startswith('video/'):
if is_vlog:
max_size_mb = SettingsService.get_setting(db, "max_vlog_size_mb", 500)
print(f"DEBUG - Vlog upload limit: {max_size_mb}MB")
else:
max_size_mb = SettingsService.get_setting(db, "max_video_size_mb", 100) max_size_mb = SettingsService.get_setting(db, "max_video_size_mb", 100)
print(f"DEBUG - Video upload limit: {max_size_mb}MB")
max_size_bytes = max_size_mb * 1024 * 1024 max_size_bytes = max_size_mb * 1024 * 1024
print(f"DEBUG - Video upload limit: {max_size_mb}MB = {max_size_bytes} bytes")
return max_size_bytes return max_size_bytes
else: else:
default_size = 10 * 1024 * 1024 # 10MB par défaut default_size = 10 * 1024 * 1024 # 10MB par défaut
@@ -79,14 +88,17 @@ class SettingsService:
@staticmethod @staticmethod
def is_file_type_allowed(db: Session, content_type: str) -> bool: def is_file_type_allowed(db: Session, content_type: str) -> bool:
"""Check if a file type is allowed.""" """Check if file type is allowed."""
if content_type.startswith('image/'): if content_type.startswith('image/'):
allowed_types = SettingsService.get_setting(db, "allowed_image_types", allowed = SettingsService.get_setting(db, "allowed_image_types",
["image/jpeg", "image/png", "image/gif", "image/webp"]) ["image/jpeg", "image/png", "image/gif", "image/webp"])
if isinstance(allowed, str):
allowed = allowed.split(",")
return content_type in allowed
elif content_type.startswith('video/'): elif content_type.startswith('video/'):
allowed_types = SettingsService.get_setting(db, "allowed_video_types", allowed = SettingsService.get_setting(db, "allowed_video_types",
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"]) ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
else: if isinstance(allowed, str):
allowed = allowed.split(",")
return content_type in allowed
return False return False
return content_type in allowed_types

View File

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

View File

@@ -1,15 +1,74 @@
FROM node:18-alpine # Stage 1 : Build prod
FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Copy package files first for better caching
COPY package*.json ./ COPY package*.json ./
# Install dependencies
RUN npm ci RUN npm ci
# Copy application files
COPY . . COPY . .
# Run the application in development mode ARG VITE_API_URL=https://api.lediscord.com
CMD ["npm", "run", "dev"] ARG VITE_APP_URL=https://lediscord.com
ARG VITE_UPLOAD_URL=https://api.lediscord.com/uploads
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_APP_URL=$VITE_APP_URL
ENV VITE_UPLOAD_URL=$VITE_UPLOAD_URL
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
WORKDIR /app
# Copier les sources pour le mode dev
COPY package*.json ./
RUN npm ci
COPY . .
# Copier le build prod
COPY --from=builder /app/dist /usr/share/nginx/html
# Config nginx
RUN echo 'worker_processes auto; \
events { worker_connections 1024; } \
http { \
include /etc/nginx/mime.types; \
default_type application/octet-stream; \
sendfile on; \
keepalive_timeout 65; \
gzip on; \
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; \
server { \
listen 8082; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
} \
location /assets { \
expires 1y; \
add_header Cache-Control "public, immutable"; \
} \
} \
}' > /etc/nginx/nginx.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
EXPOSE 8082
CMD ["/entrypoint.sh"]

53
frontend/PWA_SETUP.md Normal file
View File

@@ -0,0 +1,53 @@
# Configuration PWA - LeDiscord
## Installation
1. **Installer les dépendances PWA :**
```bash
npm install --save-dev vite-plugin-pwa sharp
```
2. **Générer les icônes PWA :**
```bash
npm run generate-icons
```
Cette commande génère automatiquement toutes les icônes nécessaires (72x72, 96x96, 128x128, 144x144, 152x152, 192x192, 384x384, 512x512) à partir du logo `public/logo_lediscord.png`.
## Fonctionnalités PWA
### ✅ Fonctionnalités implémentées
- **Service Worker** : Cache automatique des assets statiques
- **Manifest.json** : Configuration complète de l'application
- **Icônes** : Support multi-tailles pour tous les appareils
- **Offline** : Cache des ressources pour fonctionner hors ligne
- **Installation** : L'application peut être installée sur mobile et desktop
### 📱 Cache Strategy
- **Assets statiques** : Cache First (JS, CSS, images, vidéos)
- **API** : Network First avec cache de 5 minutes
- **Uploads** : Cache First avec expiration de 7 jours
- **Fonts Google** : Cache First avec expiration de 1 an
### 🔧 Configuration
La configuration PWA se trouve dans `vite.config.js` dans le plugin `VitePWA`.
### 🚀 Build Production
Lors du build en production, le service worker sera automatiquement généré :
```bash
npm run build
```
### 📝 Notes
- Le service worker est activé en développement (`devOptions.enabled: true`)
- Les mises à jour sont automatiques (`registerType: 'autoUpdate'`)
- Les icônes doivent être générées avant le premier build

View File

@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

129
frontend/dev-dist/sw.js Normal file
View File

@@ -0,0 +1,129 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-52524643'], (function (workbox) { 'use strict';
importScripts("/sw-custom.js");
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(/^https:\/\/fonts\.googleapis\.com\/.*/i, new workbox.CacheFirst({
"cacheName": "google-fonts-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 31536000
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/fonts\.gstatic\.com\/.*/i, new workbox.CacheFirst({
"cacheName": "gstatic-fonts-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 31536000
}), new workbox.CacheableResponsePlugin({
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({
"cacheName": "api-cache",
"networkTimeoutSeconds": 10,
plugins: [new workbox.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 300
})]
}), 'GET');
workbox.registerRoute(/^https?:\/\/.*\/uploads\/.*/i, new workbox.StaleWhileRevalidate({
"cacheName": "uploads-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 604800
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
}));
//# sourceMappingURL=sw.js.map

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,15 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Plateforme communautaire LeDiscord - Notre espace">
<meta name="theme-color" content="#6366f1">
<!-- PWA Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="LeDiscord">
<link rel="apple-touch-icon" href="/icon-192x192.png">
<title>LeDiscord - Notre espace</title> <title>LeDiscord - Notre espace</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"generate-icons": "node scripts/generate-icons.js"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^10.6.1", "@vueuse/core": "^10.6.1",
@@ -23,8 +24,10 @@
"@vitejs/plugin-vue": "^4.5.0", "@vitejs/plugin-vue": "^4.5.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"sharp": "^0.33.5",
"tailwindcss": "^3.3.5", "tailwindcss": "^3.3.5",
"terser": "^5.43.1", "terser": "^5.43.1",
"vite": "^5.0.0" "vite": "^5.0.0",
"vite-plugin-pwa": "^0.20.5"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,82 @@
{
"name": "LeDiscord - Notre espace",
"short_name": "LeDiscord",
"description": "Plateforme communautaire LeDiscord",
"theme_color": "#6366f1",
"background_color": "#ffffff",
"display": "standalone",
"orientation": "portrait-primary",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [],
"categories": ["social", "entertainment"],
"shortcuts": [
{
"name": "Vlogs",
"short_name": "Vlogs",
"description": "Voir les vlogs",
"url": "/vlogs",
"icons": [{ "src": "/icon-96x96.png", "sizes": "96x96" }]
},
{
"name": "Albums",
"short_name": "Albums",
"description": "Voir les albums",
"url": "/albums",
"icons": [{ "src": "/icon-96x96.png", "sizes": "96x96" }]
}
]
}

View File

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

View File

@@ -0,0 +1,79 @@
/**
* Script pour générer les icônes PWA à partir du logo
*
* Usage: node scripts/generate-icons.js
*
* Nécessite: npm install --save-dev sharp
*/
const fs = require('fs')
const path = require('path')
// Tailles d'icônes requises pour PWA
const iconSizes = [72, 96, 128, 144, 152, 192, 384, 512]
async function generateIcons() {
try {
// Vérifier si sharp est installé
let sharp
try {
sharp = require('sharp')
} catch (e) {
console.error('❌ Le package "sharp" n\'est pas installé.')
console.log('📦 Installez-le avec: npm install --save-dev sharp')
process.exit(1)
}
const logoPath = path.join(__dirname, '../public/logo_lediscord.png')
const publicDir = path.join(__dirname, '../public')
// Vérifier que le logo existe
if (!fs.existsSync(logoPath)) {
console.error(`❌ Logo introuvable: ${logoPath}`)
process.exit(1)
}
console.log('🎨 Génération des icônes PWA avec fond transparent...')
// Générer chaque taille d'icône
for (const size of iconSizes) {
const outputPath = path.join(publicDir, `icon-${size}x${size}.png`)
// Calculer le padding (10% de la taille) pour éviter que le logo touche les bords
const padding = Math.floor(size * 0.1)
const contentSize = size - (padding * 2)
await sharp(logoPath)
.resize(contentSize, contentSize, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 } // Fond transparent
})
.extend({
top: padding,
bottom: padding,
left: padding,
right: padding,
background: { r: 0, g: 0, b: 0, alpha: 0 } // Fond transparent
})
.resize(size, size, {
kernel: sharp.kernel.lanczos3 // Meilleure qualité de redimensionnement
})
.png({
quality: 100,
compressionLevel: 9
})
.toFile(outputPath)
console.log(`✅ Généré: icon-${size}x${size}.png`)
}
console.log('✨ Toutes les icônes ont été générées avec succès!')
console.log('💡 Les icônes utilisent un fond transparent avec un padding intelligent.')
} catch (error) {
console.error('❌ Erreur lors de la génération des icônes:', error)
process.exit(1)
}
}
generateIcons()

View File

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

View File

@@ -0,0 +1,154 @@
<template>
<div
v-if="showInstallPrompt"
class="fixed bottom-4 right-4 z-50 max-w-sm bg-white rounded-lg shadow-lg border border-gray-200 p-4 animate-slide-up"
>
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
<svg
class="w-6 h-6 text-indigo-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-gray-900">
Installer LeDiscord
</h3>
<p class="mt-1 text-sm text-gray-500">
Installez l'application pour un accès rapide et une meilleure expérience.
</p>
<div class="mt-3 flex space-x-2">
<button
@click="installApp"
class="flex-1 bg-indigo-600 text-white text-sm font-medium py-2 px-4 rounded-md hover:bg-indigo-700 transition-colors"
>
Installer
</button>
<button
@click="dismissPrompt"
class="flex-1 bg-gray-100 text-gray-700 text-sm font-medium py-2 px-4 rounded-md hover:bg-gray-200 transition-colors"
>
Plus tard
</button>
</div>
</div>
<button
@click="dismissPrompt"
class="flex-shrink-0 text-gray-400 hover:text-gray-500"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const showInstallPrompt = ref(false)
const deferredPrompt = ref(null)
onMounted(() => {
// Vérifier si l'app est déjà installée
if (window.matchMedia('(display-mode: standalone)').matches) {
return
}
// Vérifier si le prompt a été rejeté récemment
const dismissed = localStorage.getItem('pwa-install-dismissed')
if (dismissed) {
const dismissedTime = parseInt(dismissed, 10)
const daysSinceDismissed = (Date.now() - dismissedTime) / (1000 * 60 * 60 * 24)
// Réafficher après 7 jours
if (daysSinceDismissed < 7) {
return
}
}
// Écouter l'événement beforeinstallprompt
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
})
function handleBeforeInstallPrompt(e) {
// Empêcher le prompt par défaut
e.preventDefault()
// Stocker l'événement pour l'utiliser plus tard
deferredPrompt.value = e
// Afficher notre prompt personnalisé
showInstallPrompt.value = true
}
async function installApp() {
if (!deferredPrompt.value) {
return
}
try {
// Afficher le prompt d'installation
deferredPrompt.value.prompt()
// Attendre la réponse de l'utilisateur
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') {
console.log('✅ PWA installée avec succès')
} else {
console.log('❌ Installation PWA annulée')
}
// Réinitialiser
deferredPrompt.value = null
showInstallPrompt.value = false
} catch (error) {
console.error('Erreur lors de l\'installation:', error)
}
}
function dismissPrompt() {
showInstallPrompt.value = false
// Enregistrer le rejet avec timestamp
localStorage.setItem('pwa-install-dismissed', Date.now().toString())
}
</script>
<style scoped>
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
</style>

View File

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

View File

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

View File

@@ -1,30 +1,38 @@
<template> <template>
<div class="video-player-container"> <div class="video-player-container px-2 sm:px-0">
<div class="relative"> <div class="relative">
<!-- Video.js Player --> <!-- Video.js Player -->
<div data-vjs-player>
<video <video
ref="videoPlayer" ref="videoPlayer"
class="video-js vjs-default-skin vjs-big-play-centered w-full rounded-lg" class="video-js vjs-default-skin vjs-big-play-centered w-full rounded-lg"
controls controls
preload="auto" preload="auto"
:poster="posterUrl" :poster="posterUrl"
data-setup="{}" playsinline
> >
<source :src="videoUrl" type="video/mp4" /> <source :src="videoUrl" />
<p class="vjs-no-js"> <p class="vjs-no-js">
Pour voir cette vidéo, activez JavaScript et considérez passer à un navigateur web qui Pour voir cette vidéo, activez JavaScript et considérez passer à un navigateur web qui
<a href="https://videojs.com/html5-video-support/" target="_blank">supporte la vidéo HTML5</a>. <a href="https://videojs.com/html5-video-support/" target="_blank">supporte la vidéo HTML5</a>.
</p> </p>
</video> </video>
</div> </div>
</div>
<!-- Video Stats --> <!-- Video Stats -->
<div class="mt-4 flex items-center justify-between text-sm text-gray-600"> <div class="mt-4 flex items-center justify-between text-sm text-gray-600">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<span class="flex items-center"> <div class="flex items-center space-x-3">
<span class="flex items-center" title="Vues uniques">
<Eye class="w-4 h-4 mr-1" /> <Eye class="w-4 h-4 mr-1" />
{{ viewsCount }} vue{{ viewsCount > 1 ? 's' : '' }} {{ viewsCount }}
</span> </span>
<span class="flex items-center" title="Replays">
<RotateCcw class="w-4 h-4 mr-1" />
{{ replaysCount }}
</span>
</div>
<span class="flex items-center"> <span class="flex items-center">
<Clock class="w-4 h-4 mr-1" /> <Clock class="w-4 h-4 mr-1" />
{{ formatDuration(duration) }} {{ formatDuration(duration) }}
@@ -57,7 +65,7 @@
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue' import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import videojs from 'video.js' import videojs from 'video.js'
import 'video.js/dist/video-js.css' import 'video.js/dist/video-js.css'
import { Eye, Clock, Heart, MessageSquare } from 'lucide-vue-next' import { Eye, Clock, Heart, MessageSquare, RotateCcw } from 'lucide-vue-next'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl } from '@/utils/axios'
const props = defineProps({ const props = defineProps({
@@ -85,6 +93,10 @@ const props = defineProps({
type: Number, type: Number,
default: 0 default: 0
}, },
replaysCount: {
type: Number,
default: 0
},
likesCount: { likesCount: {
type: Number, type: Number,
default: 0 default: 0
@@ -103,6 +115,7 @@ const emit = defineEmits(['like', 'toggle-comments'])
const videoPlayer = ref(null) const videoPlayer = ref(null)
const player = ref(null) const player = ref(null)
const currentVideoSrc = ref(null) // Track la source actuelle pour éviter les rechargements inutiles
// Computed properties pour les URLs // Computed properties pour les URLs
const videoUrl = computed(() => getMediaUrl(props.src)) const videoUrl = computed(() => getMediaUrl(props.src))
@@ -124,16 +137,62 @@ function toggleComments() {
emit('toggle-comments') emit('toggle-comments')
} }
// Fonction pour gérer les raccourcis clavier manuellement
function handleKeydown(e) {
if (!player.value) return;
// Ignorer si l'utilisateur tape dans un input
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
switch(e.key) {
case 'ArrowLeft':
e.preventDefault();
player.value.currentTime(Math.max(0, player.value.currentTime() - 10));
break;
case 'ArrowRight':
e.preventDefault();
player.value.currentTime(Math.min(player.value.duration(), player.value.currentTime() + 10));
break;
case ' ':
case 'Space': // Espace pour pause/play
e.preventDefault();
if (player.value.paused()) {
player.value.play();
} else {
player.value.pause();
}
break;
}
}
onMounted(() => { onMounted(() => {
if (videoPlayer.value) { if (videoPlayer.value) {
player.value = videojs(videoPlayer.value, { // Options de base pour Video.js
const options = {
controls: true, controls: true,
fluid: true, fluid: true,
responsive: true, responsive: true,
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2], playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
// Désactiver les hotkeys natifs qui causent l'erreur passive listener
userActions: {
hotkeys: false
},
html5: {
vhs: {
overrideNative: true
},
nativeAudioTracks: false,
nativeVideoTracks: false
},
controlBar: { controlBar: {
skipButtons: {
forward: 10,
backward: 10
},
children: [ children: [
'playToggle', 'playToggle',
'skipBackward',
'skipForward',
'volumePanel', 'volumePanel',
'currentTimeDisplay', 'currentTimeDisplay',
'timeDivider', 'timeDivider',
@@ -143,12 +202,23 @@ onMounted(() => {
'fullscreenToggle' 'fullscreenToggle'
] ]
} }
}) };
player.value = videojs(videoPlayer.value, options);
// Définir la source initiale après l'initialisation
if (videoUrl.value) {
player.value.src({ src: videoUrl.value, type: 'video/mp4' })
currentVideoSrc.value = videoUrl.value
}
// Error handling // Error handling
player.value.on('error', (error) => { player.value.on('error', (error) => {
console.error('Video.js error:', error) console.error('Video.js error:', error)
}) })
// Ajouter l'écouteur d'événements clavier global
document.addEventListener('keydown', handleKeydown)
} }
}) })
@@ -156,20 +226,39 @@ onBeforeUnmount(() => {
if (player.value) { if (player.value) {
player.value.dispose() player.value.dispose()
} }
// Retirer l'écouteur
document.removeEventListener('keydown', handleKeydown)
}) })
// Watch for src changes to reload video // Watch for src changes to reload video - amélioré pour éviter les rechargements inutiles
watch(() => props.src, () => { watch(() => videoUrl.value, (newUrl, oldUrl) => {
if (player.value && videoUrl.value) { // Ne recharger que si l'URL a vraiment changé et que le player est prêt
player.value.src({ src: videoUrl.value, type: 'video/mp4' }) if (player.value && newUrl && newUrl !== currentVideoSrc.value) {
const wasPlaying = !player.value.paused()
const currentTime = player.value.currentTime()
player.value.src({ src: newUrl, type: 'video/mp4' })
player.value.load() player.value.load()
currentVideoSrc.value = newUrl
// Restaurer la position si possible (optionnel)
player.value.ready(() => {
if (currentTime > 0 && currentTime < player.value.duration()) {
player.value.currentTime(currentTime)
}
if (wasPlaying) {
player.value.play().catch(() => {})
} }
}) })
}
}, { immediate: false })
</script> </script>
<style scoped> <style scoped>
.video-js { .video-js {
aspect-ratio: 16/9; aspect-ratio: 16/9;
/* Fix pour l'erreur "passive event listener" sur certains navigateurs */
touch-action: manipulation;
} }
.video-js .vjs-big-play-button { .video-js .vjs-big-play-button {

View File

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

View File

@@ -1,20 +1,20 @@
<template> <template>
<div class="min-h-screen bg-gradient-discord flex items-center justify-center p-4"> <div class="min-h-screen bg-gradient-discord flex items-center justify-center p-3 sm:p-4 md:p-6">
<div class="w-full max-w-md"> <div class="w-full max-w-md lg:max-w-lg flex flex-col">
<div class="text-center mb-4"> <div class="text-center mb-3 sm:mb-4">
<h1 class="text-4xl font-bold bg-gradient-primary bg-clip-text text-transparent mb-0">LeDiscord</h1> <h1 class="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-primary bg-clip-text text-transparent mb-0">LeDiscord</h1>
<p class="text-secondary-600">Notre espace privé</p> <p class="text-xs sm:text-sm md:text-base text-secondary-600 mt-1">Notre espace privé</p>
</div> </div>
<div class="bg-white rounded-2xl shadow-xl p-8"> <div class="bg-white rounded-xl sm:rounded-2xl shadow-xl p-4 sm:p-6 md:p-8 flex-1 flex flex-col">
<slot /> <slot />
</div> </div>
<div class="text-center mt-4"> <div class="text-center mt-3 sm:mt-4">
<img <img
src="/logo_lediscord.png" src="/logo_lediscord.png"
alt="LeDiscord Logo" alt="LeDiscord Logo"
class="mx-auto h-48 w-auto mb-0 drop-shadow-lg" class="mx-auto h-24 sm:h-32 md:h-40 w-auto drop-shadow-lg"
> >
</div> </div>
</div> </div>

View File

@@ -21,11 +21,21 @@
v-for="item in navigation" v-for="item in navigation"
:key="item.name" :key="item.name"
:to="item.to" :to="item.to"
class="inline-flex items-center px-1 pt-1 text-sm font-medium text-secondary-600 hover:text-primary-600 border-b-2 border-transparent hover:border-primary-600 transition-colors" class="inline-flex items-center px-1 pt-1 text-sm font-medium border-b-2 border-transparent transition-colors relative"
active-class="!text-primary-600 !border-primary-600" :class="item.comingSoon
? 'text-gray-400 cursor-not-allowed opacity-60 !border-transparent'
: 'text-secondary-600 hover:text-primary-600 hover:border-primary-600'"
:active-class="item.comingSoon ? '' : '!text-primary-600 !border-primary-600'"
@click.prevent="item.comingSoon ? null : null"
> >
<component :is="item.icon" class="w-4 h-4 mr-2" /> <component :is="item.icon" class="w-4 h-4 mr-2" />
{{ item.name }} {{ item.name }}
<span
v-if="item.comingSoon"
class="ml-1.5 px-1 py-0.5 text-[9px] font-medium text-white bg-purple-600 rounded"
>
Soon
</span>
</router-link> </router-link>
</div> </div>
</div> </div>
@@ -39,8 +49,10 @@
<Bell class="w-5 h-5" /> <Bell class="w-5 h-5" />
<span <span
v-if="unreadNotifications > 0" v-if="unreadNotifications > 0"
class="absolute top-0 right-0 block h-2 w-2 rounded-full bg-red-500" class="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-500 rounded-full"
/> >
{{ unreadNotifications > 99 ? '99+' : unreadNotifications }}
</span>
</button> </button>
<!-- User Menu --> <!-- User Menu -->
@@ -112,6 +124,25 @@
Administration Administration
</router-link> </router-link>
<hr class="my-1">
<!-- Installer l'app -->
<button
@click="handleInstallApp"
:disabled="isPWAInstalled"
class="block w-full text-left px-4 py-2 text-sm transition-colors"
:class="isPWAInstalled
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-700 hover:bg-gray-100'"
>
<div class="flex items-center">
<svg class="w-4 h-4 mr-2" 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>
{{ isPWAInstalled ? 'Application installée' : 'Installer l\'app' }}
</div>
</button>
<hr class="my-1"> <hr class="my-1">
<button <button
@click="logout" @click="logout"
@@ -122,22 +153,44 @@
</div> </div>
</transition> </transition>
</div> </div>
<!-- Mobile menu button -->
<div class="flex items-center sm:hidden ml-4">
<button
@click="isMobileMenuOpen = !isMobileMenuOpen"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
>
<span class="sr-only">Open main menu</span>
<Menu v-if="!isMobileMenuOpen" class="block h-6 w-6" aria-hidden="true" />
<X v-else class="block h-6 w-6" aria-hidden="true" />
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Mobile menu --> <!-- Mobile menu -->
<div class="sm:hidden"> <div class="sm:hidden border-t border-gray-200" v-show="isMobileMenuOpen">
<div class="pt-2 pb-3 space-y-1"> <div class="pt-2 pb-3 space-y-1">
<router-link <router-link
v-for="item in navigation" v-for="item in navigation"
:key="item.name" :key="item.name"
:to="item.to" :to="item.to"
class="flex items-center px-4 py-2 text-base font-medium text-gray-600 hover:text-primary-600 hover:bg-gray-50" class="flex items-center px-4 py-2 text-base font-medium hover:bg-gray-50 relative"
:class="item.comingSoon
? 'text-gray-400 cursor-not-allowed opacity-60'
: 'text-gray-600 hover:text-primary-600'"
active-class="!text-primary-600 bg-primary-50" active-class="!text-primary-600 bg-primary-50"
@click.prevent="item.comingSoon ? null : null"
> >
<component :is="item.icon" class="w-5 h-5 mr-3" /> <component :is="item.icon" class="w-5 h-5 mr-3" />
{{ item.name }} <span>{{ item.name }}</span>
<span
v-if="item.comingSoon"
class="ml-auto px-1 py-0.5 text-[9px] font-medium text-white bg-purple-600 rounded"
>
Soon
</span>
</router-link> </router-link>
</div> </div>
</div> </div>
@@ -151,6 +204,13 @@
<!-- Ticket Floating Button --> <!-- Ticket Floating Button -->
<TicketFloatingButton /> <TicketFloatingButton />
<!-- PWA Install Tutorial -->
<PWAInstallTutorial
:show="showPWAInstructions"
:is-ios="isIOS"
@close="showPWAInstructions = false"
/>
<!-- Notifications Panel --> <!-- Notifications Panel -->
<transition <transition
enter-active-class="transition ease-out duration-200" enter-active-class="transition ease-out duration-200"
@@ -214,11 +274,10 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { format } from 'date-fns' import { formatDateInFrenchTimezone } from '@/utils/dateUtils'
import { fr } from 'date-fns/locale'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl } from '@/utils/axios'
import { import {
Home, Home,
@@ -229,10 +288,13 @@ import {
Bell, Bell,
User, User,
ChevronDown, ChevronDown,
X X,
Menu,
Dice6
} from 'lucide-vue-next' } from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import TicketFloatingButton from '@/components/TicketFloatingButton.vue' import TicketFloatingButton from '@/components/TicketFloatingButton.vue'
import PWAInstallTutorial from '@/components/PWAInstallTutorial.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
@@ -242,18 +304,27 @@ const navigation = [
{ name: 'Événements', to: '/events', icon: Calendar }, { name: 'Événements', to: '/events', icon: Calendar },
{ name: 'Albums', to: '/albums', icon: Image }, { name: 'Albums', to: '/albums', icon: Image },
{ name: 'Vlogs', to: '/vlogs', icon: Film }, { name: 'Vlogs', to: '/vlogs', icon: Film },
{ name: 'Publications', to: '/posts', icon: MessageSquare } { name: 'Publications', to: '/posts', icon: MessageSquare },
{ name: 'Jeux', to: '#', icon: Dice6, comingSoon: true }
] ]
const showUserMenu = ref(false) const showUserMenu = ref(false)
const showNotifications = ref(false) const showNotifications = ref(false)
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 user = computed(() => authStore.user)
const notifications = computed(() => authStore.notifications) const notifications = computed(() => authStore.notifications)
const unreadNotifications = computed(() => authStore.unreadCount) const unreadNotifications = computed(() => authStore.unreadCount)
function formatDate(date) { function formatDate(date) {
return format(new Date(date), 'dd MMM à HH:mm', { locale: fr }) return formatDateInFrenchTimezone(date, 'dd MMM à HH:mm')
} }
async function logout() { async function logout() {
@@ -262,7 +333,8 @@ async function logout() {
} }
async function fetchNotifications() { async function fetchNotifications() {
await authStore.fetchNotifications() const result = await authStore.fetchNotifications()
// Les notifications sont maintenant mises à jour automatiquement via le polling
} }
async function markAllRead() { async function markAllRead() {
@@ -274,16 +346,145 @@ async function handleNotificationClick(notification) {
await authStore.markNotificationRead(notification.id) await authStore.markNotificationRead(notification.id)
} }
showNotifications.value = false
if (notification.link) { if (notification.link) {
// Gérer les liens de posts différemment car il n'y a pas de route /posts/:id
if (notification.link.startsWith('/posts/')) {
const postId = notification.link.split('/posts/')[1]
// Naviguer vers /posts et passer l'ID en query pour scroll
router.push({ path: '/posts', query: { highlight: postId } })
} else {
router.push(notification.link) router.push(notification.link)
} }
}
}
showNotifications.value = false // PWA Installation logic
function checkIfMobile() {
isMobile.value = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ||
window.innerWidth < 768
}
function checkPWAInstalled() {
// Vérifier si l'app est déjà installée (mode standalone)
isPWAInstalled.value = window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true ||
document.referrer.includes('android-app://')
// Sur mobile, permettre l'installation même sans beforeinstallprompt
if (isMobile.value && !isPWAInstalled.value) {
canInstall.value = true
}
}
function handleBeforeInstallPrompt(e) {
// Empêcher le prompt par défaut
e.preventDefault()
// Stocker l'événement pour l'utiliser plus tard
deferredPrompt.value = e
canInstall.value = true
}
async function handleInstallApp() {
if (isPWAInstalled.value) {
return
}
if (deferredPrompt.value) {
try {
showUserMenu.value = false
// Afficher le prompt d'installation
deferredPrompt.value.prompt()
// Attendre la réponse de l'utilisateur
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') {
console.log('✅ PWA installée avec succès')
isPWAInstalled.value = true
} else {
console.log('❌ Installation PWA annulée')
}
// Réinitialiser
deferredPrompt.value = null
canInstall.value = false
} catch (error) {
console.error('Erreur lors de l\'installation:', error)
}
} else if (isMobile.value) {
// Sur mobile sans beforeinstallprompt, afficher le tutoriel
showUserMenu.value = false
showPWAInstructions.value = true
}
} }
onMounted(async () => { onMounted(async () => {
checkIfMobile()
// Restaurer la session si un token existe
if (authStore.token) {
await authStore.fetchCurrentUser() await authStore.fetchCurrentUser()
}
if (authStore.isAuthenticated) {
await fetchNotifications() await fetchNotifications()
await authStore.fetchUnreadCount() 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
checkPWAInstalled()
// Écouter l'événement beforeinstallprompt
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
// É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> </script>

View File

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

View File

@@ -0,0 +1,283 @@
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()
if (!authStore.isAuthenticated) return
this.isPolling = true
// Récupérer immédiatement
this.fetchNotifications()
// Puis toutes les 30 secondes
this.pollingInterval = setInterval(() => {
this.fetchNotifications()
}, this.pollInterval)
}
stopPolling() {
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
this.pollingInterval = null
}
this.isPolling = false
}
async fetchNotifications() {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
this.stopPolling()
return
}
try {
// Juste mettre à jour le store (compteur, liste)
// Les notifications push sont gérées par le service worker
await authStore.fetchNotifications()
} catch (error) {
console.error('Error polling notifications:', error)
}
}
showNotificationBadge() {
// Mettre à jour le badge du titre de la page
if ('Notification' in window && Notification.permission === 'granted') {
// La notification push sera gérée par le service worker
return
}
}
// 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()) {
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...')
const permission = await Notification.requestPermission()
const granted = permission === 'granted'
if (granted) {
console.log('✅ Notification permission granted')
await this.subscribeToPush()
} else {
console.warn('❌ Notification permission denied:', permission)
}
return granted
} catch (error) {
console.error('❌ Error requesting notification permission:', error)
return false
}
}
async showPushNotification(title, options = {}) {
// Cette méthode est maintenant principalement utilisée pour les tests
// ou les notifications locales générées par le client
if (!this.isNotificationSupported()) return null
if (Notification.permission !== 'granted') return null
// Toujours utiliser le service worker pour les notifications push
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.ready
if (!registration.active) return null
await registration.showNotification(title, {
icon: '/icon-192x192.png',
badge: '/icon-96x96.png',
tag: 'lediscord-notification',
...options
})
return true
} catch (error) {
console.error('Error showing notification:', error)
}
}
return null
}
// Écouter les messages du service worker pour les notifications push
setupServiceWorkerListener() {
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)
}
})
}
}
// Fonction de test pour vérifier que les notifications fonctionnent
async testNotification() {
console.log('🧪 Test de notification...')
const result = await this.showPushNotification('Test LeDiscord', {
body: 'Si vous voyez cette notification, les notifications push fonctionnent !',
link: '/'
})
if (result) {
console.log('✅ Test réussi - notification affichée')
return true
} else {
console.warn('⚠️ Test échoué - notification non affichée')
return false
}
}
}
export default new NotificationService()

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { uploadFormData } from '@/utils/axios'
import router from '@/router' import router from '@/router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
@@ -9,7 +10,7 @@ export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token')) const token = ref(localStorage.getItem('token'))
const toast = useToast() const toast = useToast()
const isAuthenticated = computed(() => !!token.value) const isAuthenticated = computed(() => !!token.value && !!user.value)
const isAdmin = computed(() => user.value?.is_admin || false) const isAdmin = computed(() => user.value?.is_admin || false)
if (token.value) { if (token.value) {
@@ -68,8 +69,15 @@ export const useAuthStore = defineStore('auth', () => {
async function logout() { async function logout() {
token.value = null token.value = null
user.value = null user.value = null
notifications.value = []
unreadCount.value = 0
localStorage.removeItem('token') localStorage.removeItem('token')
delete axios.defaults.headers.common['Authorization'] delete axios.defaults.headers.common['Authorization']
// Arrêter le polling des notifications
const notificationService = (await import('@/services/notificationService')).default
notificationService.stopPolling()
router.push('/login') router.push('/login')
toast.info('Déconnexion réussie') toast.info('Déconnexion réussie')
} }
@@ -105,19 +113,13 @@ export const useAuthStore = defineStore('auth', () => {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
const response = await axios.post('/api/users/me/avatar', formData, { const data = await uploadFormData('/api/users/me/avatar', formData)
headers: { user.value = data
'Content-Type': 'multipart/form-data'
}
})
user.value = response.data
toast.success('Avatar mis à jour') toast.success('Avatar mis à jour')
return { success: true, data: response.data } return { success: true, data }
} catch (error) { } catch (error) {
console.error('Error uploading avatar:', error)
toast.error('Erreur lors de l\'upload de l\'avatar') toast.error('Erreur lors de l\'upload de l\'avatar')
return { success: false, error: error.response?.data?.detail || 'Erreur inconnue' } return { success: false, error: error.message || 'Erreur inconnue' }
} }
} }
@@ -130,10 +132,36 @@ export const useAuthStore = defineStore('auth', () => {
try { try {
const response = await axios.get('/api/notifications?limit=50') const response = await axios.get('/api/notifications?limit=50')
notifications.value = response.data const newNotifications = response.data
unreadCount.value = notifications.value.filter(n => !n.is_read).length
// Détecter les nouvelles notifications non lues
const previousIds = new Set(notifications.value.map(n => n.id))
const previousUnreadIds = new Set(
notifications.value.filter(n => !n.is_read).map(n => n.id)
)
// Nouvelles notifications = celles qui n'existaient pas avant
const hasNewNotifications = newNotifications.some(n => !previousIds.has(n.id))
// Nouvelles notifications non lues = nouvelles ET non lues
const newUnreadNotifications = newNotifications.filter(
n => !previousIds.has(n.id) && !n.is_read
)
notifications.value = newNotifications
const newUnreadCount = notifications.value.filter(n => !n.is_read).length
const previousUnreadCount = unreadCount.value
unreadCount.value = newUnreadCount
// Retourner si de nouvelles notifications non lues ont été détectées
return {
hasNewNotifications: newUnreadNotifications.length > 0,
newCount: newUnreadCount,
previousCount: previousUnreadCount
}
} catch (error) { } catch (error) {
console.error('Error fetching notifications:', error) console.error('Error fetching notifications:', error)
return { hasNewNotifications: false, newCount: unreadCount.value, previousCount: unreadCount.value }
} }
} }

View File

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

View File

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

View File

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

View File

@@ -18,30 +18,30 @@
<div v-else> <div v-else>
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-4"> <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 space-y-4 sm:space-y-0">
<router-link to="/albums" class="text-primary-600 hover:text-primary-700 flex items-center"> <router-link to="/albums" class="text-primary-600 hover:text-primary-700 flex items-center">
<ArrowLeft class="w-4 h-4 mr-2" /> <ArrowLeft class="w-4 h-4 mr-2" />
Retour aux albums Retour aux albums
</router-link> </router-link>
<div v-if="canEdit" class="flex space-x-2"> <div v-if="canEdit" class="flex flex-wrap gap-2 w-full sm:w-auto">
<button <button
@click="showEditModal = true" @click="showEditModal = true"
class="btn-secondary" class="flex-1 sm:flex-none btn-secondary justify-center"
> >
<Edit class="w-4 h-4 mr-2" /> <Edit class="w-4 h-4 mr-2" />
Modifier Modifier
</button> </button>
<button <button
@click="showUploadModal = true" @click="showUploadModal = true"
class="btn-primary" class="flex-1 sm:flex-none btn-primary justify-center"
> >
<Upload class="w-4 h-4 mr-2" /> <Upload class="w-4 h-4 mr-2" />
Ajouter des médias Ajouter
</button> </button>
<button <button
@click="deleteAlbum" @click="deleteAlbum"
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300" class="flex-1 sm:flex-none btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300 justify-center"
> >
<Trash2 class="w-4 h-4 mr-2" /> <Trash2 class="w-4 h-4 mr-2" />
Supprimer Supprimer
@@ -49,9 +49,9 @@
</div> </div>
</div> </div>
<div class="flex items-start space-x-6"> <div class="flex flex-col md:flex-row items-start space-y-6 md:space-y-0 md:space-x-6">
<!-- Cover Image --> <!-- Cover Image -->
<div class="w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center"> <div class="w-full md:w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center flex-shrink-0">
<Image v-if="!album.cover_image" class="w-16 h-16 text-white" /> <Image v-if="!album.cover_image" class="w-16 h-16 text-white" />
<img <img
v-else v-else
@@ -475,6 +475,17 @@
<X class="w-6 h-6" /> <X class="w-6 h-6" />
</button> </button>
<!-- Download Button -->
<a
:href="getMediaUrl(selectedMedia.file_path)"
:download="selectedMedia.caption || 'media'"
target="_blank"
class="absolute top-6 right-20 z-10 bg-black bg-opacity-70 text-white rounded-full w-12 h-12 flex items-center justify-center hover:bg-opacity-90 transition-colors shadow-lg"
title="Télécharger"
>
<Download class="w-6 h-6" />
</a>
<!-- Navigation Buttons --> <!-- Navigation Buttons -->
@@ -566,9 +577,8 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { format, formatDistanceToNow } from 'date-fns' import { formatDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import { fr } from 'date-fns/locale'
import { import {
ArrowLeft, ArrowLeft,
Image, Image,
@@ -583,7 +593,8 @@ import {
Eye, Eye,
Heart, Heart,
ChevronLeft, ChevronLeft,
ChevronRight ChevronRight,
Download
} from 'lucide-vue-next' } from 'lucide-vue-next'
import LoadingLogo from '@/components/LoadingLogo.vue' import LoadingLogo from '@/components/LoadingLogo.vue'
@@ -618,7 +629,7 @@ const totalSize = computed(() =>
) )
function formatDate(date) { function formatDate(date) {
return format(new Date(date), 'dd MMMM yyyy', { locale: fr }) return formatDateInFrenchTimezone(date, 'dd MMMM yyyy')
} }
function formatBytes(bytes) { function formatBytes(bytes) {
@@ -717,9 +728,7 @@ async function uploadMedia() {
formData.append('files', media.file) formData.append('files', media.file)
}) })
await axios.post(`/api/albums/${album.value.id}/media`, formData, { await uploadFormData(`/api/albums/${album.value.id}/media`, formData)
headers: { 'Content-Type': 'multipart/form-data' }
})
// Refresh album data // Refresh album data
await fetchAlbum() await fetchAlbum()

View File

@@ -1,14 +1,14 @@
<template> <template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-center mb-8"> <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
<div> <div>
<h1 class="text-3xl font-bold text-gray-900">Albums photos</h1> <h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Albums photos</h1>
<p class="text-gray-600 mt-1">Partagez vos souvenirs en photos et vidéos</p> <p class="text-sm sm:text-base text-gray-600 mt-1">Partagez vos souvenirs en photos et vidéos</p>
</div> </div>
<button <button
@click="showCreateModal = true" @click="showCreateModal = true"
class="btn-primary" class="w-full sm:w-auto btn-primary justify-center"
> >
<Plus class="w-4 h-4 mr-2" /> <Plus class="w-4 h-4 mr-2" />
Nouvel album Nouvel album
@@ -333,10 +333,9 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from '@/utils/axios' import axios, { postJson } from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatDistanceToNow, format } from 'date-fns' import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone } from '@/utils/dateUtils'
import { fr } from 'date-fns/locale'
import { import {
Plus, Plus,
Image, Image,
@@ -384,11 +383,11 @@ const uploadSuccess = ref([])
const isDragOver = ref(false) const isDragOver = ref(false)
function formatRelativeDate(date) { function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr }) return formatRelativeDateInFrenchTimezone(date)
} }
function formatDate(date) { function formatDate(date) {
return format(new Date(date), 'dd/MM/yyyy', { locale: fr }) return formatShortDateInFrenchTimezone(date)
} }
function formatFileSize(bytes) { function formatFileSize(bytes) {
@@ -631,8 +630,7 @@ async function createAlbum() {
} }
uploadStatus.value = 'Création de l\'album...' uploadStatus.value = 'Création de l\'album...'
const albumResponse = await axios.post('/api/albums', albumData) const album = await postJson('/api/albums', albumData)
const album = albumResponse.data
// Upload media files in batches for better performance // Upload media files in batches for better performance
const batchSize = 5 // Upload 5 files at a time const batchSize = 5 // Upload 5 files at a time
@@ -654,15 +652,11 @@ async function createAlbum() {
} }
}) })
await axios.post(`/api/albums/${album.id}/media`, formData, { await uploadFormData(`/api/albums/${album.id}/media`, formData)
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => { // Update progress
// Update progress for this batch
const batchProgress = (progressEvent.loaded / progressEvent.total) * 100
const overallProgress = ((batchIndex * batchSize + batch.length) / newAlbum.value.media.length) * 100 const overallProgress = ((batchIndex * batchSize + batch.length) / newAlbum.value.media.length) * 100
uploadProgress.value = Math.min(overallProgress, 100) uploadProgress.value = Math.min(overallProgress, 100)
}
})
// Mark batch as successful // Mark batch as successful
batch.forEach((media, index) => { batch.forEach((media, index) => {

View File

@@ -18,23 +18,23 @@
<div v-else> <div v-else>
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-4"> <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 space-y-4 sm:space-y-0">
<router-link to="/events" class="text-primary-600 hover:text-primary-700 flex items-center"> <router-link to="/events" class="text-primary-600 hover:text-primary-700 flex items-center">
<ArrowLeft class="w-4 h-4 mr-2" /> <ArrowLeft class="w-4 h-4 mr-2" />
Retour aux événements Retour aux événements
</router-link> </router-link>
<div v-if="canEdit" class="flex space-x-2"> <div v-if="canEdit" class="flex w-full sm:w-auto space-x-2">
<button <button
@click="showEditModal = true" @click="showEditModal = true"
class="btn-secondary" class="flex-1 sm:flex-none btn-secondary justify-center"
> >
<Edit class="w-4 h-4 mr-2" /> <Edit class="w-4 h-4 mr-2" />
Modifier Modifier
</button> </button>
<button <button
@click="deleteEvent" @click="deleteEvent"
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300" class="flex-1 sm:flex-none btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300 justify-center"
> >
<Trash2 class="w-4 h-4 mr-2" /> <Trash2 class="w-4 h-4 mr-2" />
Supprimer Supprimer
@@ -42,9 +42,9 @@
</div> </div>
</div> </div>
<div class="flex items-start space-x-6"> <div class="flex flex-col md:flex-row items-start space-y-6 md:space-y-0 md:space-x-6">
<!-- Cover Image --> <!-- Cover Image -->
<div class="w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center"> <div class="w-full md:w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center flex-shrink-0">
<img <img
v-if="!event.cover_image && event.creator_avatar" v-if="!event.cover_image && event.creator_avatar"
:src="getMediaUrl(event.creator_avatar)" :src="getMediaUrl(event.creator_avatar)"
@@ -122,25 +122,6 @@
<p class="text-gray-700 whitespace-pre-wrap">{{ event.description }}</p> <p class="text-gray-700 whitespace-pre-wrap">{{ event.description }}</p>
</div> </div>
<!-- Map Section -->
<div v-if="event.latitude && event.longitude" class="card p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Localisation</h2>
<div class="bg-gray-100 rounded-lg p-6 h-48 flex items-center justify-center">
<div class="text-center text-gray-600">
<MapPin class="w-12 h-12 mx-auto mb-2 text-primary-600" />
<p class="text-sm">Carte interactive</p>
<p class="text-xs mt-1">{{ event.latitude }}, {{ event.longitude }}</p>
<a
:href="`https://www.openstreetmap.org/?mlat=${event.latitude}&mlon=${event.longitude}&zoom=15`"
target="_blank"
class="text-primary-600 hover:underline text-sm mt-2 inline-block"
>
Voir sur OpenStreetMap
</a>
</div>
</div>
</div>
<!-- My Participation --> <!-- My Participation -->
<div class="card p-6 mb-8"> <div class="card p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Ma participation</h2> <h2 class="text-xl font-semibold text-gray-900 mb-4">Ma participation</h2>
@@ -361,8 +342,7 @@ import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl } from '@/utils/axios'
import { format, formatDistanceToNow } from 'date-fns' import { formatFullDateInFrenchTimezone, formatRelativeDateInFrenchTimezone, formatDateForInputInFrenchTimezone, convertFrenchTimezoneToUTC } from '@/utils/dateUtils'
import { fr } from 'date-fns/locale'
import { import {
ArrowLeft, ArrowLeft,
Calendar, Calendar,
@@ -398,11 +378,11 @@ const canEdit = computed(() =>
) )
function formatDate(date) { function formatDate(date) {
return format(new Date(date), 'EEEE d MMMM yyyy à HH:mm', { locale: fr }) return formatFullDateInFrenchTimezone(date)
} }
function formatRelativeDate(date) { function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr }) return formatRelativeDateInFrenchTimezone(date)
} }
function getParticipationClass(status) { function getParticipationClass(status) {
@@ -463,7 +443,7 @@ async function fetchEvent() {
editForm.value = { editForm.value = {
title: event.value.title, title: event.value.title,
description: event.value.description || '', description: event.value.description || '',
date: format(new Date(event.value.date), "yyyy-MM-dd'T'HH:mm", { locale: fr }), date: formatDateForInputInFrenchTimezone(event.value.date),
location: event.value.location || '' location: event.value.location || ''
} }
} catch (error) { } catch (error) {
@@ -489,7 +469,7 @@ async function updateEvent() {
try { try {
const response = await axios.put(`/api/events/${event.value.id}`, { const response = await axios.put(`/api/events/${event.value.id}`, {
...editForm.value, ...editForm.value,
date: new Date(editForm.value.date).toISOString() date: convertFrenchTimezoneToUTC(new Date(editForm.value.date)).toISOString()
}) })
event.value = response.data event.value = response.data
showEditModal.value = false showEditModal.value = false

View File

@@ -1,14 +1,14 @@
<template> <template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-center mb-8"> <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
<div> <div>
<h1 class="text-3xl font-bold text-gray-900">Événements</h1> <h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Événements</h1>
<p class="text-gray-600 mt-1">Organisez et participez aux événements du groupe</p> <p class="text-sm sm:text-base text-gray-600 mt-1">Organisez et participez aux événements du groupe</p>
</div> </div>
<button <button
@click="showCreateModal = true" @click="showCreateModal = true"
class="btn-primary" class="w-full sm:w-auto btn-primary justify-center"
> >
<Plus class="w-4 h-4 mr-2" /> <Plus class="w-4 h-4 mr-2" />
Nouvel événement Nouvel événement
@@ -71,7 +71,14 @@
</div> </div>
<!-- Participation Badge --> <!-- Participation Badge -->
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2 flex gap-1">
<span
v-if="event.is_private"
class="px-2 py-1 rounded-full text-xs font-medium text-white bg-purple-600"
title="Événement privé"
>
🔒 Privé
</span>
<span <span
v-if="getUserParticipation(event)" v-if="getUserParticipation(event)"
class="px-2 py-1 rounded-full text-xs font-medium text-white" class="px-2 py-1 rounded-full text-xs font-medium text-white"
@@ -214,72 +221,91 @@
> >
</div> </div>
<!-- Map Section --> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label class="label">Coordonnées géographiques (optionnel)</label> <label class="label text-sm">Date et heure</label>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-sm text-gray-600">Latitude</label>
<input
v-model="newEvent.latitude"
type="number"
step="0.000001"
class="input"
placeholder="48.8566"
>
</div>
<div>
<label class="text-sm text-gray-600">Longitude</label>
<input
v-model="newEvent.longitude"
type="number"
step="0.000001"
class="input"
placeholder="2.3522"
>
</div>
</div>
<!-- Map Preview -->
<div v-if="newEvent.latitude && newEvent.longitude" class="mt-3">
<div class="bg-gray-100 rounded-lg p-4 h-32 flex items-center justify-center">
<div class="text-center text-gray-600">
<MapPin class="w-8 h-8 mx-auto mb-2 text-primary-600" />
<p class="text-sm">Localisation sélectionnée</p>
<p class="text-xs mt-1">{{ newEvent.latitude }}, {{ newEvent.longitude }}</p>
<a
:href="`https://www.openstreetmap.org/?mlat=${newEvent.latitude}&mlon=${newEvent.longitude}&zoom=15`"
target="_blank"
class="text-primary-600 hover:underline text-xs mt-2 inline-block"
>
Voir sur OpenStreetMap
</a>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">Date et heure</label>
<input <input
v-model="newEvent.date" v-model="newEvent.date"
type="datetime-local" type="datetime-local"
required required
class="input" class="input text-sm w-full"
style="min-width: 0;"
> >
</div> </div>
<div> <div>
<label class="label">Date de fin (optionnel)</label> <label class="label text-sm">Date de fin (optionnel)</label>
<input <input
v-model="newEvent.end_date" v-model="newEvent.end_date"
type="datetime-local" type="datetime-local"
class="input" class="input text-sm w-full"
style="min-width: 0;"
> >
</div> </div>
</div> </div>
<!-- Type d'événement -->
<div>
<label class="label">Type d'événement</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="newEvent.is_private"
:value="false"
class="mr-2"
>
<span class="text-sm">Public (visible par tous)</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="newEvent.is_private"
:value="true"
class="mr-2"
>
<span class="text-sm">Privé (invitations uniquement)</span>
</label>
</div>
</div>
<!-- Sélection des invités pour événements privés -->
<div v-if="newEvent.is_private">
<label class="label">Inviter des membres</label>
<div class="border border-gray-300 rounded-lg p-3 max-h-48 overflow-y-auto">
<div
v-for="user in users.filter(u => u.id !== authStore.user?.id)"
:key="user.id"
class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0"
>
<div class="flex items-center">
<img
v-if="user.avatar_url"
:src="getMediaUrl(user.avatar_url)"
:alt="user.full_name"
class="w-8 h-8 rounded-full mr-2 object-cover"
>
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center mr-2">
<User class="w-4 h-4 text-primary-600" />
</div>
<div>
<div class="text-sm font-medium text-gray-900">{{ user.full_name }}</div>
<div class="text-xs text-gray-500">@{{ user.username }}</div>
</div>
</div>
<input
type="checkbox"
:value="user.id"
v-model="newEvent.invited_user_ids"
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">
Sélectionnez les membres à inviter à cet événement privé
</p>
</div>
<div class="flex gap-3 pt-4"> <div class="flex gap-3 pt-4">
<button <button
type="button" type="button"
@@ -307,10 +333,9 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from '@/utils/axios' import axios, { postJson } from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl } from '@/utils/axios'
import { formatDistanceToNow, format } from 'date-fns' import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone, convertFrenchTimezoneToUTC } from '@/utils/dateUtils'
import { fr } from 'date-fns/locale'
import { import {
Plus, Plus,
Calendar, Calendar,
@@ -339,9 +364,9 @@ const newEvent = ref({
description: '', description: '',
date: '', date: '',
location: '', location: '',
latitude: null, end_date: null,
longitude: null, is_private: false,
end_date: null invited_user_ids: []
}) })
const eventMentions = ref([]) const eventMentions = ref([])
@@ -356,11 +381,11 @@ const filteredEvents = computed(() => {
}) })
function formatRelativeDate(date) { function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr }) return formatRelativeDateInFrenchTimezone(date)
} }
function formatDate(date) { function formatDate(date) {
return format(new Date(date), 'dd/MM/yyyy', { locale: fr }) return formatShortDateInFrenchTimezone(date)
} }
function openEvent(event) { function openEvent(event) {
@@ -444,32 +469,60 @@ async function quickParticipation(eventId, status) {
} }
async function createEvent() { async function createEvent() {
if (!newEvent.value.title || !newEvent.value.date) return console.log('🔵 createEvent appelée')
console.log('🔵 newEvent.value:', newEvent.value)
if (!newEvent.value.title || !newEvent.value.date) {
console.warn('⚠️ Validation échouée: titre ou date manquant')
return
}
// Vérifier que des invités sont sélectionnés pour les événements privés
if (newEvent.value.is_private && (!newEvent.value.invited_user_ids || newEvent.value.invited_user_ids.length === 0)) {
toast.warning('Veuillez sélectionner au moins un membre à inviter pour un événement privé')
return
}
creating.value = true creating.value = true
console.log('🔵 creating.value = true')
try { try {
const eventData = { const eventData = {
title: newEvent.value.title, title: newEvent.value.title,
description: newEvent.value.description, description: newEvent.value.description,
date: new Date(newEvent.value.date).toISOString(), date: convertFrenchTimezoneToUTC(new Date(newEvent.value.date)).toISOString(),
location: newEvent.value.location, location: newEvent.value.location,
latitude: newEvent.value.latitude, end_date: newEvent.value.end_date ? convertFrenchTimezoneToUTC(new Date(newEvent.value.end_date)).toISOString() : null,
longitude: newEvent.value.longitude, is_private: newEvent.value.is_private,
end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null invited_user_ids: newEvent.value.is_private ? newEvent.value.invited_user_ids : null
} }
await axios.post('/api/events', eventData) console.log('🔵 eventData préparé:', eventData)
console.log('🔵 Envoi de la requête postJson...')
const data = await postJson('/api/events', eventData)
console.log('✅ Réponse reçue:', data)
// Refresh events list // Refresh events list
await fetchEvents() await fetchEvents()
showCreateModal.value = false showCreateModal.value = false
resetForm() resetForm()
toast.success('Événement créé avec succès') toast.success(newEvent.value.is_private ? 'Événement privé créé avec succès' : 'Événement créé avec succès')
} catch (error) { } catch (error) {
console.error('❌ Erreur dans createEvent:', error)
console.error('❌ Détails de l\'erreur:', {
message: error.message,
response: error.response,
request: error.request,
config: error.config
})
toast.error('Erreur lors de la création de l\'événement') toast.error('Erreur lors de la création de l\'événement')
} } finally {
creating.value = false creating.value = false
console.log('🔵 creating.value = false')
}
} }
function resetForm() { function resetForm() {
@@ -478,9 +531,9 @@ function resetForm() {
description: '', description: '',
date: '', date: '',
location: '', location: '',
latitude: null, end_date: null,
longitude: null, is_private: false,
end_date: null invited_user_ids: []
} }
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8">
<!-- Welcome Section --> <!-- Welcome Section -->
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2"> <h1 class="text-3xl font-bold text-gray-900 mb-2">
@@ -9,8 +9,8 @@
</div> </div>
<!-- Quick Stats --> <!-- Quick Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8"> <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="card p-6"> <div class="card p-4 sm:p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-gray-600">Prochain événement</p> <p class="text-sm text-gray-600">Prochain événement</p>
@@ -23,7 +23,7 @@
</div> </div>
</div> </div>
<div class="card p-6"> <div class="card p-4 sm:p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-gray-600">Taux de présence</p> <p class="text-sm text-gray-600">Taux de présence</p>
@@ -33,7 +33,7 @@
</div> </div>
</div> </div>
<div class="card p-6"> <div class="card p-4 sm:p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-gray-600">Nouveaux posts</p> <p class="text-sm text-gray-600">Nouveaux posts</p>
@@ -44,7 +44,7 @@
</div> </div>
</div> </div>
<div class="card p-6"> <div class="card p-4 sm:p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-gray-600">Membres actifs</p> <p class="text-sm text-gray-600">Membres actifs</p>
@@ -59,17 +59,17 @@
<!-- Recent Posts --> <!-- Recent Posts -->
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<div class="card"> <div class="card">
<div class="p-6 border-b border-gray-100"> <div class="p-4 sm:p-6 border-b border-gray-100">
<h2 class="text-lg font-semibold text-gray-900">Publications récentes</h2> <h2 class="text-lg font-semibold text-gray-900">Publications récentes</h2>
</div> </div>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
<div v-if="posts.length === 0" class="p-6 text-center text-gray-500"> <div v-if="posts.length === 0" class="p-4 sm:p-6 text-center text-gray-500">
Aucune publication récente Aucune publication récente
</div> </div>
<div <div
v-for="post in posts" v-for="post in posts"
:key="post.id" :key="post.id"
class="p-6 hover:bg-gray-50 transition-colors" class="p-4 sm:p-6 hover:bg-gray-50 transition-colors"
> >
<div class="flex items-start space-x-3"> <div class="flex items-start space-x-3">
<img <img
@@ -133,11 +133,11 @@
<!-- Upcoming Events --> <!-- Upcoming Events -->
<div> <div>
<div class="card"> <div class="card">
<div class="p-6 border-b border-gray-100"> <div class="p-4 sm:p-6 border-b border-gray-100">
<h2 class="text-lg font-semibold text-gray-900">Événements à venir</h2> <h2 class="text-lg font-semibold text-gray-900">Événements à venir</h2>
</div> </div>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
<div v-if="upcomingEvents.length === 0" class="p-6 text-center text-gray-500"> <div v-if="upcomingEvents.length === 0" class="p-4 sm:p-6 text-center text-gray-500">
Aucun événement prévu Aucun événement prévu
</div> </div>
<router-link <router-link
@@ -171,11 +171,11 @@
<!-- Recent Vlogs --> <!-- Recent Vlogs -->
<div class="card mt-6"> <div class="card mt-6">
<div class="p-6 border-b border-gray-100"> <div class="p-4 sm:p-6 border-b border-gray-100">
<h2 class="text-lg font-semibold text-gray-900">Derniers vlogs</h2> <h2 class="text-lg font-semibold text-gray-900">Derniers vlogs</h2>
</div> </div>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
<div v-if="recentVlogs.length === 0" class="p-6 text-center text-gray-500"> <div v-if="recentVlogs.length === 0" class="p-4 sm:p-6 text-center text-gray-500">
Aucun vlog récent Aucun vlog récent
</div> </div>
<router-link <router-link
@@ -220,8 +220,7 @@ import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl } from '@/utils/axios'
import { format, formatDistanceToNow } from 'date-fns' import { formatFullDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import { fr } from 'date-fns/locale'
import { import {
Calendar, Calendar,
TrendingUp, TrendingUp,
@@ -248,11 +247,11 @@ const recentPosts = computed(() => stats.value.recent_posts || 0)
const activeMembers = computed(() => stats.value.total_users || 0) const activeMembers = computed(() => stats.value.total_users || 0)
function formatDate(date) { function formatDate(date) {
return format(new Date(date), 'EEEE d MMMM à HH:mm', { locale: fr }) return formatFullDateInFrenchTimezone(date)
} }
function formatRelativeDate(date) { function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr }) return formatRelativeDateInFrenchTimezone(date)
} }
async function fetchDashboardData() { async function fetchDashboardData() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,165 +1,374 @@
<template> <template>
<div class="space-y-6"> <div class="w-full max-w-2xl mx-auto space-y-4 sm:space-y-6 px-4 sm:px-6 pb-4 sm:pb-6">
<!-- Header --> <!-- Header -->
<div class="text-center"> <div class="text-center pt-2 sm:pt-0">
<h2 class="text-2xl font-bold text-gray-900">Créer un compte</h2> <h2 class="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900">Créer un compte</h2>
<p class="mt-2 text-sm text-gray-500">Rejoignez notre communauté en quelques étapes</p> <p class="mt-2 text-xs sm:text-sm text-gray-500">Remplissez ça et après promis je vous embête plus</p>
</div> </div>
<!-- Progress Bar --> <!-- Progress Bar -->
<div class="pt-4"> <div class="pt-2 sm:pt-4">
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">Étape {{ currentStep }} sur {{ totalSteps }}</span> <span class="text-xs sm:text-sm font-medium text-gray-700">Étape {{ currentStep }} sur {{ totalSteps }}</span>
<span class="text-sm font-medium text-gray-500">{{ Math.round((currentStep / totalSteps) * 100) }}%</span> <span class="text-xs sm:text-sm font-medium text-gray-500">{{ Math.round((currentStep / totalSteps) * 100) }}%</span>
</div> </div>
<div class="w-full bg-gray-200 rounded-full h-1.5"> <div class="w-full bg-gray-200 rounded-full h-2 sm:h-2.5">
<div <div
class="bg-primary-600 h-1.5 rounded-full transition-all duration-500 ease-in-out" class="bg-primary-600 h-2 sm:h-2.5 rounded-full transition-all duration-500 ease-in-out"
:style="{ width: `${(currentStep / totalSteps) * 100}%` }" :style="{ width: `${(currentStep / totalSteps) * 100}%` }"
></div> ></div>
</div> </div>
</div> </div>
<!-- Step Content --> <!-- Step Content -->
<div class="min-h-[350px] flex flex-col justify-center"> <div class="flex flex-col justify-center py-4 sm:py-6">
<StepTransition :step="currentStep"> <StepTransition :step="currentStep">
<!-- Step 1: Welcome --> <!-- Step 1: Welcome -->
<div v-if="currentStep === 1" class="text-center"> <div v-if="currentStep === 1" class="text-center px-2 sm:px-0">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Bienvenue sur LeDiscord !</h3> <br />
<p class="text-gray-600 max-w-sm mx-auto"> <br />
Nous sommes ravis de vous accueillir. Préparez-vous à rejoindre une communauté passionnante. <br />
<br />
<h3 class="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 mb-3 sm:mb-4">Yo les ptits potes ! 🎉</h3>
<p class="text-sm sm:text-base text-gray-600 max-w-md mx-auto leading-relaxed mb-6 sm:mb-8">
Bienvenue sur LeDiscord ! Ici on partage nos vlogs, nos photos de soirées et on organise nos prochaines beuveries. 🍻
</p> </p>
<!-- Minimalist features preview -->
<div class="grid grid-cols-3 gap-2 sm:gap-4 max-w-md mx-auto mt-6 sm:mt-8">
<div class="text-center">
<div class="text-2xl sm:text-3xl mb-1">📹</div>
<div class="text-xs sm:text-sm font-medium text-gray-700">Vlogs</div>
</div>
<div class="text-center">
<div class="text-2xl sm:text-3xl mb-1">🍺</div>
<div class="text-xs sm:text-sm font-medium text-gray-700">Événements</div>
</div>
<div class="text-center">
<div class="text-2xl sm:text-3xl mb-1">📸</div>
<div class="text-xs sm:text-sm font-medium text-gray-700">Albums</div>
</div>
</div>
</div> </div>
<!-- Step 2: Registration Form --> <!-- Step 2: Registration Form -->
<div v-if="currentStep === 2"> <div v-if="currentStep === 2" class="w-full">
<form @submit.prevent="nextStep" class="space-y-6"> <form @submit.prevent="nextStep" class="space-y-4 sm:space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
<div> <div>
<label for="email" class="label">Email</label> <label for="email" class="label text-sm sm:text-base">Email</label>
<input id="email" v-model="form.email" type="email" required class="input" @blur="touchedFields.email = true"> <input
id="email"
v-model="form.email"
type="email"
required
class="input text-sm sm:text-base"
placeholder="exemple@email.com"
@blur="touchedFields.email = true"
>
</div> </div>
<div> <div>
<label for="username" class="label">Nom d'utilisateur</label> <label for="username" class="label text-sm sm:text-base">
<input id="username" v-model="form.username" type="text" required minlength="3" class="input" @blur="touchedFields.username = true"> Identifiant unique
<span class="text-xs text-gray-500 font-normal ml-1">(non modifiable)</span>
</label>
<input
id="username"
v-model="form.username"
type="text"
required
minlength="3"
class="input text-sm sm:text-base"
placeholder="mon_pseudo"
@blur="touchedFields.username = true"
>
</div> </div>
</div> </div>
<div> <div>
<label for="full_name" class="label">Nom complet</label> <label for="full_name" class="label text-sm sm:text-base">
<input id="full_name" v-model="form.full_name" type="text" required class="input" @blur="touchedFields.full_name = true"> Pseudo affiché
<span class="text-xs text-gray-500 font-normal ml-1">(modifiable plus tard)</span>
</label>
<input
id="full_name"
v-model="form.full_name"
type="text"
required
class="input text-sm sm:text-base"
placeholder="Ton surnom / prénom"
@blur="touchedFields.full_name = true"
>
<p class="text-xs text-gray-500 mt-1">C'est ce qui sera affiché aux autres membres</p>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
<div> <div>
<label for="password" class="label">Mot de passe</label> <label for="password" class="label text-sm sm:text-base">Mot de passe</label>
<input id="password" v-model="form.password" type="password" required minlength="6" class="input" @blur="touchedFields.password = true"> <input
id="password"
v-model="form.password"
type="password"
required
minlength="6"
class="input text-sm sm:text-base"
placeholder="••••••••"
@blur="touchedFields.password = true"
>
</div> </div>
<div> <div>
<label for="password_confirm" class="label">Confirmer</label> <label for="password_confirm" class="label text-sm sm:text-base">Confirmer</label>
<input id="password_confirm" v-model="form.password_confirm" type="password" required class="input" @blur="touchedFields.password_confirm = true"> <input
id="password_confirm"
v-model="form.password_confirm"
type="password"
required
class="input text-sm sm:text-base"
placeholder="••••••••"
@blur="touchedFields.password_confirm = true"
>
</div> </div>
</div> </div>
<PasswordStrength :password="form.password" /> <PasswordStrength :password="form.password" />
<div v-if="touchedFields.password_confirm && form.password_confirm && form.password !== form.password_confirm" class="flex items-center text-sm text-red-600"> <div v-if="touchedFields.password_confirm && form.password_confirm && form.password !== form.password_confirm" class="flex items-center text-xs sm:text-sm text-red-600 mt-2">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" /></svg> <svg class="w-4 h-4 mr-1 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
Les mots de passe ne correspondent pas <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<span>Les mots de passe ne correspondent pas</span>
</div> </div>
</form> </form>
</div> </div>
<!-- Step 3: Warning --> <!-- Step 3: Warning -->
<div v-if="currentStep === 3" class="text-center"> <div v-if="currentStep === 3" class="w-full px-2 sm:px-0 text-center">
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4"> <h3 class="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
<div class="flex"> Version Bêta en cours ! 🚧
<div class="flex-shrink-0"> </h3>
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" /> <div class="bg-gradient-to-br from-yellow-50 to-orange-50 border-2 border-yellow-200 rounded-xl p-4 sm:p-6 max-w-lg mx-auto mb-4 sm:mb-6">
<p class="text-sm sm:text-base text-gray-700 leading-relaxed mb-4">
Je suis encore en train de peaufiner tout ça, donc si tu vois un bug ou que quelque chose te fait chier, <strong>dis-moi !</strong> 💬
</p>
<div class="flex items-center justify-center space-x-2 text-xs sm:text-sm text-gray-600">
<span>🔧</span>
<span>J'améliore au fur et à mesure</span>
</div>
</div>
<div class="mt-4 sm:mt-6">
<p class="text-xs sm:text-sm text-gray-700 mb-3 font-medium">Pour me signaler un problème, utilise le bouton ticket :</p>
<div class="inline-flex items-center justify-center bg-primary-600 text-white rounded-full p-3 sm:p-4 shadow-lg">
<svg class="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
</svg> </svg>
</div> </div>
<div class="ml-3 text-left"> <p class="text-xs sm:text-sm text-gray-500 mt-3">
<p class="text-sm text-yellow-700"> Il se trouve en bas à droite de l'écran une fois connecté
LeDiscord est actuellement en version bêta. Votre retour est précieux pour nous aider à améliorer la plateforme.
</p> </p>
</div> </div>
</div> </div>
<!-- Step 4: Interactive Tour -->
<div v-if="currentStep === 4" class="w-full px-2 sm:px-0">
<h3 class="text-xl sm:text-2xl font-bold text-gray-900 mb-4 sm:mb-6 text-center">Petite visite guidée 🗺️</h3>
<div class="max-w-lg mx-auto">
<!-- Tour Content -->
<div class="bg-white border-2 border-gray-200 rounded-xl p-4 sm:p-6 mb-4 sm:mb-6 min-h-[280px] flex flex-col justify-center">
<div v-if="tourStep === 0" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">🏠</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Accueil</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">C'est ici que tu verras toutes les dernières activités de la communauté</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1"> <strong>Astuce :</strong> Tu peux liker, commenter et partager tout ce qui t'intéresse</p>
<p>🔔 Les notifications te tiendront au courant des nouvelles interactions</p>
</div>
</div>
<div v-if="tourStep === 1" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">📅</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Événements</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">Organise et participe aux prochaines soirées et beuveries</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1">📝 <strong>Crée un événement :</strong> Date, lieu, description, le tout en quelques clics</p>
<p>✅ <strong>Participe :</strong> Indique ta présence et vois qui vient</p>
</div>
</div>
<div v-if="tourStep === 2" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">📸</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Albums</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">Partage tes meilleures photos de soirées et de moments entre potes</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1">📁 <strong>Crée un album :</strong> Regroupe tes photos par événement ou thème</p>
<p>💾 <strong>Upload multiple :</strong> Ajoute plusieurs photos d'un coup</p>
</div>
</div>
<div v-if="tourStep === 3" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">📹</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Vlogs</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">Regarde et partage tes vlogs avec la communauté</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1">🎬 <strong>Upload un vlog :</strong> Vidéos jusqu'à 500MB, avec thumbnail personnalisé</p>
<p>👀 <strong>Statistiques :</strong> Vues, replays, likes, tout est tracké</p>
</div>
</div>
<div v-if="tourStep === 4" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">💬</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Publications</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">Discute, partage et échange avec tout le monde</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1">@<strong>Mentions :</strong> Tag tes potes avec @nom_utilisateur</p>
<p>📎 <strong>Médias :</strong> Ajoute des images directement dans tes posts</p>
</div>
</div> </div>
</div> </div>
<!-- Step 4: Features Tour --> <!-- Tour Navigation -->
<div v-if="currentStep === 4" class="text-center"> <div class="flex items-center justify-between">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Découvrez les fonctionnalités</h3> <button
<div class="space-y-4"> @click="tourStep = Math.max(0, tourStep - 1)"
<div v-for="feature in features" :key="feature.title" class="border rounded-lg p-4 text-left flex items-center space-x-4"> :disabled="tourStep === 0"
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center"> class="px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
<span v-html="feature.icon"></span> >
← Précédent
</button>
<div class="flex space-x-2">
<div
v-for="i in 5"
:key="i"
class="w-2 h-2 rounded-full transition-all duration-300"
:class="tourStep === i - 1 ? 'bg-primary-600 w-6' : 'bg-gray-300'"
></div>
</div> </div>
<div>
<h4 class="font-semibold">{{ feature.title }}</h4> <button
<p class="text-sm text-gray-600">{{ feature.description }}</p> @click="tourStep = Math.min(4, tourStep + 1)"
:disabled="tourStep === 4"
class="px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Suivant →
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Step 5: Install App -->
<div v-if="currentStep === 5" class="w-full px-2 sm:px-0 text-center">
<h3 class="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
Ah et tiens... 📱
</h3>
<p class="text-sm sm:text-base text-gray-600 max-w-md mx-auto leading-relaxed mb-6 sm:mb-8">
Prends l'app, ce sera plus sympa sur mobile ! Accès rapide, notifications, et tout ça directement depuis ton téléphone.
</p>
<div class="bg-gradient-to-br from-primary-50 to-purple-50 border-2 border-primary-200 rounded-xl p-4 sm:p-6 max-w-md mx-auto mb-6">
<div class="flex items-center justify-center space-x-3 mb-4">
<div class="text-3xl">📱</div>
<div class="text-3xl"></div>
<div class="text-3xl"></div>
</div>
<p class="text-xs sm:text-sm text-gray-700 mb-4">
Une fois connecté, tu pourras installer l'app depuis le menu de ton profil
</p>
<button
v-if="deferredPrompt || isMobile"
@click="handleInstallApp"
class="w-full sm:w-auto bg-primary-600 hover:bg-primary-700 text-white font-medium py-3 px-6 rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105 text-sm sm:text-base"
>
<div class="flex items-center justify-center space-x-2">
<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>
</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é
</p>
</div>
</div> </div>
</StepTransition> </StepTransition>
</div> </div>
<!-- Navigation Buttons --> <!-- Navigation Buttons -->
<div class="flex items-center pt-6" :class="currentStep > 1 ? 'justify-between' : 'justify-end'"> <div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 sm:gap-4 pt-4 sm:pt-6" :class="currentStep > 1 ? 'sm:justify-between' : 'sm:justify-end'">
<button <button
v-if="currentStep > 1" v-if="currentStep > 1"
@click="previousStep" @click="previousStep"
class="btn-secondary" class="btn-secondary w-full sm:w-auto order-2 sm:order-1 text-sm sm:text-base py-2.5 sm:py-2"
:disabled="loading" :disabled="loading"
>Précédent</button> >
Précédent
</button>
<button <button
v-if="currentStep < totalSteps" v-if="currentStep < totalSteps"
@click="nextStep" @click="nextStep"
:disabled="!canProceed || loading" :disabled="!canProceed || loading"
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600" class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600 w-full sm:w-auto order-1 sm:order-2 text-sm sm:text-base py-2.5 sm:py-2"
:class="{ 'opacity-50 cursor-not-allowed': !canProceed }" :class="{ 'opacity-50 cursor-not-allowed': !canProceed || loading }"
>Suivant</button> >
Suivant
</button>
<button <button
v-if="currentStep === totalSteps" v-if="currentStep === totalSteps"
@click="handleRegister" @click="handleRegister"
:disabled="loading" :disabled="loading || !canProceed"
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600" class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600 w-full sm:w-auto order-1 sm:order-2 text-sm sm:text-base py-2.5 sm:py-2"
:class="{ 'opacity-50 cursor-not-allowed': loading || !canProceed }"
> >
<span v-if="loading">Création en cours...</span> <span v-if="loading">Création en cours...</span>
<span v-else>Créer mon compte</span> <span v-else>Créer mon compte</span>
</button> </button>
</div> </div>
<div v-if="error" class="mt-4 text-center text-red-600"> <div v-if="error" class="mt-4 text-center text-xs sm:text-sm text-red-600 px-2">
{{ error }} {{ error }}
</div> </div>
<!-- Login Link --> <!-- Login Link -->
<div class="mt-8 text-center"> <div class="mt-6 sm:mt-8 text-center pb-4 sm:pb-0">
<p class="text-sm text-gray-600"> <p class="text-xs sm:text-sm text-gray-600">
Déjà un compte ? Déjà un compte ?
<router-link to="/login" class="font-medium text-primary-600 hover:text-primary-500"> <router-link to="/login" class="font-medium text-primary-600 hover:text-primary-500 transition-colors">
Se connecter Se connecter
</router-link> </router-link>
</p> </p>
</div> </div>
<!-- PWA Install Tutorial -->
<PWAInstallTutorial
:show="showPWAInstructions"
:is-ios="isIOS"
:is-android="isAndroid"
:is-windows="isWindows"
:is-mac="isMac"
@close="showPWAInstructions = false"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import StepTransition from '@/components/StepTransition.vue' import StepTransition from '@/components/StepTransition.vue'
import PasswordStrength from '@/components/PasswordStrength.vue' import PasswordStrength from '@/components/PasswordStrength.vue'
import FormValidation from '@/components/FormValidation.vue' import FormValidation from '@/components/FormValidation.vue'
import PWAInstallTutorial from '@/components/PWAInstallTutorial.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
// Step management // Step management
const currentStep = ref(1) const currentStep = ref(1)
const totalSteps = 4 const totalSteps = 5
const tourStep = ref(0)
const deferredPrompt = ref(null)
// Form data // Form data
const form = ref({ const form = ref({
@@ -214,6 +423,10 @@ const canProceed = computed(() => {
function nextStep() { function nextStep() {
if (currentStep.value < totalSteps && canProceed.value) { if (currentStep.value < totalSteps && canProceed.value) {
currentStep.value++ currentStep.value++
// Reset tour step when entering step 4
if (currentStep.value === 4) {
tourStep.value = 0
}
} }
} }
@@ -242,4 +455,54 @@ async function handleRegister() {
} }
loading.value = false loading.value = false
} }
// PWA Installation
const isMobile = ref(false)
const showPWAInstructions = ref(false)
const isIOS = computed(() => /iPhone|iPad|iPod/i.test(navigator.userAgent))
const isAndroid = computed(() => /Android/i.test(navigator.userAgent))
const isWindows = computed(() => /Windows/i.test(navigator.userAgent))
const isMac = computed(() => /Macintosh|Mac OS/i.test(navigator.userAgent) && !isIOS.value)
function checkIfMobile() {
isMobile.value = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ||
window.innerWidth < 768
}
function handleBeforeInstallPrompt(e) {
e.preventDefault()
deferredPrompt.value = e
}
async function handleInstallApp() {
if (deferredPrompt.value) {
try {
deferredPrompt.value.prompt()
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') {
console.log('✅ PWA installée avec succès')
}
deferredPrompt.value = null
} catch (error) {
console.error('Erreur lors de l\'installation:', error)
}
} else {
// Si pas de beforeinstallprompt, afficher les instructions (mobile ou desktop)
showPWAInstructions.value = true
}
}
onMounted(() => {
checkIfMobile()
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('resize', checkIfMobile)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('resize', checkIfMobile)
})
</script> </script>

View File

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

View File

@@ -18,23 +18,23 @@
<div v-else> <div v-else>
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-4"> <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 space-y-4 sm:space-y-0">
<router-link to="/vlogs" class="text-primary-600 hover:text-primary-700 flex items-center"> <router-link to="/vlogs" class="text-primary-600 hover:text-primary-700 flex items-center">
<ArrowLeft class="w-4 h-4 mr-2" /> <ArrowLeft class="w-4 h-4 mr-2" />
Retour aux vlogs Retour aux vlogs
</router-link> </router-link>
<div v-if="canEdit" class="flex space-x-2"> <div v-if="canEdit" class="flex w-full sm:w-auto space-x-2">
<button <button
@click="showEditModal = true" @click="showEditModal = true"
class="btn-secondary" class="flex-1 sm:flex-none btn-secondary justify-center"
> >
<Edit class="w-4 h-4 mr-2" /> <Edit class="w-4 h-4 mr-2" />
Modifier Modifier
</button> </button>
<button <button
@click="deleteVlog" @click="deleteVlog"
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300" class="flex-1 sm:flex-none btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300 justify-center"
> >
<Trash2 class="w-4 h-4 mr-2" /> <Trash2 class="w-4 h-4 mr-2" />
Supprimer Supprimer
@@ -42,9 +42,9 @@
</div> </div>
</div> </div>
<h1 class="text-4xl font-bold text-gray-900 mb-4">{{ vlog.title }}</h1> <h1 class="text-2xl sm:text-4xl font-bold text-gray-900 mb-4">{{ vlog.title }}</h1>
<div class="flex items-center space-x-6 text-gray-600 mb-6"> <div class="flex flex-wrap items-center gap-4 text-sm sm:text-base text-gray-600 mb-6">
<div class="flex items-center"> <div class="flex items-center">
<img <img
v-if="vlog.author_avatar" v-if="vlog.author_avatar"
@@ -52,10 +52,10 @@
:alt="vlog.author_name" :alt="vlog.author_name"
class="w-8 h-8 rounded-full object-cover mr-3" class="w-8 h-8 rounded-full object-cover mr-3"
> >
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center mr-3"> <div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center mr-2">
<User class="w-4 h-4 text-primary-600" /> <User class="w-4 h-4 text-primary-600" />
</div> </div>
<span>Par {{ vlog.author_name }}</span> <span>{{ vlog.author_name }}</span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
@@ -68,6 +68,11 @@
<span>{{ vlog.views_count }} vue{{ vlog.views_count > 1 ? 's' : '' }}</span> <span>{{ vlog.views_count }} vue{{ vlog.views_count > 1 ? 's' : '' }}</span>
</div> </div>
<div class="flex items-center">
<RotateCcw class="w-4 h-4 mr-2" />
<span>{{ vlog.replays_count }} replay{{ vlog.replays_count > 1 ? 's' : '' }}</span>
</div>
<div v-if="vlog.duration" class="flex items-center"> <div v-if="vlog.duration" class="flex items-center">
<Clock class="w-4 h-4 mr-2" /> <Clock class="w-4 h-4 mr-2" />
<span>{{ formatDuration(vlog.duration) }}</span> <span>{{ formatDuration(vlog.duration) }}</span>
@@ -76,7 +81,7 @@
</div> </div>
<!-- Video Player --> <!-- Video Player -->
<div class="card p-6 mb-8"> <div class="card p-0 sm:p-6 mb-8 overflow-hidden sm:overflow-visible">
<VideoPlayer <VideoPlayer
:src="vlog.video_url" :src="vlog.video_url"
:poster="vlog.thumbnail_url" :poster="vlog.thumbnail_url"
@@ -84,6 +89,7 @@
:description="vlog.description" :description="vlog.description"
:duration="vlog.duration" :duration="vlog.duration"
:views-count="vlog.views_count" :views-count="vlog.views_count"
:replays-count="vlog.replays_count"
:likes-count="vlog.likes_count" :likes-count="vlog.likes_count"
:comments-count="vlog.comments?.length || 0" :comments-count="vlog.comments?.length || 0"
:is-liked="vlog.is_liked" :is-liked="vlog.is_liked"
@@ -93,13 +99,13 @@
</div> </div>
<!-- Description --> <!-- Description -->
<div v-if="vlog.description" class="card p-6 mb-8"> <div v-if="vlog.description" class="card p-4 sm:p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Description</h2> <h2 class="text-xl font-semibold text-gray-900 mb-4">Description</h2>
<p class="text-gray-700 whitespace-pre-wrap">{{ vlog.description }}</p> <p class="text-gray-700 whitespace-pre-wrap">{{ vlog.description }}</p>
</div> </div>
<!-- Comments Section --> <!-- Comments Section -->
<div class="card p-6 mb-8"> <div class="card p-4 sm:p-6 mb-8">
<VlogComments <VlogComments
:vlog-id="vlog.id" :vlog-id="vlog.id"
:comments="vlog.comments || []" :comments="vlog.comments || []"
@@ -222,8 +228,7 @@ import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl } from '@/utils/axios'
import { format, formatDistanceToNow } from 'date-fns' import { formatDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import { fr } from 'date-fns/locale'
import { import {
ArrowLeft, ArrowLeft,
User, User,
@@ -233,7 +238,8 @@ import {
Edit, Edit,
Trash2, Trash2,
Film, Film,
Play Play,
RotateCcw
} from 'lucide-vue-next' } from 'lucide-vue-next'
import VideoPlayer from '@/components/VideoPlayer.vue' import VideoPlayer from '@/components/VideoPlayer.vue'
import VlogComments from '@/components/VlogComments.vue' import VlogComments from '@/components/VlogComments.vue'
@@ -262,11 +268,11 @@ const canEdit = computed(() =>
) )
function formatDate(date) { function formatDate(date) {
return format(new Date(date), 'dd MMMM yyyy', { locale: fr }) return formatDateInFrenchTimezone(date, 'dd MMMM yyyy')
} }
function formatRelativeDate(date) { function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr }) return formatRelativeDateInFrenchTimezone(date)
} }
function formatDuration(seconds) { function formatDuration(seconds) {
@@ -279,8 +285,9 @@ function formatDuration(seconds) {
async function toggleLike() { async function toggleLike() {
try { try {
const response = await axios.post(`/api/vlogs/${vlog.value.id}/like`) const response = await axios.post(`/api/vlogs/${vlog.value.id}/like`)
// Refresh vlog data to get updated like count // Update local state without refreshing full vlog data (avoiding view increment)
await fetchVlog() vlog.value.likes_count = response.data.likes_count
vlog.value.is_liked = !vlog.value.is_liked
toast.success(response.data.message) toast.success(response.data.message)
} catch (error) { } catch (error) {
toast.error('Erreur lors de la mise à jour du like') toast.error('Erreur lors de la mise à jour du like')

View File

@@ -1,14 +1,14 @@
<template> <template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-center mb-8"> <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
<div> <div>
<h1 class="text-3xl font-bold text-gray-900">Vlogs</h1> <h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Vlogs</h1>
<p class="text-gray-600 mt-1">Partagez vos moments en vidéo avec le groupe</p> <p class="text-sm sm:text-base text-gray-600 mt-1">Partagez vos moments en vidéo avec le groupe</p>
</div> </div>
<button <button
@click="showCreateModal = true" @click="showCreateModal = true"
class="btn-primary" class="w-full sm:w-auto btn-primary justify-center"
> >
<Plus class="w-4 h-4 mr-2" /> <Plus class="w-4 h-4 mr-2" />
Nouveau vlog Nouveau vlog
@@ -267,9 +267,8 @@ import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatDistanceToNow } from 'date-fns' import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import { fr } from 'date-fns/locale'
import { import {
Plus, Plus,
Film, Film,
@@ -312,7 +311,7 @@ function formatDuration(seconds) {
} }
function formatRelativeDate(date) { function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr }) return formatRelativeDateInFrenchTimezone(date)
} }
function openVlog(vlog) { function openVlog(vlog) {
@@ -436,16 +435,13 @@ async function createVlog() {
formData.append('thumbnail', newVlog.value.thumbnailFile) formData.append('thumbnail', newVlog.value.thumbnailFile)
} }
const response = await axios.post('/api/vlogs/upload', formData, { const data = await uploadFormData('/api/vlogs/upload', formData)
headers: { 'Content-Type': 'multipart/form-data' } vlogs.value.unshift(data)
})
vlogs.value.unshift(response.data)
showCreateModal.value = false showCreateModal.value = false
resetForm() resetForm()
toast.success('Vlog créé avec succès') toast.success('Vlog créé avec succès')
} catch (error) { } catch (error) {
toast.error('Erreur lors de la création du vlog') toast.error(error.message || 'Erreur lors de la création du vlog')
} }
creating.value = false creating.value = false
} }

View File

@@ -2,6 +2,15 @@ const { defineConfig } = require('vite')
const vue = require('@vitejs/plugin-vue') const vue = require('@vitejs/plugin-vue')
const path = require('path') const path = require('path')
// Import conditionnel du plugin PWA
let VitePWA = null
try {
VitePWA = require('vite-plugin-pwa').VitePWA
} catch (e) {
console.warn('⚠️ vite-plugin-pwa n\'est pas installé. La fonctionnalité PWA sera désactivée.')
console.warn(' Installez-le avec: npm install --save-dev vite-plugin-pwa')
}
// Configuration par environnement // Configuration par environnement
const getEnvironmentConfig = (mode) => { const getEnvironmentConfig = (mode) => {
const configs = { const configs = {
@@ -79,8 +88,169 @@ module.exports = defineConfig(({ command, mode }) => {
VITE_APP_URL: process.env.VITE_APP_URL VITE_APP_URL: process.env.VITE_APP_URL
}) })
const plugins = [vue()]
// Ajouter le plugin PWA seulement s'il est installé
if (VitePWA) {
plugins.push(VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'logo_lediscord.png'],
manifest: {
name: 'LeDiscord - Notre espace',
short_name: 'LeDiscord',
description: 'Plateforme communautaire LeDiscord',
theme_color: '#6366f1',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
scope: '/',
start_url: '/',
icons: [
{
src: '/icon-72x72.png',
sizes: '72x72',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-96x96.png',
sizes: '96x96',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-128x128.png',
sizes: '128x128',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-144x144.png',
sizes: '144x144',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-152x152.png',
sizes: '152x152',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-384x384.png',
sizes: '384x384',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
],
shortcuts: [
{
name: 'Vlogs',
short_name: 'Vlogs',
description: 'Voir les vlogs',
url: '/vlogs',
icons: [{ src: '/icon-96x96.png', sizes: '96x96' }]
},
{
name: 'Albums',
short_name: 'Albums',
description: 'Voir les albums',
url: '/albums',
icons: [{ src: '/icon-96x96.png', sizes: '96x96' }]
}
],
categories: ['social', 'entertainment']
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,mp4}'],
// Notifications push
navigateFallback: null,
skipWaiting: true,
clientsClaim: true,
// Intégrer le service worker personnalisé
importScripts: ['/sw-custom.js'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'gstatic-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
// Intercepter uniquement les requêtes GET pour l'API
urlPattern: ({ url, request }) => {
const urlString = url.href || url.toString()
return /^https?:\/\/.*\/api\/.*/i.test(urlString) && request.method === 'GET'
},
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 5 // 5 minutes
},
networkTimeoutSeconds: 10
}
},
{
urlPattern: /^https?:\/\/.*\/uploads\/.*/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'uploads-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 7 // 7 days
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
},
devOptions: {
enabled: true,
type: 'module'
}
}))
}
return { return {
plugins: [vue()], plugins,
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src') '@': path.resolve(__dirname, './src')

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "LeDiscord",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

1
package.json Normal file
View File

@@ -0,0 +1 @@
{}