Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca168c34c | ||
|
|
8ff0f22682 | ||
|
|
0eea2f1a1d | ||
|
|
d68af8d5a1 | ||
|
|
97ae75c9bf | ||
|
|
f33dfd5ab7 | ||
|
|
658b7a9dda | ||
|
|
08810440e0 | ||
|
|
d63f2f9f51 | ||
|
|
02a54f5625 | ||
|
|
e32b1ce04e | ||
|
|
f63b204c5c | ||
|
|
127aef60e3 | ||
|
|
cb11a74d33 | ||
|
|
a3a1d3306d | ||
|
|
4756c34fd1 | ||
|
|
2b172c5d34 | ||
|
|
e4b9e354ef | ||
|
|
634b850a89 | ||
|
|
69a07c9e07 | ||
|
|
8f8e1046c1 | ||
|
|
d1bfa488a6 | ||
|
|
ec30db4c2d | ||
|
|
a47a70e608 | ||
|
|
dfeaecce73 | ||
|
|
5bbe05000e | ||
|
|
0020c13bfd | ||
|
|
3fbf372dae | ||
|
|
480ae1e40e | ||
|
|
857b136124 | ||
|
|
93c9935465 | ||
|
|
7cd468a859 | ||
|
|
eebd26bb7d | ||
|
|
414eb824f8 | ||
|
|
2319a8c0cb | ||
|
|
6cf379f01b | ||
|
|
b436e19087 | ||
|
|
ae3821a68e |
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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,40 +20,90 @@ 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
|
||||||
users = db.query(User).filter(User.is_active == True).all()
|
if event.is_private:
|
||||||
for user in users:
|
# Événement privé : inviter uniquement les utilisateurs sélectionnés
|
||||||
participation = EventParticipation(
|
invited_user_ids = event_data.invited_user_ids or []
|
||||||
event_id=event.id,
|
# Toujours inclure le créateur
|
||||||
user_id=user.id,
|
if current_user.id not in invited_user_ids:
|
||||||
status=ParticipationStatus.PENDING
|
invited_user_ids.append(current_user.id)
|
||||||
)
|
|
||||||
db.add(participation)
|
|
||||||
|
|
||||||
# Create notification
|
for user_id in invited_user_ids:
|
||||||
if user.id != current_user.id:
|
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||||||
notification = Notification(
|
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()
|
||||||
|
for user in users:
|
||||||
|
participation = EventParticipation(
|
||||||
|
event_id=event.id,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
type=NotificationType.EVENT_INVITATION,
|
status=ParticipationStatus.PENDING
|
||||||
title=f"Nouvel événement: {event.title}",
|
|
||||||
message=f"{current_user.full_name} a créé un nouvel événement",
|
|
||||||
link=f"/events/{event.id}"
|
|
||||||
)
|
)
|
||||||
db.add(notification)
|
db.add(participation)
|
||||||
|
|
||||||
# Send email notification (async task would be better)
|
# Create notification
|
||||||
try:
|
if user.id != current_user.id:
|
||||||
send_event_notification(user.email, event)
|
notif_title = f"Nouvel événement: {event.title}"
|
||||||
except:
|
notif_message = f"{current_user.full_name} a créé un nouvel événement"
|
||||||
pass # Don't fail if email sending fails
|
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
|
||||||
|
|
||||||
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
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
vlog.views_count += 1
|
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
|
||||||
|
|
||||||
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
|
||||||
like = VlogLike(vlog_id=vlog_id, user_id=current_user.id)
|
try:
|
||||||
db.add(like)
|
like = VlogLike(vlog_id=vlog_id, user_id=current_user.id)
|
||||||
vlog.likes_count += 1
|
db.add(like)
|
||||||
message = "Vlog liked"
|
vlog.likes_count += 1
|
||||||
|
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,16 +255,19 @@ 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"):
|
||||||
allowed_types = SettingsService.get_setting(db, "allowed_video_types",
|
# Fallback check for common video types if content_type is generic application/octet-stream
|
||||||
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
|
filename = video.filename.lower()
|
||||||
raise HTTPException(
|
if not (filename.endswith('.mp4') or filename.endswith('.mov') or filename.endswith('.webm') or filename.endswith('.mkv')):
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
allowed_types = SettingsService.get_setting(db, "allowed_video_types",
|
||||||
detail=f"Invalid video type. Allowed types: {', '.join(allowed_types)}"
|
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
|
||||||
)
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid video type. Allowed types: {', '.join(allowed_types)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Check file size
|
# 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,
|
||||||
|
|||||||
109
backend/app.py
@@ -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"])
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
45
backend/migrations/README.md
Normal 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
@@ -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()
|
||||||
|
|
||||||
29
backend/migrations/script.py.mako
Normal 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"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
54
backend/migrations/versions/0001_initial_schema.py
Normal 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)
|
||||||
|
|
||||||
46
backend/migrations/versions/0002_add_push_subscriptions.py
Normal 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')
|
||||||
|
|
||||||
|
|
||||||
@@ -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 ###
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
70
backend/scripts/generate_vapid_keys.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script pour générer des clés VAPID pour les notifications push.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/generate_vapid_keys.py
|
||||||
|
|
||||||
|
Les clés générées doivent être ajoutées aux variables d'environnement:
|
||||||
|
VAPID_PUBLIC_KEY=<clé_publique>
|
||||||
|
VAPID_PRIVATE_KEY=<clé_privée>
|
||||||
|
VAPID_CLAIMS_EMAIL=mailto:admin@example.com
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
except ImportError:
|
||||||
|
print("❌ cryptography n'est pas installé.")
|
||||||
|
print(" Installez-le avec: pip install cryptography")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_vapid_keys():
|
||||||
|
"""Génère une paire de clés VAPID au format base64url."""
|
||||||
|
# Générer une clé privée EC P-256
|
||||||
|
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||||
|
|
||||||
|
# Extraire la clé privée brute (32 octets)
|
||||||
|
private_numbers = private_key.private_numbers()
|
||||||
|
private_bytes = private_numbers.private_value.to_bytes(32, byteorder='big')
|
||||||
|
private_key_b64 = base64.urlsafe_b64encode(private_bytes).rstrip(b'=').decode('ascii')
|
||||||
|
|
||||||
|
# Extraire la clé publique non compressée (65 octets = 0x04 + X + Y)
|
||||||
|
public_key = private_key.public_key()
|
||||||
|
public_bytes = public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.X962,
|
||||||
|
format=serialization.PublicFormat.UncompressedPoint
|
||||||
|
)
|
||||||
|
public_key_b64 = base64.urlsafe_b64encode(public_bytes).rstrip(b'=').decode('ascii')
|
||||||
|
|
||||||
|
return public_key_b64, private_key_b64
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("🔐 Génération des clés VAPID pour Web Push...\n")
|
||||||
|
|
||||||
|
public_key, private_key = generate_vapid_keys()
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("Clés VAPID générées avec succès !")
|
||||||
|
print("=" * 70)
|
||||||
|
print()
|
||||||
|
print("Ajoutez ces variables à votre fichier .env ou configuration:")
|
||||||
|
print()
|
||||||
|
print(f'VAPID_PUBLIC_KEY={public_key}')
|
||||||
|
print()
|
||||||
|
print(f'VAPID_PRIVATE_KEY={private_key}')
|
||||||
|
print()
|
||||||
|
print('VAPID_CLAIMS_EMAIL=mailto:admin@lediscord.com')
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print()
|
||||||
|
print("⚠️ IMPORTANT:")
|
||||||
|
print(" - Gardez la clé privée SECRÈTE")
|
||||||
|
print(" - Les clés doivent être les mêmes en dev et prod")
|
||||||
|
print(" - Après changement de clés, les utilisateurs devront se réabonner")
|
||||||
44
backend/scripts/migrate.sh
Executable 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
136
backend/utils/push_service.py
Normal 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
|
||||||
|
|
||||||
@@ -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"},
|
||||||
)
|
)
|
||||||
token_data = verify_token(token, credentials_exception)
|
|
||||||
|
# Log si pas de token
|
||||||
|
if token is None:
|
||||||
|
print("❌ AUTH: No token provided in Authorization header")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated - no token provided",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log le token (premiers caractères seulement pour la sécurité)
|
||||||
|
print(f"🔐 AUTH: Token received, length={len(token)}, preview={token[:20]}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_data = verify_token(token, credentials_exception)
|
||||||
|
print(f"✅ AUTH: Token valid for user_id={token_data.user_id}")
|
||||||
|
except HTTPException as e:
|
||||||
|
print(f"❌ AUTH: Token validation failed - {e.detail}")
|
||||||
|
raise
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == token_data.user_id).first()
|
user = db.query(User).filter(User.id == token_data.user_id).first()
|
||||||
if user is None:
|
if user is None:
|
||||||
|
print(f"❌ AUTH: User not found for id={token_data.user_id}")
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
|
||||||
|
print(f"✅ AUTH: User authenticated: {user.username}")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
|||||||
@@ -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:
|
|
||||||
return int(value)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Nombres flottants
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# JSON
|
|
||||||
if value.startswith('{') or value.startswith('['):
|
|
||||||
try:
|
try:
|
||||||
return json.loads(value)
|
return int(setting.value)
|
||||||
except json.JSONDecodeError:
|
except ValueError:
|
||||||
pass
|
return default
|
||||||
|
elif isinstance(default, bool):
|
||||||
|
return setting.value.lower() == "true"
|
||||||
|
elif isinstance(default, list):
|
||||||
|
return setting.value.split(",")
|
||||||
|
|
||||||
# Liste séparée par des virgules
|
return setting.value
|
||||||
if ',' in value and not value.startswith('{') and not value.startswith('['):
|
|
||||||
return [item.strip() for item in value.split(',')]
|
|
||||||
|
|
||||||
return value
|
@staticmethod
|
||||||
|
def set_setting(db: Session, key: str, value: str, description: str = None, category: str = "general") -> SystemSettings:
|
||||||
|
"""Set a setting value."""
|
||||||
|
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
|
||||||
|
if setting:
|
||||||
|
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)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(setting)
|
||||||
|
return setting
|
||||||
|
|
||||||
@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/'):
|
||||||
max_size_mb = SettingsService.get_setting(db, "max_video_size_mb", 100)
|
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)
|
||||||
|
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):
|
||||||
return False
|
allowed = allowed.split(",")
|
||||||
|
return content_type in allowed
|
||||||
return content_type in allowed_types
|
return False
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1
frontend/dev-dist/registerSW.js
Normal 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
@@ -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} didn’t 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
|
||||||
1
frontend/dev-dist/sw.js.map
Normal file
4785
frontend/dev-dist/workbox-47da91e0.js
Normal file
1
frontend/dev-dist/workbox-47da91e0.js.map
Normal file
4757
frontend/dev-dist/workbox-52524643.js
Normal file
1
frontend/dev-dist/workbox-52524643.js.map
Normal file
4646
frontend/dev-dist/workbox-9be7f7ba.js
Normal file
1
frontend/dev-dist/workbox-9be7f7ba.js.map
Normal 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>
|
||||||
|
|||||||
8101
frontend/package-lock.json
generated
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/public/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
frontend/public/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
frontend/public/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
frontend/public/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
frontend/public/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
frontend/public/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
82
frontend/public/manifest.json
Normal 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" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
119
frontend/public/sw-custom.js
Normal 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)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
79
frontend/scripts/generate-icons.js
Normal 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()
|
||||||
|
|
||||||
@@ -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>
|
||||||
154
frontend/src/components/PWAInstallPrompt.vue
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
308
frontend/src/components/PWAInstallTutorial.vue
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 -->
|
||||||
<video
|
<div data-vjs-player>
|
||||||
ref="videoPlayer"
|
<video
|
||||||
class="video-js vjs-default-skin vjs-big-play-centered w-full rounded-lg"
|
ref="videoPlayer"
|
||||||
controls
|
class="video-js vjs-default-skin vjs-big-play-centered w-full rounded-lg"
|
||||||
preload="auto"
|
controls
|
||||||
:poster="posterUrl"
|
preload="auto"
|
||||||
data-setup="{}"
|
:poster="posterUrl"
|
||||||
>
|
playsinline
|
||||||
<source :src="videoUrl" type="video/mp4" />
|
>
|
||||||
<p class="vjs-no-js">
|
<source :src="videoUrl" />
|
||||||
Pour voir cette vidéo, activez JavaScript et considérez passer à un navigateur web qui
|
<p class="vjs-no-js">
|
||||||
<a href="https://videojs.com/html5-video-support/" target="_blank">supporte la vidéo HTML5</a>.
|
Pour voir cette vidéo, activez JavaScript et considérez passer à un navigateur web qui
|
||||||
</p>
|
<a href="https://videojs.com/html5-video-support/" target="_blank">supporte la vidéo HTML5</a>.
|
||||||
</video>
|
</p>
|
||||||
|
</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">
|
||||||
<Eye class="w-4 h-4 mr-1" />
|
<span class="flex items-center" title="Vues uniques">
|
||||||
{{ viewsCount }} vue{{ viewsCount > 1 ? 's' : '' }}
|
<Eye class="w-4 h-4 mr-1" />
|
||||||
</span>
|
{{ viewsCount }}
|
||||||
|
</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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
router.push(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
showNotifications.value = false
|
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 () => {
|
||||||
await authStore.fetchCurrentUser()
|
checkIfMobile()
|
||||||
await fetchNotifications()
|
|
||||||
await authStore.fetchUnreadCount()
|
// Restaurer la session si un token existe
|
||||||
|
if (authStore.token) {
|
||||||
|
await authStore.fetchCurrentUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
await fetchNotifications()
|
||||||
|
await authStore.fetchUnreadCount()
|
||||||
|
|
||||||
|
// Démarrer le polling des notifications
|
||||||
|
try {
|
||||||
|
const notificationService = (await import('@/services/notificationService')).default
|
||||||
|
notificationService.startPolling()
|
||||||
|
notificationService.setupServiceWorkerListener()
|
||||||
|
|
||||||
|
// Demander la permission pour les notifications push
|
||||||
|
// Sur iOS, attendre un peu pour s'assurer que la PWA est bien détectée
|
||||||
|
if (typeof notificationService.isIOS === 'function' && notificationService.isIOS()) {
|
||||||
|
console.log('🍎 iOS détecté, vérification de la PWA...')
|
||||||
|
// Attendre que la page soit complètement chargée et que la PWA soit détectée
|
||||||
|
setTimeout(async () => {
|
||||||
|
const isInstalled = notificationService.isPWAInstalled()
|
||||||
|
console.log('🍎 PWA installée:', isInstalled)
|
||||||
|
|
||||||
|
if (isInstalled) {
|
||||||
|
console.log('🍎 Demande de permission pour les notifications...')
|
||||||
|
await notificationService.requestNotificationPermission()
|
||||||
|
} else {
|
||||||
|
console.warn('🍎 PWA non installée - Les notifications ne fonctionneront pas sur iOS')
|
||||||
|
console.warn('🍎 Instructions: Ajouter l\'app à l\'écran d\'accueil, puis l\'ouvrir depuis l\'écran d\'accueil')
|
||||||
|
}
|
||||||
|
}, 2000) // Attendre 2 secondes pour iOS
|
||||||
|
} else {
|
||||||
|
await notificationService.requestNotificationPermission()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement du service de notifications:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si PWA est installée
|
||||||
|
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>
|
||||||
@@ -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')
|
||||||
|
|||||||
283
frontend/src/services/notificationService.js
Normal 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()
|
||||||
|
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')) {
|
||||||
localStorage.removeItem('token')
|
if (isRealAuthError) {
|
||||||
router.push('/login')
|
localStorage.removeItem('token')
|
||||||
toast.error('Session expirée, veuillez vous reconnecter')
|
router.push('/login')
|
||||||
|
toast.error('Session expirée, veuillez vous reconnecter')
|
||||||
|
} else {
|
||||||
|
// Erreur 401 non liée à l'auth (rare mais possible)
|
||||||
|
toast.error('Erreur d\'autorisation')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (error.response?.status === 403) {
|
} 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
79
frontend/src/utils/dateUtils.js
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 overallProgress = ((batchIndex * batchSize + batch.length) / newAlbum.value.media.length) * 100
|
||||||
const batchProgress = (progressEvent.loaded / progressEvent.total) * 100
|
uploadProgress.value = Math.min(overallProgress, 100)
|
||||||
const overallProgress = ((batchIndex * batchSize + batch.length) / newAlbum.value.media.length) * 100
|
|
||||||
uploadProgress.value = Math.min(overallProgress, 100)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mark batch as successful
|
// Mark batch as successful
|
||||||
batch.forEach((media, index) => {
|
batch.forEach((media, index) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
<label class="label">Coordonnées géographiques (optionnel)</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>
|
<div>
|
||||||
<label class="label">Date et heure</label>
|
<label class="label text-sm">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
|
||||||
|
console.log('🔵 creating.value = false')
|
||||||
}
|
}
|
||||||
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: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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" />
|
||||||
</div>
|
</svg>
|
||||||
|
<span>Les mots de passe ne correspondent pas</span>
|
||||||
|
</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">
|
||||||
</svg>
|
<p class="text-sm sm:text-base text-gray-700 leading-relaxed mb-4">
|
||||||
</div>
|
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> 💬
|
||||||
<div class="ml-3 text-left">
|
</p>
|
||||||
<p class="text-sm text-yellow-700">
|
<div class="flex items-center justify-center space-x-2 text-xs sm:text-sm text-gray-600">
|
||||||
LeDiscord est actuellement en version bêta. Votre retour est précieux pour nous aider à améliorer la plateforme.
|
<span>🔧</span>
|
||||||
</p>
|
<span>J'améliore au fur et à mesure</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs sm:text-sm text-gray-500 mt-3">
|
||||||
|
Il se trouve en bas à droite de l'écran une fois connecté
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 4: Features Tour -->
|
<!-- Step 4: Interactive Tour -->
|
||||||
<div v-if="currentStep === 4" class="text-center">
|
<div v-if="currentStep === 4" class="w-full px-2 sm:px-0">
|
||||||
<h3 class="text-2xl font-bold text-gray-900 mb-4">Découvrez les fonctionnalités</h3>
|
<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="space-y-4">
|
|
||||||
<div v-for="feature in features" :key="feature.title" class="border rounded-lg p-4 text-left flex items-center space-x-4">
|
<div class="max-w-lg mx-auto">
|
||||||
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
|
<!-- Tour Content -->
|
||||||
<span v-html="feature.icon"></span>
|
<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>
|
||||||
<div>
|
|
||||||
<h4 class="font-semibold">{{ feature.title }}</h4>
|
<div v-if="tourStep === 1" class="text-center">
|
||||||
<p class="text-sm text-gray-600">{{ feature.description }}</p>
|
<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>
|
||||||
|
|
||||||
|
<!-- Tour Navigation -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
@click="tourStep = Math.max(0, tourStep - 1)"
|
||||||
|
:disabled="tourStep === 0"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
← 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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@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>
|
||||||
|
|
||||||
|
<!-- 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>
|
</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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "LeDiscord",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
1
package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||