initial commit - LeDiscord plateforme des copains
This commit is contained in:
73
.dockerignore
Normal file
73
.dockerignore
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Node modules (pour le frontend)
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
postgres_data/
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
|
!uploads/.gitkeep
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
Dockerfile*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
107
.github/workflows/ci.yml
vendored
Normal file
107
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
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:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Run backend tests
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
python -m pytest tests/ -v
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/lediscord_test
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm run test:unit
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Run security scan
|
||||||
|
uses: snyk/actions/node@master
|
||||||
|
env:
|
||||||
|
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||||
|
with:
|
||||||
|
args: --severity-threshold=high
|
||||||
|
path: frontend/
|
||||||
|
|
||||||
|
- name: Run Python security scan
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
pip install safety
|
||||||
|
safety check
|
||||||
|
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test, security]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Build and test Docker images
|
||||||
|
run: |
|
||||||
|
docker-compose build
|
||||||
|
docker-compose up -d
|
||||||
|
sleep 30
|
||||||
|
curl -f http://localhost:8000/health || exit 1
|
||||||
|
curl -f http://localhost:5173 || exit 1
|
||||||
|
docker-compose down
|
||||||
107
.gitignore
vendored
Normal file
107
.gitignore
vendored
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# FastAPI
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
|
!uploads/.gitkeep
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Vue
|
||||||
|
.DS_Store
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
postgres_data/
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Production
|
||||||
|
.env.production
|
||||||
|
.env.staging
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.cache/
|
||||||
158
CONTRIBUTING.md
Normal file
158
CONTRIBUTING.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Guide de contribution - LeDiscord
|
||||||
|
|
||||||
|
## 🎯 Comment contribuer
|
||||||
|
|
||||||
|
Merci de votre intérêt pour contribuer à LeDiscord ! Ce guide vous aidera à comprendre comment participer au projet.
|
||||||
|
|
||||||
|
## 🚀 Démarrage rapide
|
||||||
|
|
||||||
|
### 1. Fork et clone
|
||||||
|
```bash
|
||||||
|
# Fork le projet sur GitHub
|
||||||
|
# Puis clonez votre fork
|
||||||
|
git clone https://github.com/votre-username/LeDiscord.git
|
||||||
|
cd LeDiscord
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configuration de l'environnement
|
||||||
|
```bash
|
||||||
|
# Copier le fichier d'environnement
|
||||||
|
cp env.example .env
|
||||||
|
|
||||||
|
# Éditer .env avec vos valeurs
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# Démarrer l'application
|
||||||
|
make start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Créer une branche
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/nom-de-votre-fonctionnalite
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Standards de code
|
||||||
|
|
||||||
|
### Backend (Python)
|
||||||
|
- **Formatage** : Utilisez `black` pour le formatage automatique
|
||||||
|
- **Linting** : Utilisez `flake8` pour la vérification du code
|
||||||
|
- **Types** : Utilisez les annotations de type Python
|
||||||
|
- **Tests** : Écrivez des tests pour toutes les nouvelles fonctionnalités
|
||||||
|
|
||||||
|
### Frontend (Vue.js)
|
||||||
|
- **Formatage** : Utilisez `prettier` pour le formatage automatique
|
||||||
|
- **Linting** : Utilisez `eslint` pour la vérification du code
|
||||||
|
- **Composition API** : Utilisez la Composition API de Vue 3
|
||||||
|
- **Tests** : Écrivez des tests unitaires pour les composants
|
||||||
|
|
||||||
|
## 🔧 Outils de développement
|
||||||
|
|
||||||
|
### Installation des outils
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
pip install black flake8 pytest
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend
|
||||||
|
npm install -g prettier eslint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commandes utiles
|
||||||
|
```bash
|
||||||
|
# Formatage automatique
|
||||||
|
make format
|
||||||
|
|
||||||
|
# Vérification du code
|
||||||
|
make lint
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Tests
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run test:unit
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Checklist avant de soumettre
|
||||||
|
|
||||||
|
- [ ] Code formaté avec les outils appropriés
|
||||||
|
- [ ] Tests passent
|
||||||
|
- [ ] Documentation mise à jour
|
||||||
|
- [ ] Pas de secrets ou de données sensibles dans le code
|
||||||
|
- [ ] Messages de commit clairs et descriptifs
|
||||||
|
|
||||||
|
## 🚀 Processus de contribution
|
||||||
|
|
||||||
|
### 1. Développement
|
||||||
|
- Développez votre fonctionnalité
|
||||||
|
- Écrivez des tests
|
||||||
|
- Mettez à jour la documentation
|
||||||
|
|
||||||
|
### 2. Tests
|
||||||
|
```bash
|
||||||
|
# Tests backend
|
||||||
|
make test-backend
|
||||||
|
|
||||||
|
# Tests frontend
|
||||||
|
make test-frontend
|
||||||
|
|
||||||
|
# Tests complets
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Commit et push
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: ajouter une nouvelle fonctionnalité"
|
||||||
|
git push origin feature/nom-de-votre-fonctionnalite
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Pull Request
|
||||||
|
- Créez une PR sur GitHub
|
||||||
|
- Décrivez clairement les changements
|
||||||
|
- Attendez la review
|
||||||
|
|
||||||
|
## 📚 Ressources utiles
|
||||||
|
|
||||||
|
- [Documentation FastAPI](https://fastapi.tiangolo.com/)
|
||||||
|
- [Documentation Vue.js 3](https://vuejs.org/)
|
||||||
|
- [Documentation Tailwind CSS](https://tailwindcss.com/)
|
||||||
|
- [Documentation Docker](https://docs.docker.com/)
|
||||||
|
|
||||||
|
## 🐛 Signaler un bug
|
||||||
|
|
||||||
|
1. Vérifiez que le bug n'a pas déjà été signalé
|
||||||
|
2. Créez une issue avec :
|
||||||
|
- Description claire du bug
|
||||||
|
- Étapes pour le reproduire
|
||||||
|
- Comportement attendu vs. observé
|
||||||
|
- Version de l'application
|
||||||
|
- Logs d'erreur si applicable
|
||||||
|
|
||||||
|
## 💡 Proposer une fonctionnalité
|
||||||
|
|
||||||
|
1. Créez une issue avec le label "enhancement"
|
||||||
|
2. Décrivez la fonctionnalité souhaitée
|
||||||
|
3. Expliquez pourquoi elle serait utile
|
||||||
|
4. Proposez une implémentation si possible
|
||||||
|
|
||||||
|
## 📞 Besoin d'aide ?
|
||||||
|
|
||||||
|
- Créez une issue avec le label "question"
|
||||||
|
- Consultez la documentation
|
||||||
|
- Rejoignez la communauté
|
||||||
|
|
||||||
|
## 🎉 Merci !
|
||||||
|
|
||||||
|
Votre contribution aide à améliorer LeDiscord pour tous les utilisateurs. Merci de participer au projet !
|
||||||
56
Makefile
Normal file
56
Makefile
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
.PHONY: help start stop restart logs clean build install
|
||||||
|
|
||||||
|
help: ## Afficher cette aide
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
start: ## Démarrer l'application
|
||||||
|
./start.sh
|
||||||
|
|
||||||
|
stop: ## Arrêter l'application
|
||||||
|
./stop.sh
|
||||||
|
|
||||||
|
restart: ## Redémarrer l'application
|
||||||
|
./stop.sh
|
||||||
|
./start.sh
|
||||||
|
|
||||||
|
logs: ## Afficher les logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
logs-backend: ## Afficher les logs du backend
|
||||||
|
docker compose logs -f backend
|
||||||
|
|
||||||
|
logs-frontend: ## Afficher les logs du frontend
|
||||||
|
docker compose logs -f frontend
|
||||||
|
|
||||||
|
logs-db: ## Afficher les logs de la base de données
|
||||||
|
docker compose logs -f postgres
|
||||||
|
|
||||||
|
build: ## Reconstruire les images Docker
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
clean: ## Nettoyer les conteneurs et volumes
|
||||||
|
docker compose down -v
|
||||||
|
rm -rf backend/__pycache__
|
||||||
|
rm -rf backend/**/__pycache__
|
||||||
|
|
||||||
|
install: ## Installer les dépendances localement (dev)
|
||||||
|
cd backend && pip install -r requirements.txt
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
dev-backend: ## Lancer le backend en mode développement
|
||||||
|
cd backend && uvicorn app:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
dev-frontend: ## Lancer le frontend en mode développement
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
shell-backend: ## Ouvrir un shell dans le conteneur backend
|
||||||
|
docker compose exec backend /bin/bash
|
||||||
|
|
||||||
|
shell-db: ## Ouvrir psql dans le conteneur PostgreSQL
|
||||||
|
docker compose exec postgres psql -U lediscord_user -d lediscord
|
||||||
|
|
||||||
|
backup-db: ## Sauvegarder la base de données
|
||||||
|
docker compose exec postgres pg_dump -U lediscord_user lediscord > backup_$$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
|
||||||
|
status: ## Afficher le statut des conteneurs
|
||||||
|
docker compose ps
|
||||||
265
README.md
Normal file
265
README.md
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
# LeDiscord - Plateforme communautaire pour groupe d'amis
|
||||||
|
|
||||||
|
## 🎯 Description
|
||||||
|
|
||||||
|
LeDiscord est une plateforme web privée pour gérer la vie sociale d'un groupe d'amis. Elle permet de :
|
||||||
|
|
||||||
|
- 📹 **Vlogs** : Partager des vlogs vidéo avec le groupe
|
||||||
|
- 📸 **Albums** : Créer des albums photos/vidéos pour les soirées et vacances
|
||||||
|
- 📅 **Événements** : Organiser des événements avec système de présence
|
||||||
|
- 💬 **Publications** : Poster des actualités et mentionner des amis
|
||||||
|
- 📊 **Statistiques** : Visualiser des stats fun sur le groupe
|
||||||
|
- 👥 **Taux de présence** : Suivre la participation aux événements
|
||||||
|
- 🔔 **Notifications** : Recevoir des alertes par email et dans l'app
|
||||||
|
- 🛡️ **Admin** : Dashboard pour gérer l'espace de stockage et les utilisateurs
|
||||||
|
- 🎫 **Tickets** : Système de support et de gestion des demandes
|
||||||
|
- ℹ️ **Informations** : Gestion d'informations publiques et privées
|
||||||
|
|
||||||
|
## 🚀 Technologies
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Python 3.11** avec FastAPI
|
||||||
|
- **PostgreSQL** pour la base de données
|
||||||
|
- **JWT** pour l'authentification
|
||||||
|
- **SQLAlchemy** comme ORM
|
||||||
|
- **Alembic** pour les migrations
|
||||||
|
- **Celery** pour les tâches asynchrones (notifications)
|
||||||
|
- **PIL (Pillow)** pour le traitement d'images
|
||||||
|
- **OpenCV** pour la génération de miniatures vidéo
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Vue.js 3** avec Composition API
|
||||||
|
- **Vite** pour le build
|
||||||
|
- **Tailwind CSS** pour le style
|
||||||
|
- **Pinia** pour la gestion d'état
|
||||||
|
- **Vue Router** pour la navigation
|
||||||
|
- **Axios** pour les appels API
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- **Docker** et Docker Compose
|
||||||
|
- **Volumes mappés** pour le stockage des médias
|
||||||
|
|
||||||
|
## 📁 Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
LeDiscord/
|
||||||
|
├── backend/
|
||||||
|
│ ├── api/routers/ # Routes API (auth, events, albums, etc.)
|
||||||
|
│ ├── models/ # Modèles SQLAlchemy
|
||||||
|
│ ├── schemas/ # Schémas Pydantic
|
||||||
|
│ ├── config/ # Configuration
|
||||||
|
│ ├── utils/ # Utilitaires (sécurité, email, etc.)
|
||||||
|
│ └── app.py # Application principale
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── views/ # Pages Vue
|
||||||
|
│ │ ├── layouts/ # Layouts de l'app
|
||||||
|
│ │ ├── stores/ # Stores Pinia
|
||||||
|
│ │ ├── router/ # Configuration du router
|
||||||
|
│ │ └── utils/ # Utilitaires
|
||||||
|
│ └── package.json
|
||||||
|
├── uploads/ # Dossier des médias uploadés (mappé sur NAS)
|
||||||
|
├── docker-compose.yml # Configuration Docker
|
||||||
|
├── Makefile # Commandes utiles
|
||||||
|
├── env.example # Variables d'environnement d'exemple
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Installation et démarrage
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
- Docker et Docker Compose installés
|
||||||
|
- Port 8000 (backend), 5173 (frontend) et 5432 (PostgreSQL) disponibles
|
||||||
|
- Git pour cloner le projet
|
||||||
|
|
||||||
|
### 1. **Cloner le projet**
|
||||||
|
```bash
|
||||||
|
git clone <votre-repo-github>
|
||||||
|
cd LeDiscord
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Configuration de l'environnement**
|
||||||
|
```bash
|
||||||
|
# Copier le fichier d'exemple
|
||||||
|
cp env.example .env
|
||||||
|
|
||||||
|
# Éditer le fichier .env avec vos valeurs
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
**Variables importantes à modifier :**
|
||||||
|
- `DB_PASSWORD` : Mot de passe fort pour la base de données
|
||||||
|
- `JWT_SECRET_KEY` : Clé secrète très longue et aléatoire
|
||||||
|
- `ADMIN_PASSWORD` : Mot de passe admin (à changer absolument !)
|
||||||
|
- `SMTP_USER` et `SMTP_PASSWORD` : Configuration email (optionnel)
|
||||||
|
|
||||||
|
### 3. **Démarrer l'application**
|
||||||
|
```bash
|
||||||
|
# Démarrer tous les services
|
||||||
|
make start
|
||||||
|
|
||||||
|
# Ou manuellement
|
||||||
|
docker-compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. **Accéder à l'application**
|
||||||
|
- **Frontend** : http://localhost:5173
|
||||||
|
- **API Docs** : http://localhost:8000/docs
|
||||||
|
- **Admin** : Se connecter avec les identifiants admin définis dans `.env`
|
||||||
|
|
||||||
|
## 👤 Compte administrateur par défaut
|
||||||
|
- Email : `admin@lediscord.com`
|
||||||
|
- Mot de passe : Celui défini dans votre fichier `.env`
|
||||||
|
|
||||||
|
## 🔧 Développement
|
||||||
|
|
||||||
|
### Commandes utiles (Makefile)
|
||||||
|
```bash
|
||||||
|
make help # Afficher l'aide
|
||||||
|
make start # Démarrer l'application
|
||||||
|
make stop # Arrêter l'application
|
||||||
|
make restart # Redémarrer l'application
|
||||||
|
make logs # Voir les logs
|
||||||
|
make build # Reconstruire les images Docker
|
||||||
|
make clean # Nettoyer les conteneurs et volumes
|
||||||
|
make install # Installer les dépendances localement
|
||||||
|
```
|
||||||
|
|
||||||
|
### Développement local
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
make dev-backend
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
make dev-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accès aux conteneurs
|
||||||
|
```bash
|
||||||
|
make shell-backend # Shell dans le conteneur backend
|
||||||
|
make shell-db # psql dans PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Fonctionnalités principales
|
||||||
|
|
||||||
|
### Authentification
|
||||||
|
- Inscription/Connexion avec JWT
|
||||||
|
- Tokens d'accès sécurisés
|
||||||
|
- Protection des routes
|
||||||
|
- Gestion des permissions admin/user
|
||||||
|
|
||||||
|
### Événements
|
||||||
|
- Création d'événements avec date, lieu, description
|
||||||
|
- Système de présence (présent/absent/peut-être)
|
||||||
|
- Notifications automatiques aux membres
|
||||||
|
- Taux de présence calculé automatiquement
|
||||||
|
|
||||||
|
### Albums & Médias
|
||||||
|
- Upload de photos/vidéos
|
||||||
|
- Albums liés aux événements
|
||||||
|
- Génération automatique de miniatures
|
||||||
|
- Gestion de l'espace de stockage
|
||||||
|
- Support multi-fichiers avec drag & drop
|
||||||
|
|
||||||
|
### Vlogs
|
||||||
|
- Upload de vidéos
|
||||||
|
- Compteur de vues
|
||||||
|
- Miniatures personnalisées
|
||||||
|
- Génération automatique de miniatures
|
||||||
|
|
||||||
|
### Publications
|
||||||
|
- Posts avec mentions d'utilisateurs
|
||||||
|
- Notifications lors des mentions
|
||||||
|
- Timeline chronologique
|
||||||
|
- Support des images
|
||||||
|
|
||||||
|
### Statistiques
|
||||||
|
- Taux de présence par utilisateur
|
||||||
|
- Membre le plus actif
|
||||||
|
- Statistiques fun personnalisables
|
||||||
|
|
||||||
|
### Administration
|
||||||
|
- Dashboard de gestion
|
||||||
|
- Monitoring de l'espace disque
|
||||||
|
- Gestion des utilisateurs
|
||||||
|
- Nettoyage des fichiers orphelins
|
||||||
|
- Gestion des tickets de support
|
||||||
|
|
||||||
|
### Tickets
|
||||||
|
- Système de support complet
|
||||||
|
- Gestion des priorités et statuts
|
||||||
|
- Upload de captures d'écran
|
||||||
|
- Notes administrateur
|
||||||
|
- Assignation aux admins
|
||||||
|
|
||||||
|
### Informations
|
||||||
|
- Gestion d'informations publiques/privées
|
||||||
|
- Système de catégories
|
||||||
|
- Priorités d'affichage
|
||||||
|
- Interface d'administration
|
||||||
|
|
||||||
|
## 🔐 Sécurité
|
||||||
|
|
||||||
|
- Authentification JWT
|
||||||
|
- Mots de passe hashés avec bcrypt
|
||||||
|
- Protection CORS configurée
|
||||||
|
- Validation des types de fichiers
|
||||||
|
- Limitation de la taille des uploads
|
||||||
|
- Permissions par rôle (admin/user)
|
||||||
|
- Variables d'environnement sécurisées
|
||||||
|
|
||||||
|
## 🚧 Roadmap / Améliorations futures
|
||||||
|
|
||||||
|
- [ ] Application mobile (React Native ou PWA)
|
||||||
|
- [ ] Notifications push sur mobile
|
||||||
|
- [ ] Chat en temps réel
|
||||||
|
- [ ] Calendrier partagé
|
||||||
|
- [ ] Sondages et votes
|
||||||
|
- [ ] Cagnotte pour les événements
|
||||||
|
- [ ] Export des photos d'un événement
|
||||||
|
- [ ] Mode sombre
|
||||||
|
- [ ] Système de likes/réactions
|
||||||
|
- [ ] Historique d'activité détaillé
|
||||||
|
|
||||||
|
## 📋 Checklist de déploiement
|
||||||
|
|
||||||
|
### Avant de commiter sur GitHub
|
||||||
|
- [ ] Vérifier que `.env` est dans `.gitignore`
|
||||||
|
- [ ] Vérifier que `uploads/` est dans `.gitignore`
|
||||||
|
- [ ] Vérifier que `postgres_data/` est dans `.gitignore`
|
||||||
|
- [ ] Tester que l'application démarre correctement
|
||||||
|
|
||||||
|
### Sur la nouvelle machine
|
||||||
|
- [ ] Cloner le repository
|
||||||
|
- [ ] Copier `env.example` vers `.env`
|
||||||
|
- [ ] Modifier les variables dans `.env`
|
||||||
|
- [ ] Lancer `make start`
|
||||||
|
- [ ] Vérifier l'accès à l'application
|
||||||
|
|
||||||
|
## 🐛 Dépannage
|
||||||
|
|
||||||
|
### Problèmes courants
|
||||||
|
1. **Ports déjà utilisés** : Vérifiez que 8000, 5173 et 5432 sont libres
|
||||||
|
2. **Permissions Docker** : Assurez-vous d'être dans le groupe docker
|
||||||
|
3. **Variables d'environnement** : Vérifiez que `.env` est correctement configuré
|
||||||
|
4. **Base de données** : Vérifiez que PostgreSQL peut démarrer
|
||||||
|
|
||||||
|
### Logs et debugging
|
||||||
|
```bash
|
||||||
|
make logs # Voir tous les logs
|
||||||
|
make logs-backend # Logs du backend uniquement
|
||||||
|
make logs-frontend # Logs du frontend uniquement
|
||||||
|
make logs-db # Logs de la base de données
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📄 Licence
|
||||||
|
|
||||||
|
Projet privé - Tous droits réservés
|
||||||
|
|
||||||
|
## 🤝 Contact
|
||||||
|
|
||||||
|
Pour toute question sur le projet, contactez l'administrateur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note** : Ce projet est conçu pour un usage privé entre amis. Assurez-vous de configurer correctement la sécurité avant tout déploiement en production.
|
||||||
29
backend/Dockerfile
Normal file
29
backend/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
libmagic1 \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libsm6 \
|
||||||
|
libxext6 \
|
||||||
|
libxrender1 \
|
||||||
|
libgomp1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create uploads directory
|
||||||
|
RUN mkdir -p /app/uploads
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Backend package initialization
|
||||||
3
backend/api/routers/__init__.py
Normal file
3
backend/api/routers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from . import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings, information, tickets
|
||||||
|
|
||||||
|
__all__ = ["auth", "users", "events", "albums", "posts", "vlogs", "stats", "admin", "notifications", "settings", "information", "tickets"]
|
||||||
444
backend/api/routers/admin.py
Normal file
444
backend/api/routers/admin.py
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
from typing import List
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from config.database import get_db
|
||||||
|
from config.settings import settings
|
||||||
|
from models.user import User
|
||||||
|
from models.album import Album, Media
|
||||||
|
from models.vlog import Vlog
|
||||||
|
from models.event import Event
|
||||||
|
from models.post import Post
|
||||||
|
from utils.security import get_admin_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def get_directory_size(path):
|
||||||
|
"""Calculate total size of a directory."""
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
for entry in os.scandir(path):
|
||||||
|
if entry.is_file():
|
||||||
|
total += entry.stat().st_size
|
||||||
|
elif entry.is_dir():
|
||||||
|
total += get_directory_size(entry.path)
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
pass
|
||||||
|
return total
|
||||||
|
|
||||||
|
def format_bytes(bytes):
|
||||||
|
"""Format bytes to human readable string."""
|
||||||
|
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||||
|
if bytes < 1024.0:
|
||||||
|
return f"{bytes:.2f} {unit}"
|
||||||
|
bytes /= 1024.0
|
||||||
|
return f"{bytes:.2f} PB"
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
async def get_admin_dashboard(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Get admin dashboard information."""
|
||||||
|
# User statistics
|
||||||
|
total_users = db.query(User).count()
|
||||||
|
active_users = db.query(User).filter(User.is_active == True).count()
|
||||||
|
admin_users = db.query(User).filter(User.is_admin == True).count()
|
||||||
|
|
||||||
|
# Content statistics
|
||||||
|
total_events = db.query(Event).count()
|
||||||
|
total_posts = db.query(Post).count()
|
||||||
|
total_vlogs = db.query(Vlog).count()
|
||||||
|
total_media = db.query(Media).count()
|
||||||
|
|
||||||
|
# Storage statistics
|
||||||
|
upload_path = Path(settings.UPLOAD_PATH)
|
||||||
|
total_storage = 0
|
||||||
|
storage_breakdown = {}
|
||||||
|
|
||||||
|
if upload_path.exists():
|
||||||
|
# Calculate storage by category
|
||||||
|
categories = ['avatars', 'albums', 'vlogs', 'posts']
|
||||||
|
for category in categories:
|
||||||
|
category_path = upload_path / category
|
||||||
|
if category_path.exists():
|
||||||
|
size = get_directory_size(category_path)
|
||||||
|
storage_breakdown[category] = {
|
||||||
|
"bytes": size,
|
||||||
|
"formatted": format_bytes(size)
|
||||||
|
}
|
||||||
|
total_storage += size
|
||||||
|
|
||||||
|
# Database storage
|
||||||
|
db_size = db.query(func.sum(Media.file_size)).scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"users": {
|
||||||
|
"total": total_users,
|
||||||
|
"active": active_users,
|
||||||
|
"admins": admin_users
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"events": total_events,
|
||||||
|
"posts": total_posts,
|
||||||
|
"vlogs": total_vlogs,
|
||||||
|
"media_files": total_media
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"total_bytes": total_storage,
|
||||||
|
"total_formatted": format_bytes(total_storage),
|
||||||
|
"breakdown": storage_breakdown,
|
||||||
|
"database_tracked": format_bytes(db_size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
async def get_all_users_admin(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Get all users with admin details."""
|
||||||
|
users = db.query(User).all()
|
||||||
|
|
||||||
|
user_list = []
|
||||||
|
for user in users:
|
||||||
|
# Calculate user storage
|
||||||
|
user_media = db.query(func.sum(Media.file_size)).join(Album).filter(
|
||||||
|
Album.creator_id == user.id
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
user_vlogs = db.query(func.sum(Vlog.id)).filter(
|
||||||
|
Vlog.author_id == user.id
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
user_list.append({
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"username": user.username,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"is_admin": user.is_admin,
|
||||||
|
"created_at": user.created_at,
|
||||||
|
"attendance_rate": user.attendance_rate,
|
||||||
|
"storage_used": format_bytes(user_media),
|
||||||
|
"content_count": {
|
||||||
|
"posts": db.query(Post).filter(Post.author_id == user.id).count(),
|
||||||
|
"vlogs": user_vlogs,
|
||||||
|
"albums": db.query(Album).filter(Album.creator_id == user.id).count()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return user_list
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/toggle-active")
|
||||||
|
async def toggle_user_active(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Toggle user active status."""
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot deactivate your own account"
|
||||||
|
)
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
user.is_active = not user.is_active
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"User {'activated' if user.is_active else 'deactivated'} successfully",
|
||||||
|
"is_active": user.is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/toggle-admin")
|
||||||
|
async def toggle_user_admin(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Toggle user admin status."""
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot change your own admin status"
|
||||||
|
)
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
user.is_admin = not user.is_admin
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"User admin status {'granted' if user.is_admin else 'revoked'} successfully",
|
||||||
|
"is_admin": user.is_admin
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Delete a user and all their content."""
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot delete your own account"
|
||||||
|
)
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete user's files
|
||||||
|
user_dirs = [
|
||||||
|
Path(settings.UPLOAD_PATH) / "avatars" / str(user_id),
|
||||||
|
Path(settings.UPLOAD_PATH) / "vlogs" / str(user_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
for dir_path in user_dirs:
|
||||||
|
if dir_path.exists():
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(dir_path)
|
||||||
|
|
||||||
|
# Delete user (cascade will handle related records)
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "User deleted successfully"}
|
||||||
|
|
||||||
|
@router.get("/storage/cleanup")
|
||||||
|
async def cleanup_storage(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Find and optionally clean up orphaned files."""
|
||||||
|
upload_path = Path(settings.UPLOAD_PATH)
|
||||||
|
orphaned_files = []
|
||||||
|
total_orphaned_size = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check for orphaned media files
|
||||||
|
if (upload_path / "albums").exists():
|
||||||
|
for album_dir in (upload_path / "albums").iterdir():
|
||||||
|
if album_dir.is_dir():
|
||||||
|
try:
|
||||||
|
album_id = int(album_dir.name)
|
||||||
|
album = db.query(Album).filter(Album.id == album_id).first()
|
||||||
|
|
||||||
|
if not album:
|
||||||
|
# Entire album directory is orphaned
|
||||||
|
size = get_directory_size(album_dir)
|
||||||
|
orphaned_files.append({
|
||||||
|
"path": str(album_dir),
|
||||||
|
"type": "album_directory",
|
||||||
|
"size": format_bytes(size)
|
||||||
|
})
|
||||||
|
total_orphaned_size += size
|
||||||
|
else:
|
||||||
|
# Check individual files
|
||||||
|
for file_path in album_dir.glob("*"):
|
||||||
|
if file_path.is_file():
|
||||||
|
relative_path = f"/albums/{album_id}/{file_path.name}"
|
||||||
|
media = db.query(Media).filter(
|
||||||
|
(Media.file_path == relative_path) |
|
||||||
|
(Media.thumbnail_path == relative_path)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not media:
|
||||||
|
size = file_path.stat().st_size
|
||||||
|
orphaned_files.append({
|
||||||
|
"path": str(file_path),
|
||||||
|
"type": "media_file",
|
||||||
|
"size": format_bytes(size)
|
||||||
|
})
|
||||||
|
total_orphaned_size += size
|
||||||
|
except (ValueError, OSError) as e:
|
||||||
|
print(f"Error processing album directory {album_dir}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for orphaned avatar files
|
||||||
|
if (upload_path / "avatars").exists():
|
||||||
|
for user_dir in (upload_path / "avatars").iterdir():
|
||||||
|
if user_dir.is_dir():
|
||||||
|
try:
|
||||||
|
user_id = int(user_dir.name)
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
size = get_directory_size(user_dir)
|
||||||
|
orphaned_files.append({
|
||||||
|
"path": str(user_dir),
|
||||||
|
"type": "user_avatar_directory",
|
||||||
|
"size": format_bytes(size)
|
||||||
|
})
|
||||||
|
total_orphaned_size += size
|
||||||
|
except (ValueError, OSError) as e:
|
||||||
|
print(f"Error processing avatar directory {user_dir}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for orphaned vlog files
|
||||||
|
if (upload_path / "vlogs").exists():
|
||||||
|
for user_dir in (upload_path / "vlogs").iterdir():
|
||||||
|
if user_dir.is_dir():
|
||||||
|
try:
|
||||||
|
user_id = int(user_dir.name)
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
size = get_directory_size(user_dir)
|
||||||
|
orphaned_files.append({
|
||||||
|
"path": str(user_dir),
|
||||||
|
"type": "user_vlog_directory",
|
||||||
|
"size": format_bytes(size)
|
||||||
|
})
|
||||||
|
total_orphaned_size += size
|
||||||
|
except (ValueError, OSError) as e:
|
||||||
|
print(f"Error processing vlog directory {user_dir}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return {
|
||||||
|
"orphaned_files": orphaned_files,
|
||||||
|
"total_orphaned": len(orphaned_files),
|
||||||
|
"total_orphaned_size": format_bytes(total_orphaned_size),
|
||||||
|
"message": f"Trouvé {len(orphaned_files)} fichier(s) orphelin(s) pour un total de {format_bytes(total_orphaned_size)}. Utilisez DELETE pour les supprimer."
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during cleanup scan: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error during cleanup scan: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/storage/cleanup")
|
||||||
|
async def delete_orphaned_files(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Delete orphaned files."""
|
||||||
|
upload_path = Path(settings.UPLOAD_PATH)
|
||||||
|
deleted_files = []
|
||||||
|
total_freed_space = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check for orphaned media files
|
||||||
|
if (upload_path / "albums").exists():
|
||||||
|
for album_dir in (upload_path / "albums").iterdir():
|
||||||
|
if album_dir.is_dir():
|
||||||
|
try:
|
||||||
|
album_id = int(album_dir.name)
|
||||||
|
album = db.query(Album).filter(Album.id == album_id).first()
|
||||||
|
|
||||||
|
if not album:
|
||||||
|
# Entire album directory is orphaned
|
||||||
|
size = get_directory_size(album_dir)
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(album_dir)
|
||||||
|
deleted_files.append({
|
||||||
|
"path": str(album_dir),
|
||||||
|
"type": "album_directory",
|
||||||
|
"size": format_bytes(size)
|
||||||
|
})
|
||||||
|
total_freed_space += size
|
||||||
|
else:
|
||||||
|
# Check individual files
|
||||||
|
for file_path in album_dir.glob("*"):
|
||||||
|
if file_path.is_file():
|
||||||
|
relative_path = f"/albums/{album_id}/{file_path.name}"
|
||||||
|
media = db.query(Media).filter(
|
||||||
|
(Media.file_path == relative_path) |
|
||||||
|
(Media.thumbnail_path == relative_path)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not media:
|
||||||
|
size = file_path.stat().st_size
|
||||||
|
file_path.unlink() # Delete the file
|
||||||
|
deleted_files.append({
|
||||||
|
"path": str(file_path),
|
||||||
|
"type": "media_file",
|
||||||
|
"size": format_bytes(size)
|
||||||
|
})
|
||||||
|
total_freed_space += size
|
||||||
|
except (ValueError, OSError) as e:
|
||||||
|
print(f"Error processing album directory {album_dir}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for orphaned avatar files
|
||||||
|
if (upload_path / "avatars").exists():
|
||||||
|
for user_dir in (upload_path / "avatars").iterdir():
|
||||||
|
if user_dir.is_dir():
|
||||||
|
try:
|
||||||
|
user_id = int(user_dir.name)
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
# User directory is orphaned
|
||||||
|
size = get_directory_size(user_dir)
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(user_dir)
|
||||||
|
deleted_files.append({
|
||||||
|
"path": str(user_dir),
|
||||||
|
"type": "user_avatar_directory",
|
||||||
|
"size": format_bytes(size)
|
||||||
|
})
|
||||||
|
total_freed_space += size
|
||||||
|
except (ValueError, OSError) as e:
|
||||||
|
print(f"Error processing avatar directory {user_dir}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for orphaned vlog files
|
||||||
|
if (upload_path / "vlogs").exists():
|
||||||
|
for user_dir in (upload_path / "vlogs").iterdir():
|
||||||
|
if user_dir.is_dir():
|
||||||
|
try:
|
||||||
|
user_id = int(user_dir.name)
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
# User directory is orphaned
|
||||||
|
size = get_directory_size(user_dir)
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(user_dir)
|
||||||
|
deleted_files.append({
|
||||||
|
"path": str(user_dir),
|
||||||
|
"type": "user_vlog_directory",
|
||||||
|
"size": format_bytes(size)
|
||||||
|
})
|
||||||
|
total_freed_space += size
|
||||||
|
except (ValueError, OSError) as e:
|
||||||
|
print(f"Error processing vlog directory {user_dir}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Cleanup completed successfully",
|
||||||
|
"deleted_files": deleted_files,
|
||||||
|
"total_deleted": len(deleted_files),
|
||||||
|
"total_freed_space": format_bytes(total_freed_space),
|
||||||
|
"details": f"Supprimé {len(deleted_files)} fichier(s) pour libérer {format_bytes(total_freed_space)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during cleanup: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error during cleanup: {str(e)}"
|
||||||
|
)
|
||||||
464
backend/api/routers/albums.py
Normal file
464
backend/api/routers/albums.py
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
import magic
|
||||||
|
from config.database import get_db
|
||||||
|
from config.settings import settings
|
||||||
|
from models.album import Album, Media, MediaType, MediaLike
|
||||||
|
from models.user import User
|
||||||
|
from schemas.album import AlbumCreate, AlbumUpdate, AlbumResponse
|
||||||
|
from utils.security import get_current_active_user
|
||||||
|
from utils.settings_service import SettingsService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/", response_model=AlbumResponse)
|
||||||
|
async def create_album(
|
||||||
|
album_data: AlbumCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Create a new album."""
|
||||||
|
album = Album(
|
||||||
|
**album_data.dict(),
|
||||||
|
creator_id=current_user.id
|
||||||
|
)
|
||||||
|
db.add(album)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(album)
|
||||||
|
return format_album_response(album, db, current_user.id)
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[AlbumResponse])
|
||||||
|
async def get_albums(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
event_id: Optional[int] = None
|
||||||
|
):
|
||||||
|
"""Get all albums, optionally filtered by event."""
|
||||||
|
query = db.query(Album)
|
||||||
|
if event_id:
|
||||||
|
query = query.filter(Album.event_id == event_id)
|
||||||
|
albums = query.order_by(Album.created_at.desc()).all()
|
||||||
|
return [format_album_response(album, db, current_user.id) for album in albums]
|
||||||
|
|
||||||
|
@router.get("/{album_id}", response_model=AlbumResponse)
|
||||||
|
async def get_album(
|
||||||
|
album_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get a specific album."""
|
||||||
|
album = db.query(Album).filter(Album.id == album_id).first()
|
||||||
|
if not album:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Album not found"
|
||||||
|
)
|
||||||
|
return format_album_response(album, db, current_user.id)
|
||||||
|
|
||||||
|
@router.put("/{album_id}", response_model=AlbumResponse)
|
||||||
|
async def update_album(
|
||||||
|
album_id: int,
|
||||||
|
album_update: AlbumUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Update an album."""
|
||||||
|
album = db.query(Album).filter(Album.id == album_id).first()
|
||||||
|
if not album:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Album not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if album.creator_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to update this album"
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, value in album_update.dict(exclude_unset=True).items():
|
||||||
|
setattr(album, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(album)
|
||||||
|
return format_album_response(album, db, current_user.id)
|
||||||
|
|
||||||
|
@router.delete("/{album_id}")
|
||||||
|
async def delete_album(
|
||||||
|
album_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Delete an album."""
|
||||||
|
album = db.query(Album).filter(Album.id == album_id).first()
|
||||||
|
if not album:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Album not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if album.creator_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to delete this album"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete media files
|
||||||
|
for media in album.media:
|
||||||
|
try:
|
||||||
|
if media.file_path:
|
||||||
|
os.remove(settings.UPLOAD_PATH + media.file_path)
|
||||||
|
if media.thumbnail_path:
|
||||||
|
os.remove(settings.UPLOAD_PATH + media.thumbnail_path)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.delete(album)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Album deleted successfully"}
|
||||||
|
|
||||||
|
@router.post("/{album_id}/media")
|
||||||
|
async def upload_media(
|
||||||
|
album_id: int,
|
||||||
|
files: List[UploadFile] = File(...),
|
||||||
|
captions: Optional[List[str]] = Form(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Upload media files to an album."""
|
||||||
|
album = db.query(Album).filter(Album.id == album_id).first()
|
||||||
|
if not album:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Album not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if album.creator_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to add media to this album"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check max media per album limit
|
||||||
|
from utils.settings_service import SettingsService
|
||||||
|
max_media_per_album = SettingsService.get_setting(db, "max_media_per_album", 50)
|
||||||
|
current_media_count = len(album.media)
|
||||||
|
|
||||||
|
if current_media_count + len(files) > max_media_per_album:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Album would exceed maximum of {max_media_per_album} media files. Current: {current_media_count}, trying to add: {len(files)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create album directory
|
||||||
|
album_dir = Path(settings.UPLOAD_PATH) / "albums" / str(album_id)
|
||||||
|
album_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
thumbnails_dir = album_dir / "thumbnails"
|
||||||
|
thumbnails_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
uploaded_media = []
|
||||||
|
|
||||||
|
for i, file in enumerate(files):
|
||||||
|
# Check file size
|
||||||
|
contents = await file.read()
|
||||||
|
file_size = len(contents)
|
||||||
|
|
||||||
|
# Get dynamic upload limits
|
||||||
|
max_size = SettingsService.get_max_upload_size(db, file.content_type or "unknown")
|
||||||
|
if file_size > max_size:
|
||||||
|
max_size_mb = max_size // (1024 * 1024)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"File {file.filename} exceeds maximum size of {max_size_mb}MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check file type
|
||||||
|
mime = magic.from_buffer(contents, mime=True)
|
||||||
|
if not SettingsService.is_file_type_allowed(db, mime):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File {file.filename} has unsupported type: {mime}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine media type
|
||||||
|
if mime.startswith('image/'):
|
||||||
|
media_type = MediaType.IMAGE
|
||||||
|
elif mime.startswith('video/'):
|
||||||
|
media_type = MediaType.VIDEO
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File {file.filename} has unsupported type: {mime}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
file_extension = file.filename.split(".")[-1]
|
||||||
|
filename = f"{uuid.uuid4()}.{file_extension}"
|
||||||
|
file_path = album_dir / filename
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(contents)
|
||||||
|
|
||||||
|
# Process media
|
||||||
|
thumbnail_path = None
|
||||||
|
width = height = duration = None
|
||||||
|
|
||||||
|
if media_type == MediaType.IMAGE:
|
||||||
|
# Get image dimensions and create thumbnail
|
||||||
|
try:
|
||||||
|
img = Image.open(file_path)
|
||||||
|
width, height = img.size
|
||||||
|
|
||||||
|
# Create thumbnail with better quality
|
||||||
|
thumbnail = img.copy()
|
||||||
|
thumbnail.thumbnail((800, 800)) # Larger thumbnail for better quality
|
||||||
|
thumbnail_filename = f"thumb_{filename}"
|
||||||
|
thumbnail_path_full = thumbnails_dir / thumbnail_filename
|
||||||
|
thumbnail.save(thumbnail_path_full, quality=95, optimize=True) # High quality
|
||||||
|
thumbnail_path = f"/albums/{album_id}/thumbnails/{thumbnail_filename}"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing image: {e}")
|
||||||
|
|
||||||
|
# Create media record
|
||||||
|
media = Media(
|
||||||
|
album_id=album_id,
|
||||||
|
file_path=f"/albums/{album_id}/{filename}",
|
||||||
|
thumbnail_path=thumbnail_path,
|
||||||
|
media_type=media_type,
|
||||||
|
caption=captions[i] if captions and i < len(captions) else None,
|
||||||
|
file_size=file_size,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
db.add(media)
|
||||||
|
uploaded_media.append(media)
|
||||||
|
|
||||||
|
# Set first image as album cover if not set
|
||||||
|
if not album.cover_image and uploaded_media:
|
||||||
|
first_image = next((m for m in uploaded_media if m.media_type == MediaType.IMAGE), None)
|
||||||
|
if first_image:
|
||||||
|
album.cover_image = first_image.thumbnail_path or first_image.file_path
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Update event cover image if this album is linked to an event
|
||||||
|
if album.event_id:
|
||||||
|
update_event_cover_from_album(album.event_id, db)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Successfully uploaded {len(uploaded_media)} files",
|
||||||
|
"media_count": len(uploaded_media)
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.delete("/{album_id}/media/{media_id}")
|
||||||
|
async def delete_media(
|
||||||
|
album_id: int,
|
||||||
|
media_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Delete a media file from an album."""
|
||||||
|
media = db.query(Media).filter(
|
||||||
|
Media.id == media_id,
|
||||||
|
Media.album_id == album_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not media:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Media not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
album = media.album
|
||||||
|
if album.creator_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to delete this media"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete files
|
||||||
|
try:
|
||||||
|
if media.file_path:
|
||||||
|
os.remove(settings.UPLOAD_PATH + media.file_path)
|
||||||
|
if media.thumbnail_path:
|
||||||
|
os.remove(settings.UPLOAD_PATH + media.thumbnail_path)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.delete(media)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Update event cover image if this album is linked to an event
|
||||||
|
if album.event_id:
|
||||||
|
update_event_cover_from_album(album.event_id, db)
|
||||||
|
|
||||||
|
return {"message": "Media deleted successfully"}
|
||||||
|
|
||||||
|
@router.post("/{album_id}/media/{media_id}/like")
|
||||||
|
async def toggle_media_like(
|
||||||
|
album_id: int,
|
||||||
|
media_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Toggle like on a media file."""
|
||||||
|
media = db.query(Media).filter(
|
||||||
|
Media.id == media_id,
|
||||||
|
Media.album_id == album_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not media:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Media not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_like = db.query(MediaLike).filter(
|
||||||
|
MediaLike.media_id == media_id,
|
||||||
|
MediaLike.user_id == current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_like:
|
||||||
|
# Unlike
|
||||||
|
db.delete(existing_like)
|
||||||
|
media.likes_count = max(0, media.likes_count - 1)
|
||||||
|
message = "Like removed"
|
||||||
|
else:
|
||||||
|
# Like
|
||||||
|
like = MediaLike(media_id=media_id, user_id=current_user.id)
|
||||||
|
db.add(like)
|
||||||
|
media.likes_count += 1
|
||||||
|
message = "Media liked"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Update event cover image if this album is linked to an event
|
||||||
|
album = media.album
|
||||||
|
if album.event_id:
|
||||||
|
update_event_cover_from_album(album.event_id, db)
|
||||||
|
|
||||||
|
return {"message": message, "likes_count": media.likes_count}
|
||||||
|
|
||||||
|
def format_album_response(album: Album, db: Session, current_user_id: int) -> dict:
|
||||||
|
"""Format album response with additional information."""
|
||||||
|
# Get top media (most liked)
|
||||||
|
top_media = db.query(Media).filter(
|
||||||
|
Media.album_id == album.id
|
||||||
|
).order_by(Media.likes_count.desc()).limit(3).all()
|
||||||
|
|
||||||
|
# Format media with likes information
|
||||||
|
formatted_media = []
|
||||||
|
for media in album.media:
|
||||||
|
# Check if current user liked this media
|
||||||
|
is_liked = db.query(MediaLike).filter(
|
||||||
|
MediaLike.media_id == media.id,
|
||||||
|
MediaLike.user_id == current_user_id
|
||||||
|
).first() is not None
|
||||||
|
|
||||||
|
# Format likes
|
||||||
|
likes = []
|
||||||
|
for like in media.likes:
|
||||||
|
user = db.query(User).filter(User.id == like.user_id).first()
|
||||||
|
if user:
|
||||||
|
likes.append({
|
||||||
|
"id": like.id,
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"avatar_url": user.avatar_url,
|
||||||
|
"created_at": like.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
formatted_media.append({
|
||||||
|
"id": media.id,
|
||||||
|
"file_path": media.file_path,
|
||||||
|
"thumbnail_path": media.thumbnail_path,
|
||||||
|
"media_type": media.media_type,
|
||||||
|
"caption": media.caption,
|
||||||
|
"file_size": media.file_size,
|
||||||
|
"width": media.width,
|
||||||
|
"height": media.height,
|
||||||
|
"duration": media.duration,
|
||||||
|
"likes_count": media.likes_count,
|
||||||
|
"is_liked": is_liked,
|
||||||
|
"likes": likes,
|
||||||
|
"created_at": media.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
# Format top media
|
||||||
|
formatted_top_media = []
|
||||||
|
for media in top_media:
|
||||||
|
is_liked = db.query(MediaLike).filter(
|
||||||
|
MediaLike.media_id == media.id,
|
||||||
|
MediaLike.user_id == current_user_id
|
||||||
|
).first() is not None
|
||||||
|
|
||||||
|
likes = []
|
||||||
|
for like in media.likes:
|
||||||
|
user = db.query(User).filter(User.id == like.user_id).first()
|
||||||
|
if user:
|
||||||
|
likes.append({
|
||||||
|
"id": like.id,
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"avatar_url": user.avatar_url,
|
||||||
|
"created_at": like.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
formatted_top_media.append({
|
||||||
|
"id": media.id,
|
||||||
|
"file_path": media.file_path,
|
||||||
|
"thumbnail_path": media.thumbnail_path,
|
||||||
|
"media_type": media.media_type,
|
||||||
|
"caption": media.caption,
|
||||||
|
"file_size": media.file_size,
|
||||||
|
"width": media.width,
|
||||||
|
"height": media.height,
|
||||||
|
"duration": media.duration,
|
||||||
|
"likes_count": media.likes_count,
|
||||||
|
"is_liked": is_liked,
|
||||||
|
"likes": likes,
|
||||||
|
"created_at": media.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": album.id,
|
||||||
|
"title": album.title,
|
||||||
|
"description": album.description,
|
||||||
|
"creator_id": album.creator_id,
|
||||||
|
"event_id": album.event_id,
|
||||||
|
"cover_image": album.cover_image,
|
||||||
|
"created_at": album.created_at,
|
||||||
|
"updated_at": album.updated_at,
|
||||||
|
"creator_name": album.creator.full_name,
|
||||||
|
"media_count": len(album.media),
|
||||||
|
"media": formatted_media,
|
||||||
|
"top_media": formatted_top_media,
|
||||||
|
"event_title": album.event.title if album.event else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_event_cover_from_album(event_id: int, db: Session):
|
||||||
|
"""Update event cover image from the most liked media in linked albums."""
|
||||||
|
from models.event import Event
|
||||||
|
|
||||||
|
event = db.query(Event).filter(Event.id == event_id).first()
|
||||||
|
if not event:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the most liked media from all albums linked to this event
|
||||||
|
most_liked_media = db.query(Media).join(Album).filter(
|
||||||
|
Album.event_id == event_id,
|
||||||
|
Media.media_type == MediaType.IMAGE
|
||||||
|
).order_by(Media.likes_count.desc()).first()
|
||||||
|
|
||||||
|
if most_liked_media:
|
||||||
|
event.cover_image = most_liked_media.thumbnail_path or most_liked_media.file_path
|
||||||
|
db.commit()
|
||||||
97
backend/api/routers/auth.py
Normal file
97
backend/api/routers/auth.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import timedelta
|
||||||
|
from config.database import get_db
|
||||||
|
from config.settings import settings
|
||||||
|
from models.user import User
|
||||||
|
from schemas.user import UserCreate, UserResponse, Token
|
||||||
|
from utils.security import verify_password, get_password_hash, create_access_token
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/register", response_model=Token)
|
||||||
|
async def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Register a new user."""
|
||||||
|
# Check if registration is enabled
|
||||||
|
from utils.settings_service import SettingsService
|
||||||
|
if not SettingsService.get_setting(db, "enable_registration", True):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Registration is currently disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if max users limit is reached
|
||||||
|
max_users = SettingsService.get_setting(db, "max_users", 50)
|
||||||
|
current_users_count = db.query(User).count()
|
||||||
|
if current_users_count >= max_users:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Maximum number of users reached"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if email already exists
|
||||||
|
if db.query(User).filter(User.email == user_data.email).first():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Email already registered"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if username already exists
|
||||||
|
if db.query(User).filter(User.username == user_data.username).first():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Username already taken"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new user
|
||||||
|
user = User(
|
||||||
|
email=user_data.email,
|
||||||
|
username=user_data.username,
|
||||||
|
full_name=user_data.full_name,
|
||||||
|
hashed_password=get_password_hash(user_data.password)
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
# Create access token
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": str(user.id), "email": user.email}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": user
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||||
|
"""Login with email and password."""
|
||||||
|
# Find user by email (username field is used for email in OAuth2PasswordRequestForm)
|
||||||
|
user = db.query(User).filter(User.email == form_data.username).first()
|
||||||
|
|
||||||
|
if not user or not verify_password(form_data.password, user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect email or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Inactive user"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create access token
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": str(user.id), "email": user.email}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": user
|
||||||
|
}
|
||||||
257
backend/api/routers/events.py
Normal file
257
backend/api/routers/events.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
|
from config.database import get_db
|
||||||
|
from models.event import Event, EventParticipation, ParticipationStatus
|
||||||
|
from models.user import User
|
||||||
|
from models.notification import Notification, NotificationType
|
||||||
|
from schemas.event import EventCreate, EventUpdate, EventResponse, ParticipationUpdate
|
||||||
|
from utils.security import get_current_active_user
|
||||||
|
from utils.email import send_event_notification
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/", response_model=EventResponse)
|
||||||
|
async def create_event(
|
||||||
|
event_data: EventCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Create a new event."""
|
||||||
|
event = Event(
|
||||||
|
**event_data.dict(),
|
||||||
|
creator_id=current_user.id
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(event)
|
||||||
|
|
||||||
|
# Create participations for all active users
|
||||||
|
users = db.query(User).filter(User.is_active == True).all()
|
||||||
|
for user in users:
|
||||||
|
participation = EventParticipation(
|
||||||
|
event_id=event.id,
|
||||||
|
user_id=user.id,
|
||||||
|
status=ParticipationStatus.PENDING
|
||||||
|
)
|
||||||
|
db.add(participation)
|
||||||
|
|
||||||
|
# Create notification
|
||||||
|
if user.id != current_user.id:
|
||||||
|
notification = Notification(
|
||||||
|
user_id=user.id,
|
||||||
|
type=NotificationType.EVENT_INVITATION,
|
||||||
|
title=f"Nouvel événement: {event.title}",
|
||||||
|
message=f"{current_user.full_name} a créé un nouvel événement",
|
||||||
|
link=f"/events/{event.id}"
|
||||||
|
)
|
||||||
|
db.add(notification)
|
||||||
|
|
||||||
|
# Send email notification (async task would be better)
|
||||||
|
try:
|
||||||
|
send_event_notification(user.email, event)
|
||||||
|
except:
|
||||||
|
pass # Don't fail if email sending fails
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return format_event_response(event, db)
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[EventResponse])
|
||||||
|
async def get_events(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
upcoming: bool = None
|
||||||
|
):
|
||||||
|
"""Get all events, optionally filtered by upcoming status."""
|
||||||
|
query = db.query(Event)
|
||||||
|
|
||||||
|
if upcoming is True:
|
||||||
|
# Only upcoming events
|
||||||
|
query = query.filter(Event.date >= datetime.utcnow())
|
||||||
|
elif upcoming is False:
|
||||||
|
# Only past events
|
||||||
|
query = query.filter(Event.date < datetime.utcnow())
|
||||||
|
# If upcoming is None, return all events
|
||||||
|
|
||||||
|
events = query.order_by(Event.date.desc()).all()
|
||||||
|
return [format_event_response(event, db) for event in events]
|
||||||
|
|
||||||
|
@router.get("/upcoming", response_model=List[EventResponse])
|
||||||
|
async def get_upcoming_events(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get only upcoming events."""
|
||||||
|
events = db.query(Event).filter(
|
||||||
|
Event.date >= datetime.utcnow()
|
||||||
|
).order_by(Event.date).all()
|
||||||
|
return [format_event_response(event, db) for event in events]
|
||||||
|
|
||||||
|
@router.get("/past", response_model=List[EventResponse])
|
||||||
|
async def get_past_events(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get only past events."""
|
||||||
|
events = db.query(Event).filter(
|
||||||
|
Event.date < datetime.utcnow()
|
||||||
|
).order_by(Event.date.desc()).all()
|
||||||
|
return [format_event_response(event, db) for event in events]
|
||||||
|
|
||||||
|
@router.get("/{event_id}", response_model=EventResponse)
|
||||||
|
async def get_event(
|
||||||
|
event_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get a specific 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"
|
||||||
|
)
|
||||||
|
return format_event_response(event, db)
|
||||||
|
|
||||||
|
@router.put("/{event_id}", response_model=EventResponse)
|
||||||
|
async def update_event(
|
||||||
|
event_id: int,
|
||||||
|
event_update: EventUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Update 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.creator_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to update this event"
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, value in event_update.dict(exclude_unset=True).items():
|
||||||
|
setattr(event, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(event)
|
||||||
|
return format_event_response(event, db)
|
||||||
|
|
||||||
|
@router.delete("/{event_id}")
|
||||||
|
async def delete_event(
|
||||||
|
event_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Delete 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.creator_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to delete this event"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.delete(event)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Event deleted successfully"}
|
||||||
|
|
||||||
|
@router.put("/{event_id}/participation", response_model=EventResponse)
|
||||||
|
async def update_participation(
|
||||||
|
event_id: int,
|
||||||
|
participation_update: ParticipationUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Update user participation status for an event."""
|
||||||
|
participation = db.query(EventParticipation).filter(
|
||||||
|
EventParticipation.event_id == event_id,
|
||||||
|
EventParticipation.user_id == current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not participation:
|
||||||
|
# Create new participation if it doesn't exist
|
||||||
|
participation = EventParticipation(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
status=participation_update.status
|
||||||
|
)
|
||||||
|
db.add(participation)
|
||||||
|
else:
|
||||||
|
participation.status = participation_update.status
|
||||||
|
participation.response_date = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Update user attendance rate
|
||||||
|
update_user_attendance_rate(current_user, db)
|
||||||
|
|
||||||
|
event = db.query(Event).filter(Event.id == event_id).first()
|
||||||
|
return format_event_response(event, db)
|
||||||
|
|
||||||
|
def format_event_response(event: Event, db: Session) -> dict:
|
||||||
|
"""Format event response with participation counts."""
|
||||||
|
participations = []
|
||||||
|
present_count = absent_count = maybe_count = pending_count = 0
|
||||||
|
|
||||||
|
for p in event.participations:
|
||||||
|
user = db.query(User).filter(User.id == p.user_id).first()
|
||||||
|
participations.append({
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"avatar_url": user.avatar_url,
|
||||||
|
"status": p.status,
|
||||||
|
"response_date": p.response_date
|
||||||
|
})
|
||||||
|
|
||||||
|
if p.status == ParticipationStatus.PRESENT:
|
||||||
|
present_count += 1
|
||||||
|
elif p.status == ParticipationStatus.ABSENT:
|
||||||
|
absent_count += 1
|
||||||
|
elif p.status == ParticipationStatus.MAYBE:
|
||||||
|
maybe_count += 1
|
||||||
|
else:
|
||||||
|
pending_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": event.id,
|
||||||
|
"title": event.title,
|
||||||
|
"description": event.description,
|
||||||
|
"location": event.location,
|
||||||
|
"date": event.date,
|
||||||
|
"end_date": event.end_date,
|
||||||
|
"creator_id": event.creator_id,
|
||||||
|
"cover_image": event.cover_image,
|
||||||
|
"created_at": event.created_at,
|
||||||
|
"updated_at": event.updated_at,
|
||||||
|
"creator_name": event.creator.full_name,
|
||||||
|
"participations": participations,
|
||||||
|
"present_count": present_count,
|
||||||
|
"absent_count": absent_count,
|
||||||
|
"maybe_count": maybe_count,
|
||||||
|
"pending_count": pending_count
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_user_attendance_rate(user: User, db: Session):
|
||||||
|
"""Update user attendance rate based on past events."""
|
||||||
|
past_participations = db.query(EventParticipation).join(Event).filter(
|
||||||
|
EventParticipation.user_id == user.id,
|
||||||
|
Event.date < datetime.utcnow(),
|
||||||
|
EventParticipation.status.in_([ParticipationStatus.PRESENT, ParticipationStatus.ABSENT])
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if past_participations:
|
||||||
|
present_count = sum(1 for p in past_participations if p.status == ParticipationStatus.PRESENT)
|
||||||
|
user.attendance_rate = (present_count / len(past_participations)) * 100
|
||||||
|
db.commit()
|
||||||
143
backend/api/routers/information.py
Normal file
143
backend/api/routers/information.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
|
from config.database import get_db
|
||||||
|
from models.information import Information
|
||||||
|
from models.user import User
|
||||||
|
from schemas.information import InformationCreate, InformationUpdate, InformationResponse
|
||||||
|
from utils.security import get_current_active_user, get_admin_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[InformationResponse])
|
||||||
|
async def get_informations(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
category: str = None,
|
||||||
|
published_only: bool = True
|
||||||
|
):
|
||||||
|
"""Get all informations, optionally filtered by category and published status."""
|
||||||
|
query = db.query(Information)
|
||||||
|
|
||||||
|
if published_only:
|
||||||
|
query = query.filter(Information.is_published == True)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query = query.filter(Information.category == category)
|
||||||
|
|
||||||
|
informations = query.order_by(Information.priority.desc(), Information.created_at.desc()).all()
|
||||||
|
return informations
|
||||||
|
|
||||||
|
@router.get("/public", response_model=List[InformationResponse])
|
||||||
|
async def get_public_informations(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
category: str = None
|
||||||
|
):
|
||||||
|
"""Get public informations without authentication."""
|
||||||
|
query = db.query(Information).filter(Information.is_published == True)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query = query.filter(Information.category == category)
|
||||||
|
|
||||||
|
informations = query.order_by(Information.priority.desc(), Information.created_at.desc()).all()
|
||||||
|
return informations
|
||||||
|
|
||||||
|
@router.get("/{information_id}", response_model=InformationResponse)
|
||||||
|
async def get_information(
|
||||||
|
information_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get a specific information."""
|
||||||
|
information = db.query(Information).filter(Information.id == information_id).first()
|
||||||
|
if not information:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Information not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not information.is_published and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Information not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return information
|
||||||
|
|
||||||
|
@router.post("/", response_model=InformationResponse)
|
||||||
|
async def create_information(
|
||||||
|
information_data: InformationCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Create a new information (admin only)."""
|
||||||
|
information = Information(**information_data.dict())
|
||||||
|
db.add(information)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(information)
|
||||||
|
return information
|
||||||
|
|
||||||
|
@router.put("/{information_id}", response_model=InformationResponse)
|
||||||
|
async def update_information(
|
||||||
|
information_id: int,
|
||||||
|
information_update: InformationUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Update an information (admin only)."""
|
||||||
|
information = db.query(Information).filter(Information.id == information_id).first()
|
||||||
|
if not information:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Information not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, value in information_update.dict(exclude_unset=True).items():
|
||||||
|
setattr(information, field, value)
|
||||||
|
|
||||||
|
information.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(information)
|
||||||
|
return information
|
||||||
|
|
||||||
|
@router.delete("/{information_id}")
|
||||||
|
async def delete_information(
|
||||||
|
information_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Delete an information (admin only)."""
|
||||||
|
information = db.query(Information).filter(Information.id == information_id).first()
|
||||||
|
if not information:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Information not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.delete(information)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Information deleted successfully"}
|
||||||
|
|
||||||
|
@router.put("/{information_id}/toggle-publish")
|
||||||
|
async def toggle_information_publish(
|
||||||
|
information_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Toggle information published status (admin only)."""
|
||||||
|
information = db.query(Information).filter(Information.id == information_id).first()
|
||||||
|
if not information:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Information not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
information.is_published = not information.is_published
|
||||||
|
information.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Information {'published' if information.is_published else 'unpublished'} successfully",
|
||||||
|
"is_published": information.is_published
|
||||||
|
}
|
||||||
79
backend/api/routers/notifications.py
Normal file
79
backend/api/routers/notifications.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
|
from config.database import get_db
|
||||||
|
from models.notification import Notification
|
||||||
|
from models.user import User
|
||||||
|
from schemas.notification import NotificationResponse
|
||||||
|
from utils.security import get_current_active_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[NotificationResponse])
|
||||||
|
async def get_user_notifications(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""Get user notifications."""
|
||||||
|
notifications = db.query(Notification).filter(
|
||||||
|
Notification.user_id == current_user.id
|
||||||
|
).order_by(Notification.created_at.desc()).limit(limit).offset(offset).all()
|
||||||
|
|
||||||
|
return notifications
|
||||||
|
|
||||||
|
@router.put("/{notification_id}/read")
|
||||||
|
async def mark_notification_read(
|
||||||
|
notification_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Mark a notification as read."""
|
||||||
|
notification = db.query(Notification).filter(
|
||||||
|
Notification.id == notification_id,
|
||||||
|
Notification.user_id == current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not notification:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Notification not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
notification.is_read = True
|
||||||
|
notification.read_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Notification marked as read"}
|
||||||
|
|
||||||
|
@router.put("/read-all")
|
||||||
|
async def mark_all_notifications_read(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Mark all user notifications as read."""
|
||||||
|
db.query(Notification).filter(
|
||||||
|
Notification.user_id == current_user.id,
|
||||||
|
Notification.is_read == False
|
||||||
|
).update({
|
||||||
|
"is_read": True,
|
||||||
|
"read_at": datetime.utcnow()
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "All notifications marked as read"}
|
||||||
|
|
||||||
|
@router.get("/unread-count")
|
||||||
|
async def get_unread_count(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get count of unread notifications."""
|
||||||
|
count = db.query(Notification).filter(
|
||||||
|
Notification.user_id == current_user.id,
|
||||||
|
Notification.is_read == False
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return {"unread_count": count}
|
||||||
347
backend/api/routers/posts.py
Normal file
347
backend/api/routers/posts.py
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
from config.database import get_db
|
||||||
|
from config.settings import settings
|
||||||
|
from models.post import Post, PostMention, PostLike, PostComment
|
||||||
|
from models.user import User
|
||||||
|
from models.notification import Notification, NotificationType
|
||||||
|
from utils.notification_service import NotificationService
|
||||||
|
from schemas.post import PostCreate, PostUpdate, PostResponse, PostCommentCreate
|
||||||
|
from utils.security import get_current_active_user
|
||||||
|
from utils.settings_service import SettingsService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/", response_model=PostResponse)
|
||||||
|
async def create_post(
|
||||||
|
post_data: PostCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Create a new post."""
|
||||||
|
post = Post(
|
||||||
|
author_id=current_user.id,
|
||||||
|
content=post_data.content,
|
||||||
|
image_url=post_data.image_url
|
||||||
|
)
|
||||||
|
db.add(post)
|
||||||
|
db.flush() # Get the post ID before creating mentions
|
||||||
|
|
||||||
|
# Create mentions
|
||||||
|
for user_id in post_data.mentioned_user_ids:
|
||||||
|
mentioned_user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if mentioned_user:
|
||||||
|
mention = PostMention(
|
||||||
|
post_id=post.id,
|
||||||
|
mentioned_user_id=user_id
|
||||||
|
)
|
||||||
|
db.add(mention)
|
||||||
|
|
||||||
|
# Create notification for mentioned user
|
||||||
|
NotificationService.create_mention_notification(
|
||||||
|
db=db,
|
||||||
|
mentioned_user_id=user_id,
|
||||||
|
author=current_user,
|
||||||
|
content_type="post",
|
||||||
|
content_id=post.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(post)
|
||||||
|
return format_post_response(post, db)
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[PostResponse])
|
||||||
|
async def get_posts(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""Get all posts."""
|
||||||
|
posts = db.query(Post).order_by(Post.created_at.desc()).limit(limit).offset(offset).all()
|
||||||
|
return [format_post_response(post, db) for post in posts]
|
||||||
|
|
||||||
|
@router.get("/{post_id}", response_model=PostResponse)
|
||||||
|
async def get_post(
|
||||||
|
post_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get a specific post."""
|
||||||
|
post = db.query(Post).filter(Post.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Post not found"
|
||||||
|
)
|
||||||
|
return format_post_response(post, db)
|
||||||
|
|
||||||
|
@router.put("/{post_id}", response_model=PostResponse)
|
||||||
|
async def update_post(
|
||||||
|
post_id: int,
|
||||||
|
post_update: PostUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Update a post."""
|
||||||
|
post = db.query(Post).filter(Post.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Post not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if post.author_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to update this post"
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, value in post_update.dict(exclude_unset=True).items():
|
||||||
|
setattr(post, field, value)
|
||||||
|
|
||||||
|
post.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(post)
|
||||||
|
return format_post_response(post, db)
|
||||||
|
|
||||||
|
@router.delete("/{post_id}")
|
||||||
|
async def delete_post(
|
||||||
|
post_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Delete a post."""
|
||||||
|
post = db.query(Post).filter(Post.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Post not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if post.author_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to delete this post"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.delete(post)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Post deleted successfully"}
|
||||||
|
|
||||||
|
@router.post("/upload-image")
|
||||||
|
async def upload_post_image(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Upload an image for a post."""
|
||||||
|
# Validate file type
|
||||||
|
if not SettingsService.is_file_type_allowed(db, file.content_type or "unknown"):
|
||||||
|
allowed_types = SettingsService.get_setting(db, "allowed_image_types",
|
||||||
|
["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
contents = await file.read()
|
||||||
|
file_size = len(contents)
|
||||||
|
max_size = SettingsService.get_max_upload_size(db, file.content_type or "image/jpeg")
|
||||||
|
if file_size > max_size:
|
||||||
|
max_size_mb = max_size // (1024 * 1024)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"File too large. Maximum size: {max_size_mb}MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create posts directory
|
||||||
|
posts_dir = Path(settings.UPLOAD_PATH) / "posts" / str(current_user.id)
|
||||||
|
posts_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
file_extension = file.filename.split(".")[-1]
|
||||||
|
filename = f"{uuid.uuid4()}.{file_extension}"
|
||||||
|
file_path = posts_dir / filename
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(contents)
|
||||||
|
|
||||||
|
# Resize image if it's REALLY too large (4K support)
|
||||||
|
try:
|
||||||
|
img = Image.open(file_path)
|
||||||
|
if img.size[0] > 3840 or img.size[1] > 2160: # 4K threshold
|
||||||
|
img.thumbnail((3840, 2160), Image.Resampling.LANCZOS)
|
||||||
|
img.save(file_path, quality=95, optimize=True) # High quality
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing image: {e}")
|
||||||
|
|
||||||
|
# Return the image URL
|
||||||
|
image_url = f"/posts/{current_user.id}/{filename}"
|
||||||
|
|
||||||
|
return {"image_url": image_url}
|
||||||
|
|
||||||
|
@router.post("/{post_id}/like")
|
||||||
|
async def toggle_post_like(
|
||||||
|
post_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Toggle like on a post."""
|
||||||
|
post = db.query(Post).filter(Post.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Post not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user already liked the post
|
||||||
|
existing_like = db.query(PostLike).filter(
|
||||||
|
PostLike.post_id == post_id,
|
||||||
|
PostLike.user_id == current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_like:
|
||||||
|
# Unlike
|
||||||
|
db.delete(existing_like)
|
||||||
|
post.likes_count = max(0, post.likes_count - 1)
|
||||||
|
message = "Like removed"
|
||||||
|
is_liked = False
|
||||||
|
else:
|
||||||
|
# Like
|
||||||
|
like = PostLike(post_id=post_id, user_id=current_user.id)
|
||||||
|
db.add(like)
|
||||||
|
post.likes_count += 1
|
||||||
|
message = "Post liked"
|
||||||
|
is_liked = True
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": message, "is_liked": is_liked, "likes_count": post.likes_count}
|
||||||
|
|
||||||
|
@router.post("/{post_id}/comment")
|
||||||
|
async def add_post_comment(
|
||||||
|
post_id: int,
|
||||||
|
comment_data: PostCommentCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Add a comment to a post."""
|
||||||
|
post = db.query(Post).filter(Post.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Post not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
comment = PostComment(
|
||||||
|
post_id=post_id,
|
||||||
|
author_id=current_user.id,
|
||||||
|
content=comment_data.content
|
||||||
|
)
|
||||||
|
db.add(comment)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(comment)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Comment added successfully",
|
||||||
|
"comment": {
|
||||||
|
"id": comment.id,
|
||||||
|
"content": comment.content,
|
||||||
|
"author_id": comment.author_id,
|
||||||
|
"author_name": current_user.full_name,
|
||||||
|
"author_avatar": current_user.avatar_url,
|
||||||
|
"created_at": comment.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.delete("/{post_id}/comment/{comment_id}")
|
||||||
|
async def delete_post_comment(
|
||||||
|
post_id: int,
|
||||||
|
comment_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Delete a comment from a post."""
|
||||||
|
comment = db.query(PostComment).filter(
|
||||||
|
PostComment.id == comment_id,
|
||||||
|
PostComment.post_id == post_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not comment:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Comment not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if comment.author_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to delete this comment"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.delete(comment)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Comment deleted successfully"}
|
||||||
|
|
||||||
|
def format_post_response(post: Post, db: Session) -> dict:
|
||||||
|
"""Format post response with author and mentions information."""
|
||||||
|
mentioned_users = []
|
||||||
|
for mention in post.mentions:
|
||||||
|
user = mention.mentioned_user
|
||||||
|
mentioned_users.append({
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"avatar_url": user.avatar_url
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get likes and comments
|
||||||
|
likes = []
|
||||||
|
for like in post.likes:
|
||||||
|
user = db.query(User).filter(User.id == like.user_id).first()
|
||||||
|
if user:
|
||||||
|
likes.append({
|
||||||
|
"id": like.id,
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"avatar_url": user.avatar_url,
|
||||||
|
"created_at": like.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
comments = []
|
||||||
|
for comment in post.comments:
|
||||||
|
user = db.query(User).filter(User.id == comment.author_id).first()
|
||||||
|
if user:
|
||||||
|
comments.append({
|
||||||
|
"id": comment.id,
|
||||||
|
"content": comment.content,
|
||||||
|
"author_id": user.id,
|
||||||
|
"author_name": user.full_name,
|
||||||
|
"author_avatar": user.avatar_url,
|
||||||
|
"created_at": comment.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": post.id,
|
||||||
|
"author_id": post.author_id,
|
||||||
|
"content": post.content,
|
||||||
|
"image_url": post.image_url,
|
||||||
|
"likes_count": post.likes_count,
|
||||||
|
"comments_count": post.comments_count,
|
||||||
|
"created_at": post.created_at,
|
||||||
|
"updated_at": post.updated_at,
|
||||||
|
"author_name": post.author.full_name,
|
||||||
|
"author_avatar": post.author.avatar_url,
|
||||||
|
"mentioned_users": mentioned_users,
|
||||||
|
"likes": likes,
|
||||||
|
"comments": comments
|
||||||
|
}
|
||||||
287
backend/api/routers/settings.py
Normal file
287
backend/api/routers/settings.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
from config.database import get_db
|
||||||
|
from models.settings import SystemSettings
|
||||||
|
from models.user import User
|
||||||
|
from schemas.settings import (
|
||||||
|
SystemSettingCreate,
|
||||||
|
SystemSettingUpdate,
|
||||||
|
SystemSettingResponse,
|
||||||
|
SettingsCategoryResponse,
|
||||||
|
UploadLimitsResponse
|
||||||
|
)
|
||||||
|
from utils.security import get_admin_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[SystemSettingResponse])
|
||||||
|
async def get_all_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Get all system settings (admin only)."""
|
||||||
|
settings = db.query(SystemSettings).order_by(SystemSettings.category, SystemSettings.key).all()
|
||||||
|
return settings
|
||||||
|
|
||||||
|
@router.get("/category/{category}", response_model=SettingsCategoryResponse)
|
||||||
|
async def get_settings_by_category(
|
||||||
|
category: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Get settings by category (admin only)."""
|
||||||
|
settings = db.query(SystemSettings).filter(SystemSettings.category == category).all()
|
||||||
|
return SettingsCategoryResponse(category=category, settings=settings)
|
||||||
|
|
||||||
|
@router.get("/upload-limits", response_model=UploadLimitsResponse)
|
||||||
|
async def get_upload_limits(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Get current upload limits configuration."""
|
||||||
|
settings = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.category == "uploads"
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Convertir en dictionnaire pour faciliter l'accès
|
||||||
|
settings_dict = {s.key: s.value for s in settings}
|
||||||
|
|
||||||
|
# Debug: afficher les valeurs récupérées
|
||||||
|
print(f"DEBUG - Upload limits from DB: {settings_dict}")
|
||||||
|
|
||||||
|
return UploadLimitsResponse(
|
||||||
|
max_album_size_mb=int(settings_dict.get("max_album_size_mb", "100")),
|
||||||
|
max_vlog_size_mb=int(settings_dict.get("max_vlog_size_mb", "500")),
|
||||||
|
max_image_size_mb=int(settings_dict.get("max_image_size_mb", "10")),
|
||||||
|
max_video_size_mb=int(settings_dict.get("max_video_size_mb", "100")),
|
||||||
|
max_media_per_album=int(settings_dict.get("max_media_per_album", "50")),
|
||||||
|
allowed_image_types=settings_dict.get("allowed_image_types", "image/jpeg,image/png,image/gif,image/webp").split(","),
|
||||||
|
allowed_video_types=settings_dict.get("allowed_video_types", "video/mp4,video/mpeg,video/quicktime,video/webm").split(",")
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/", response_model=SystemSettingResponse)
|
||||||
|
async def create_setting(
|
||||||
|
setting_data: SystemSettingCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Create a new system setting (admin only)."""
|
||||||
|
# Vérifier si la clé existe déjà
|
||||||
|
existing = db.query(SystemSettings).filter(SystemSettings.key == setting_data.key).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Setting key already exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
setting = SystemSettings(**setting_data.dict())
|
||||||
|
db.add(setting)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(setting)
|
||||||
|
return setting
|
||||||
|
|
||||||
|
@router.put("/{setting_key}", response_model=SystemSettingResponse)
|
||||||
|
async def update_setting(
|
||||||
|
setting_key: str,
|
||||||
|
setting_update: SystemSettingUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Update a system setting (admin only)."""
|
||||||
|
setting = db.query(SystemSettings).filter(SystemSettings.key == setting_key).first()
|
||||||
|
if not setting:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Setting not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not setting.is_editable:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="This setting cannot be modified"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validation des valeurs selon le type de paramètre
|
||||||
|
if setting_key.startswith("max_") and setting_key.endswith("_mb"):
|
||||||
|
try:
|
||||||
|
size_mb = int(setting_update.value)
|
||||||
|
if size_mb <= 0 or size_mb > 10000: # Max 10GB
|
||||||
|
raise ValueError("Size must be between 1 and 10000 MB")
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid size value: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
setting.value = setting_update.value
|
||||||
|
setting.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(setting)
|
||||||
|
return setting
|
||||||
|
|
||||||
|
@router.delete("/{setting_key}")
|
||||||
|
async def delete_setting(
|
||||||
|
setting_key: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Delete a system setting (admin only)."""
|
||||||
|
setting = db.query(SystemSettings).filter(SystemSettings.key == setting_key).first()
|
||||||
|
if not setting:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Setting not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not setting.is_editable:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="This setting cannot be deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.delete(setting)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Setting deleted successfully"}
|
||||||
|
|
||||||
|
@router.get("/test-upload-limits")
|
||||||
|
async def test_upload_limits(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Test endpoint to verify upload limits are working correctly."""
|
||||||
|
from utils.settings_service import SettingsService
|
||||||
|
|
||||||
|
# Test image upload limit
|
||||||
|
image_limit = SettingsService.get_max_upload_size(db, "image/jpeg")
|
||||||
|
image_limit_mb = image_limit // (1024 * 1024)
|
||||||
|
|
||||||
|
# Test video upload limit
|
||||||
|
video_limit = SettingsService.get_max_upload_size(db, "video/mp4")
|
||||||
|
video_limit_mb = video_limit // (1024 * 1024)
|
||||||
|
|
||||||
|
# Test file type validation
|
||||||
|
image_allowed = SettingsService.is_file_type_allowed(db, "image/jpeg")
|
||||||
|
video_allowed = SettingsService.is_file_type_allowed(db, "video/mp4")
|
||||||
|
invalid_allowed = SettingsService.is_file_type_allowed(db, "application/pdf")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"image_upload_limit": {
|
||||||
|
"bytes": image_limit,
|
||||||
|
"mb": image_limit_mb,
|
||||||
|
"content_type": "image/jpeg"
|
||||||
|
},
|
||||||
|
"video_upload_limit": {
|
||||||
|
"bytes": video_limit,
|
||||||
|
"mb": video_limit_mb,
|
||||||
|
"content_type": "video/mp4"
|
||||||
|
},
|
||||||
|
"file_type_validation": {
|
||||||
|
"image_jpeg_allowed": image_allowed,
|
||||||
|
"video_mp4_allowed": video_allowed,
|
||||||
|
"pdf_not_allowed": not invalid_allowed
|
||||||
|
},
|
||||||
|
"message": "Upload limits test completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/public/registration-status")
|
||||||
|
async def get_public_registration_status(
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get registration status without authentication (public endpoint)."""
|
||||||
|
from utils.settings_service import SettingsService
|
||||||
|
|
||||||
|
try:
|
||||||
|
enable_registration = SettingsService.get_setting(db, "enable_registration", True)
|
||||||
|
max_users = SettingsService.get_setting(db, "max_users", 50)
|
||||||
|
current_users_count = db.query(User).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"registration_enabled": enable_registration,
|
||||||
|
"max_users": max_users,
|
||||||
|
"current_users_count": current_users_count,
|
||||||
|
"can_register": enable_registration and current_users_count < max_users
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
# En cas d'erreur, on retourne des valeurs par défaut sécurisées
|
||||||
|
return {
|
||||||
|
"registration_enabled": False,
|
||||||
|
"max_users": 50,
|
||||||
|
"current_users_count": 0,
|
||||||
|
"can_register": False
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/initialize-defaults")
|
||||||
|
async def initialize_default_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Initialize default system settings (admin only)."""
|
||||||
|
default_settings = [
|
||||||
|
{
|
||||||
|
"key": "max_album_size_mb",
|
||||||
|
"value": "100",
|
||||||
|
"description": "Taille maximale des albums en MB",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_vlog_size_mb",
|
||||||
|
"value": "500",
|
||||||
|
"description": "Taille maximale des vlogs en MB",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_image_size_mb",
|
||||||
|
"value": "10",
|
||||||
|
"description": "Taille maximale des images en MB",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_video_size_mb",
|
||||||
|
"value": "100",
|
||||||
|
"description": "Taille maximale des vidéos en MB",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_media_per_album",
|
||||||
|
"value": "50",
|
||||||
|
"description": "Nombre maximum de médias par album",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "allowed_image_types",
|
||||||
|
"value": "image/jpeg,image/png,image/gif,image/webp",
|
||||||
|
"description": "Types d'images autorisés (séparés par des virgules)",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "allowed_video_types",
|
||||||
|
"value": "video/mp4,video/mpeg,video/quicktime,video/webm",
|
||||||
|
"description": "Types de vidéos autorisés (séparés par des virgules)",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_users",
|
||||||
|
"value": "50",
|
||||||
|
"description": "Nombre maximum d'utilisateurs",
|
||||||
|
"category": "general"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enable_registration",
|
||||||
|
"value": "true",
|
||||||
|
"description": "Autoriser les nouvelles inscriptions",
|
||||||
|
"category": "general"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
for setting_data in default_settings:
|
||||||
|
existing = db.query(SystemSettings).filter(SystemSettings.key == setting_data["key"]).first()
|
||||||
|
if not existing:
|
||||||
|
setting = SystemSettings(**setting_data)
|
||||||
|
db.add(setting)
|
||||||
|
created_count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": f"{created_count} default settings created"}
|
||||||
314
backend/api/routers/stats.py
Normal file
314
backend/api/routers/stats.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from config.database import get_db
|
||||||
|
from models.user import User
|
||||||
|
from models.event import Event, EventParticipation, ParticipationStatus
|
||||||
|
from models.album import Album, Media
|
||||||
|
from models.post import Post
|
||||||
|
from models.vlog import Vlog
|
||||||
|
from utils.security import get_current_active_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/overview")
|
||||||
|
async def get_overview_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get general overview statistics."""
|
||||||
|
total_users = db.query(User).filter(User.is_active == True).count()
|
||||||
|
total_events = db.query(Event).count()
|
||||||
|
total_albums = db.query(Album).count()
|
||||||
|
total_posts = db.query(Post).count()
|
||||||
|
total_vlogs = db.query(Vlog).count()
|
||||||
|
total_media = db.query(Media).count()
|
||||||
|
|
||||||
|
# Recent activity (last 30 days)
|
||||||
|
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
||||||
|
recent_events = db.query(Event).filter(Event.created_at >= thirty_days_ago).count()
|
||||||
|
recent_posts = db.query(Post).filter(Post.created_at >= thirty_days_ago).count()
|
||||||
|
recent_vlogs = db.query(Vlog).filter(Vlog.created_at >= thirty_days_ago).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": total_users,
|
||||||
|
"total_events": total_events,
|
||||||
|
"total_albums": total_albums,
|
||||||
|
"total_posts": total_posts,
|
||||||
|
"total_vlogs": total_vlogs,
|
||||||
|
"total_media": total_media,
|
||||||
|
"recent_events": recent_events,
|
||||||
|
"recent_posts": recent_posts,
|
||||||
|
"recent_vlogs": recent_vlogs
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/attendance")
|
||||||
|
async def get_attendance_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get attendance statistics for all users."""
|
||||||
|
users = db.query(User).filter(User.is_active == True).all()
|
||||||
|
|
||||||
|
attendance_stats = []
|
||||||
|
for user in users:
|
||||||
|
# Get past events participation
|
||||||
|
past_participations = db.query(EventParticipation).join(Event).filter(
|
||||||
|
EventParticipation.user_id == user.id,
|
||||||
|
Event.date < datetime.utcnow()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
total_past_events = len(past_participations)
|
||||||
|
present_count = sum(1 for p in past_participations if p.status == ParticipationStatus.PRESENT)
|
||||||
|
absent_count = sum(1 for p in past_participations if p.status == ParticipationStatus.ABSENT)
|
||||||
|
|
||||||
|
# Get upcoming events participation
|
||||||
|
upcoming_participations = db.query(EventParticipation).join(Event).filter(
|
||||||
|
EventParticipation.user_id == user.id,
|
||||||
|
Event.date >= datetime.utcnow()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
upcoming_present = sum(1 for p in upcoming_participations if p.status == ParticipationStatus.PRESENT)
|
||||||
|
upcoming_maybe = sum(1 for p in upcoming_participations if p.status == ParticipationStatus.MAYBE)
|
||||||
|
upcoming_pending = sum(1 for p in upcoming_participations if p.status == ParticipationStatus.PENDING)
|
||||||
|
|
||||||
|
attendance_stats.append({
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"avatar_url": user.avatar_url,
|
||||||
|
"attendance_rate": user.attendance_rate,
|
||||||
|
"total_past_events": total_past_events,
|
||||||
|
"present_count": present_count,
|
||||||
|
"absent_count": absent_count,
|
||||||
|
"upcoming_present": upcoming_present,
|
||||||
|
"upcoming_maybe": upcoming_maybe,
|
||||||
|
"upcoming_pending": upcoming_pending
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by attendance rate
|
||||||
|
attendance_stats.sort(key=lambda x: x["attendance_rate"], reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"attendance_stats": attendance_stats,
|
||||||
|
"best_attendee": attendance_stats[0] if attendance_stats else None,
|
||||||
|
"worst_attendee": attendance_stats[-1] if attendance_stats else None
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/fun")
|
||||||
|
async def get_fun_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get fun statistics about the group."""
|
||||||
|
# Most active poster
|
||||||
|
post_counts = db.query(
|
||||||
|
User.id,
|
||||||
|
User.username,
|
||||||
|
User.full_name,
|
||||||
|
func.count(Post.id).label("post_count")
|
||||||
|
).join(Post).group_by(User.id).order_by(func.count(Post.id).desc()).first()
|
||||||
|
|
||||||
|
# Most mentioned user
|
||||||
|
mention_counts = db.query(
|
||||||
|
User.id,
|
||||||
|
User.username,
|
||||||
|
User.full_name,
|
||||||
|
func.count().label("mention_count")
|
||||||
|
).join(Post.mentions).group_by(User.id).order_by(func.count().desc()).first()
|
||||||
|
|
||||||
|
# Biggest vlogger
|
||||||
|
vlog_counts = db.query(
|
||||||
|
User.id,
|
||||||
|
User.username,
|
||||||
|
User.full_name,
|
||||||
|
func.count(Vlog.id).label("vlog_count")
|
||||||
|
).join(Vlog).group_by(User.id).order_by(func.count(Vlog.id).desc()).first()
|
||||||
|
|
||||||
|
# Photo addict (most albums created)
|
||||||
|
album_counts = db.query(
|
||||||
|
User.id,
|
||||||
|
User.username,
|
||||||
|
User.full_name,
|
||||||
|
func.count(Album.id).label("album_count")
|
||||||
|
).join(Album).group_by(User.id).order_by(func.count(Album.id).desc()).first()
|
||||||
|
|
||||||
|
# Event organizer (most events created)
|
||||||
|
event_counts = db.query(
|
||||||
|
User.id,
|
||||||
|
User.username,
|
||||||
|
User.full_name,
|
||||||
|
func.count(Event.id).label("event_count")
|
||||||
|
).join(Event, Event.creator_id == User.id).group_by(User.id).order_by(func.count(Event.id).desc()).first()
|
||||||
|
|
||||||
|
# Most viewed vlog
|
||||||
|
most_viewed_vlog = db.query(Vlog).order_by(Vlog.views_count.desc()).first()
|
||||||
|
|
||||||
|
# Longest event streak (consecutive events attended)
|
||||||
|
# This would require more complex logic to calculate
|
||||||
|
|
||||||
|
return {
|
||||||
|
"most_active_poster": {
|
||||||
|
"user_id": post_counts[0] if post_counts else None,
|
||||||
|
"username": post_counts[1] if post_counts else None,
|
||||||
|
"full_name": post_counts[2] if post_counts else None,
|
||||||
|
"post_count": post_counts[3] if post_counts else 0
|
||||||
|
} if post_counts else None,
|
||||||
|
"most_mentioned": {
|
||||||
|
"user_id": mention_counts[0] if mention_counts else None,
|
||||||
|
"username": mention_counts[1] if mention_counts else None,
|
||||||
|
"full_name": mention_counts[2] if mention_counts else None,
|
||||||
|
"mention_count": mention_counts[3] if mention_counts else 0
|
||||||
|
} if mention_counts else None,
|
||||||
|
"biggest_vlogger": {
|
||||||
|
"user_id": vlog_counts[0] if vlog_counts else None,
|
||||||
|
"username": vlog_counts[1] if vlog_counts else None,
|
||||||
|
"full_name": vlog_counts[2] if vlog_counts else None,
|
||||||
|
"vlog_count": vlog_counts[3] if vlog_counts else 0
|
||||||
|
} if vlog_counts else None,
|
||||||
|
"photo_addict": {
|
||||||
|
"user_id": album_counts[0] if album_counts else None,
|
||||||
|
"username": album_counts[1] if album_counts else None,
|
||||||
|
"full_name": album_counts[2] if album_counts else None,
|
||||||
|
"album_count": album_counts[3] if album_counts else 0
|
||||||
|
} if album_counts else None,
|
||||||
|
"event_organizer": {
|
||||||
|
"user_id": event_counts[0] if event_counts else None,
|
||||||
|
"username": event_counts[1] if event_counts else None,
|
||||||
|
"full_name": event_counts[2] if event_counts else None,
|
||||||
|
"event_count": event_counts[3] if event_counts else 0
|
||||||
|
} if event_counts else None,
|
||||||
|
"most_viewed_vlog": {
|
||||||
|
"id": most_viewed_vlog.id,
|
||||||
|
"title": most_viewed_vlog.title,
|
||||||
|
"author_name": most_viewed_vlog.author.full_name,
|
||||||
|
"views_count": most_viewed_vlog.views_count
|
||||||
|
} if most_viewed_vlog else None
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/user/{user_id}")
|
||||||
|
async def get_user_stats(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get statistics for a specific user."""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
return {"error": "User not found"}
|
||||||
|
|
||||||
|
posts_count = db.query(Post).filter(Post.author_id == user_id).count()
|
||||||
|
vlogs_count = db.query(Vlog).filter(Vlog.author_id == user_id).count()
|
||||||
|
albums_count = db.query(Album).filter(Album.creator_id == user_id).count()
|
||||||
|
events_created = db.query(Event).filter(Event.creator_id == user_id).count()
|
||||||
|
|
||||||
|
# Get participation stats
|
||||||
|
participations = db.query(EventParticipation).filter(EventParticipation.user_id == user_id).all()
|
||||||
|
present_count = sum(1 for p in participations if p.status == ParticipationStatus.PRESENT)
|
||||||
|
absent_count = sum(1 for p in participations if p.status == ParticipationStatus.ABSENT)
|
||||||
|
maybe_count = sum(1 for p in participations if p.status == ParticipationStatus.MAYBE)
|
||||||
|
|
||||||
|
# Total views on vlogs
|
||||||
|
total_vlog_views = db.query(func.sum(Vlog.views_count)).filter(Vlog.author_id == user_id).scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user": {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"avatar_url": user.avatar_url,
|
||||||
|
"attendance_rate": user.attendance_rate,
|
||||||
|
"member_since": user.created_at
|
||||||
|
},
|
||||||
|
"content_stats": {
|
||||||
|
"posts_count": posts_count,
|
||||||
|
"vlogs_count": vlogs_count,
|
||||||
|
"albums_count": albums_count,
|
||||||
|
"events_created": events_created,
|
||||||
|
"total_vlog_views": total_vlog_views
|
||||||
|
},
|
||||||
|
"participation_stats": {
|
||||||
|
"total_events": len(participations),
|
||||||
|
"present_count": present_count,
|
||||||
|
"absent_count": absent_count,
|
||||||
|
"maybe_count": maybe_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/activity/user/{user_id}")
|
||||||
|
async def get_user_activity(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get recent activity for a specific user."""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
return {"error": "User not found"}
|
||||||
|
|
||||||
|
# Get recent posts
|
||||||
|
recent_posts = db.query(Post).filter(
|
||||||
|
Post.author_id == user_id
|
||||||
|
).order_by(Post.created_at.desc()).limit(5).all()
|
||||||
|
|
||||||
|
# Get recent vlogs
|
||||||
|
recent_vlogs = db.query(Vlog).filter(
|
||||||
|
Vlog.author_id == user_id
|
||||||
|
).order_by(Vlog.created_at.desc()).limit(5).all()
|
||||||
|
|
||||||
|
# Get recent albums
|
||||||
|
recent_albums = db.query(Album).filter(
|
||||||
|
Album.creator_id == user_id
|
||||||
|
).order_by(Album.created_at.desc()).limit(5).all()
|
||||||
|
|
||||||
|
# Get recent events created
|
||||||
|
recent_events = db.query(Event).filter(
|
||||||
|
Event.creator_id == user_id
|
||||||
|
).order_by(Event.created_at.desc()).limit(5).all()
|
||||||
|
|
||||||
|
# Combine and sort by date
|
||||||
|
activities = []
|
||||||
|
|
||||||
|
for post in recent_posts:
|
||||||
|
activities.append({
|
||||||
|
"id": post.id,
|
||||||
|
"type": "post",
|
||||||
|
"description": f"A publié : {post.content[:50]}...",
|
||||||
|
"created_at": post.created_at,
|
||||||
|
"link": f"/posts/{post.id}"
|
||||||
|
})
|
||||||
|
|
||||||
|
for vlog in recent_vlogs:
|
||||||
|
activities.append({
|
||||||
|
"id": vlog.id,
|
||||||
|
"type": "vlog",
|
||||||
|
"description": f"A publié un vlog : {vlog.title}",
|
||||||
|
"created_at": vlog.created_at,
|
||||||
|
"link": f"/vlogs/{vlog.id}"
|
||||||
|
})
|
||||||
|
|
||||||
|
for album in recent_albums:
|
||||||
|
activities.append({
|
||||||
|
"id": album.id,
|
||||||
|
"type": "album",
|
||||||
|
"description": f"A créé un album : {album.title}",
|
||||||
|
"created_at": album.created_at,
|
||||||
|
"link": f"/albums/{album.id}"
|
||||||
|
})
|
||||||
|
|
||||||
|
for event in recent_events:
|
||||||
|
activities.append({
|
||||||
|
"id": event.id,
|
||||||
|
"type": "event",
|
||||||
|
"description": f"A créé un événement : {event.title}",
|
||||||
|
"created_at": event.created_at,
|
||||||
|
"link": f"/events/{event.id}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by creation date (most recent first)
|
||||||
|
activities.sort(key=lambda x: x["created_at"], reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"activity": activities[:10] # Return top 10 most recent activities
|
||||||
|
}
|
||||||
295
backend/api/routers/tickets.py
Normal file
295
backend/api/routers/tickets.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
from config.database import get_db
|
||||||
|
from config.settings import settings
|
||||||
|
from models.ticket import Ticket, TicketType, TicketStatus, TicketPriority
|
||||||
|
from models.user import User
|
||||||
|
from schemas.ticket import TicketCreate, TicketUpdate, TicketResponse, TicketAdminUpdate
|
||||||
|
from utils.security import get_current_active_user, get_admin_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[TicketResponse])
|
||||||
|
async def get_user_tickets(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get current user's tickets."""
|
||||||
|
tickets = db.query(Ticket).filter(Ticket.user_id == current_user.id).order_by(Ticket.created_at.desc()).all()
|
||||||
|
return [format_ticket_response(ticket, db) for ticket in tickets]
|
||||||
|
|
||||||
|
@router.get("/admin", response_model=List[TicketResponse])
|
||||||
|
async def get_all_tickets_admin(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user),
|
||||||
|
status_filter: Optional[TicketStatus] = None,
|
||||||
|
type_filter: Optional[TicketType] = None,
|
||||||
|
priority_filter: Optional[TicketPriority] = None
|
||||||
|
):
|
||||||
|
"""Get all tickets (admin only)."""
|
||||||
|
query = db.query(Ticket)
|
||||||
|
|
||||||
|
if status_filter:
|
||||||
|
query = query.filter(Ticket.status == status_filter)
|
||||||
|
if type_filter:
|
||||||
|
query = query.filter(Ticket.ticket_type == type_filter)
|
||||||
|
if priority_filter:
|
||||||
|
query = query.filter(Ticket.priority == priority_filter)
|
||||||
|
|
||||||
|
tickets = query.order_by(Ticket.created_at.desc()).all()
|
||||||
|
return [format_ticket_response(ticket, db) for ticket in tickets]
|
||||||
|
|
||||||
|
@router.get("/{ticket_id}", response_model=TicketResponse)
|
||||||
|
async def get_ticket(
|
||||||
|
ticket_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get a specific ticket."""
|
||||||
|
ticket = db.query(Ticket).filter(Ticket.id == ticket_id).first()
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Ticket not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Users can only see their own tickets, admins can see all
|
||||||
|
if ticket.user_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to view this ticket"
|
||||||
|
)
|
||||||
|
|
||||||
|
return format_ticket_response(ticket, db)
|
||||||
|
|
||||||
|
@router.post("/", response_model=TicketResponse)
|
||||||
|
async def create_ticket(
|
||||||
|
title: str = Form(...),
|
||||||
|
description: str = Form(...),
|
||||||
|
ticket_type: str = Form("other"), # Changed from TicketType to str
|
||||||
|
priority: str = Form("medium"), # Changed from TicketPriority to str
|
||||||
|
screenshot: Optional[UploadFile] = File(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Create a new ticket."""
|
||||||
|
print(f"DEBUG - Received ticket data: title='{title}', description='{description}', ticket_type='{ticket_type}', priority='{priority}'")
|
||||||
|
|
||||||
|
# Validate and convert ticket_type
|
||||||
|
try:
|
||||||
|
ticket_type_enum = TicketType(ticket_type)
|
||||||
|
print(f"DEBUG - Ticket type converted successfully: {ticket_type_enum}")
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"DEBUG - Error converting ticket_type '{ticket_type}': {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid ticket type: {ticket_type}. Valid types: {[t.value for t in TicketType]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate and convert priority
|
||||||
|
try:
|
||||||
|
priority_enum = TicketPriority(priority)
|
||||||
|
print(f"DEBUG - Priority converted successfully: {priority_enum}")
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"DEBUG - Error converting priority '{priority}': {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid priority: {priority}. Valid priorities: {[p.value for p in TicketPriority]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("DEBUG - Starting ticket creation...")
|
||||||
|
|
||||||
|
# Create tickets directory
|
||||||
|
tickets_dir = Path(settings.UPLOAD_PATH) / "tickets" / str(current_user.id)
|
||||||
|
tickets_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"DEBUG - Created tickets directory: {tickets_dir}")
|
||||||
|
|
||||||
|
screenshot_path = None
|
||||||
|
if screenshot:
|
||||||
|
print(f"DEBUG - Processing screenshot: {screenshot.filename}, content_type: {screenshot.content_type}")
|
||||||
|
# Validate file type
|
||||||
|
if not screenshot.content_type.startswith('image/'):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Screenshot must be an image file"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
file_extension = screenshot.filename.split(".")[-1]
|
||||||
|
filename = f"ticket_{uuid.uuid4()}.{file_extension}"
|
||||||
|
file_path = tickets_dir / filename
|
||||||
|
|
||||||
|
# Save and optimize image
|
||||||
|
contents = await screenshot.read()
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(contents)
|
||||||
|
|
||||||
|
# Optimize image
|
||||||
|
try:
|
||||||
|
img = Image.open(file_path)
|
||||||
|
if img.size[0] > 1920 or img.size[1] > 1080:
|
||||||
|
img.thumbnail((1920, 1080), Image.Resampling.LANCZOS)
|
||||||
|
img.save(file_path, quality=85, optimize=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error optimizing screenshot: {e}")
|
||||||
|
|
||||||
|
screenshot_path = f"/tickets/{current_user.id}/{filename}"
|
||||||
|
print(f"DEBUG - Screenshot saved: {screenshot_path}")
|
||||||
|
|
||||||
|
print("DEBUG - Creating ticket object...")
|
||||||
|
ticket = Ticket(
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
ticket_type=ticket_type_enum,
|
||||||
|
priority=priority_enum,
|
||||||
|
user_id=current_user.id,
|
||||||
|
screenshot_path=screenshot_path
|
||||||
|
)
|
||||||
|
|
||||||
|
print("DEBUG - Adding ticket to database...")
|
||||||
|
db.add(ticket)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ticket)
|
||||||
|
print(f"DEBUG - Ticket created successfully with ID: {ticket.id}")
|
||||||
|
|
||||||
|
return format_ticket_response(ticket, db)
|
||||||
|
|
||||||
|
@router.put("/{ticket_id}", response_model=TicketResponse)
|
||||||
|
async def update_ticket(
|
||||||
|
ticket_id: int,
|
||||||
|
ticket_update: TicketUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Update a ticket."""
|
||||||
|
ticket = db.query(Ticket).filter(Ticket.id == ticket_id).first()
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Ticket not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Users can only update their own tickets, admins can update all
|
||||||
|
if ticket.user_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to update this ticket"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Regular users can only update title, description, and type
|
||||||
|
if not current_user.is_admin:
|
||||||
|
update_data = ticket_update.dict(exclude_unset=True)
|
||||||
|
allowed_fields = ['title', 'description', 'ticket_type']
|
||||||
|
update_data = {k: v for k, v in update_data.items() if k in allowed_fields}
|
||||||
|
else:
|
||||||
|
update_data = ticket_update.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
# Update resolved_at if status is resolved
|
||||||
|
if 'status' in update_data and update_data['status'] == TicketStatus.RESOLVED:
|
||||||
|
update_data['resolved_at'] = datetime.utcnow()
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(ticket, field, value)
|
||||||
|
|
||||||
|
ticket.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ticket)
|
||||||
|
|
||||||
|
return format_ticket_response(ticket, db)
|
||||||
|
|
||||||
|
@router.put("/{ticket_id}/admin", response_model=TicketResponse)
|
||||||
|
async def update_ticket_admin(
|
||||||
|
ticket_id: int,
|
||||||
|
ticket_update: TicketAdminUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Update a ticket as admin."""
|
||||||
|
ticket = db.query(Ticket).filter(Ticket.id == ticket_id).first()
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Ticket not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
update_data = ticket_update.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
# Update resolved_at if status is resolved
|
||||||
|
if 'status' in update_data and update_data['status'] == TicketStatus.RESOLVED:
|
||||||
|
update_data['resolved_at'] = datetime.utcnow()
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(ticket, field, value)
|
||||||
|
|
||||||
|
ticket.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ticket)
|
||||||
|
|
||||||
|
return format_ticket_response(ticket, db)
|
||||||
|
|
||||||
|
@router.delete("/{ticket_id}")
|
||||||
|
async def delete_ticket(
|
||||||
|
ticket_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Delete a ticket."""
|
||||||
|
ticket = db.query(Ticket).filter(Ticket.id == ticket_id).first()
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Ticket not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Users can only delete their own tickets, admins can delete all
|
||||||
|
if ticket.user_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to delete this ticket"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete screenshot if exists
|
||||||
|
if ticket.screenshot_path:
|
||||||
|
try:
|
||||||
|
screenshot_file = settings.UPLOAD_PATH + ticket.screenshot_path
|
||||||
|
if os.path.exists(screenshot_file):
|
||||||
|
os.remove(screenshot_file)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error deleting screenshot: {e}")
|
||||||
|
|
||||||
|
db.delete(ticket)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Ticket deleted successfully"}
|
||||||
|
|
||||||
|
def format_ticket_response(ticket: Ticket, db: Session) -> dict:
|
||||||
|
"""Format ticket response with user information."""
|
||||||
|
user = db.query(User).filter(User.id == ticket.user_id).first()
|
||||||
|
assigned_admin = None
|
||||||
|
if ticket.assigned_to:
|
||||||
|
assigned_admin = db.query(User).filter(User.id == ticket.assigned_to).first()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": ticket.id,
|
||||||
|
"title": ticket.title,
|
||||||
|
"description": ticket.description,
|
||||||
|
"ticket_type": ticket.ticket_type,
|
||||||
|
"status": ticket.status,
|
||||||
|
"priority": ticket.priority,
|
||||||
|
"user_id": ticket.user_id,
|
||||||
|
"assigned_to": ticket.assigned_to,
|
||||||
|
"screenshot_path": ticket.screenshot_path,
|
||||||
|
"admin_notes": ticket.admin_notes,
|
||||||
|
"created_at": ticket.created_at,
|
||||||
|
"updated_at": ticket.updated_at,
|
||||||
|
"resolved_at": ticket.resolved_at,
|
||||||
|
"user_name": user.full_name if user else "Unknown",
|
||||||
|
"user_email": user.email if user else "Unknown",
|
||||||
|
"assigned_admin_name": assigned_admin.full_name if assigned_admin else None
|
||||||
|
}
|
||||||
111
backend/api/routers/users.py
Normal file
111
backend/api/routers/users.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from PIL import Image
|
||||||
|
from config.database import get_db
|
||||||
|
from config.settings import settings
|
||||||
|
from models.user import User
|
||||||
|
from schemas.user import UserResponse, UserUpdate
|
||||||
|
from utils.security import get_current_active_user
|
||||||
|
from utils.settings_service import SettingsService
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def get_current_user_info(current_user: User = Depends(get_current_active_user)):
|
||||||
|
"""Get current user information."""
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[UserResponse])
|
||||||
|
async def get_all_users(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get all users."""
|
||||||
|
users = db.query(User).filter(User.is_active == True).all()
|
||||||
|
return users
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
|
async def get_user(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get a specific user by ID."""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.put("/me", response_model=UserResponse)
|
||||||
|
async def update_current_user(
|
||||||
|
user_update: UserUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Update current user information."""
|
||||||
|
if user_update.full_name:
|
||||||
|
current_user.full_name = user_update.full_name
|
||||||
|
if user_update.bio is not None:
|
||||||
|
current_user.bio = user_update.bio
|
||||||
|
if user_update.avatar_url is not None:
|
||||||
|
current_user.avatar_url = user_update.avatar_url
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(current_user)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
@router.post("/me/avatar", response_model=UserResponse)
|
||||||
|
async def upload_avatar(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Upload user avatar."""
|
||||||
|
# Validate file type
|
||||||
|
if not SettingsService.is_file_type_allowed(db, file.content_type or "unknown"):
|
||||||
|
allowed_types = SettingsService.get_setting(db, "allowed_image_types",
|
||||||
|
["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create user directory
|
||||||
|
user_dir = Path(settings.UPLOAD_PATH) / "avatars" / str(current_user.id)
|
||||||
|
user_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
file_extension = file.filename.split(".")[-1]
|
||||||
|
filename = f"{uuid.uuid4()}.{file_extension}"
|
||||||
|
file_path = user_dir / filename
|
||||||
|
|
||||||
|
# Save and resize image
|
||||||
|
contents = await file.read()
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(contents)
|
||||||
|
|
||||||
|
# Resize image to 800x800 with high quality
|
||||||
|
try:
|
||||||
|
img = Image.open(file_path)
|
||||||
|
img.thumbnail((800, 800))
|
||||||
|
img.save(file_path, quality=95, optimize=True)
|
||||||
|
except Exception as e:
|
||||||
|
os.remove(file_path)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Error processing image"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update user avatar URL
|
||||||
|
current_user.avatar_url = f"/avatars/{current_user.id}/{filename}"
|
||||||
|
db.commit()
|
||||||
|
db.refresh(current_user)
|
||||||
|
|
||||||
|
return current_user
|
||||||
374
backend/api/routers/vlogs.py
Normal file
374
backend/api/routers/vlogs.py
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from config.database import get_db
|
||||||
|
from config.settings import settings
|
||||||
|
from models.vlog import Vlog, VlogLike, VlogComment
|
||||||
|
from models.user import User
|
||||||
|
from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate
|
||||||
|
from utils.security import get_current_active_user
|
||||||
|
from utils.video_utils import generate_video_thumbnail, get_video_duration
|
||||||
|
from utils.settings_service import SettingsService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/", response_model=VlogResponse)
|
||||||
|
async def create_vlog(
|
||||||
|
vlog_data: VlogCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Create a new vlog."""
|
||||||
|
vlog = Vlog(
|
||||||
|
author_id=current_user.id,
|
||||||
|
**vlog_data.dict()
|
||||||
|
)
|
||||||
|
db.add(vlog)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(vlog)
|
||||||
|
return format_vlog_response(vlog, db, current_user.id)
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[VlogResponse])
|
||||||
|
async def get_vlogs(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""Get all vlogs."""
|
||||||
|
vlogs = db.query(Vlog).order_by(Vlog.created_at.desc()).limit(limit).offset(offset).all()
|
||||||
|
return [format_vlog_response(vlog, db, current_user.id) for vlog in vlogs]
|
||||||
|
|
||||||
|
@router.get("/{vlog_id}", response_model=VlogResponse)
|
||||||
|
async def get_vlog(
|
||||||
|
vlog_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get a specific vlog."""
|
||||||
|
vlog = db.query(Vlog).filter(Vlog.id == vlog_id).first()
|
||||||
|
if not vlog:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Vlog not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Increment view count
|
||||||
|
vlog.views_count += 1
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return format_vlog_response(vlog, db, current_user.id)
|
||||||
|
|
||||||
|
@router.put("/{vlog_id}", response_model=VlogResponse)
|
||||||
|
async def update_vlog(
|
||||||
|
vlog_id: int,
|
||||||
|
vlog_update: VlogUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Update a vlog."""
|
||||||
|
vlog = db.query(Vlog).filter(Vlog.id == vlog_id).first()
|
||||||
|
if not vlog:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Vlog not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if vlog.author_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to update this vlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, value in vlog_update.dict(exclude_unset=True).items():
|
||||||
|
setattr(vlog, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(vlog)
|
||||||
|
return format_vlog_response(vlog, db, current_user.id)
|
||||||
|
|
||||||
|
@router.delete("/{vlog_id}")
|
||||||
|
async def delete_vlog(
|
||||||
|
vlog_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Delete a vlog."""
|
||||||
|
vlog = db.query(Vlog).filter(Vlog.id == vlog_id).first()
|
||||||
|
if not vlog:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Vlog not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if vlog.author_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to delete this vlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete video files
|
||||||
|
try:
|
||||||
|
if vlog.video_url:
|
||||||
|
os.remove(settings.UPLOAD_PATH + vlog.video_url)
|
||||||
|
if vlog.thumbnail_url:
|
||||||
|
os.remove(settings.UPLOAD_PATH + vlog.thumbnail_url)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.delete(vlog)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Vlog deleted successfully"}
|
||||||
|
|
||||||
|
@router.post("/{vlog_id}/like")
|
||||||
|
async def toggle_vlog_like(
|
||||||
|
vlog_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Toggle like on a vlog."""
|
||||||
|
vlog = db.query(Vlog).filter(Vlog.id == vlog_id).first()
|
||||||
|
if not vlog:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Vlog not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_like = db.query(VlogLike).filter(
|
||||||
|
VlogLike.vlog_id == vlog_id,
|
||||||
|
VlogLike.user_id == current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_like:
|
||||||
|
# Unlike
|
||||||
|
db.delete(existing_like)
|
||||||
|
vlog.likes_count = max(0, vlog.likes_count - 1)
|
||||||
|
message = "Like removed"
|
||||||
|
else:
|
||||||
|
# Like
|
||||||
|
like = VlogLike(vlog_id=vlog_id, user_id=current_user.id)
|
||||||
|
db.add(like)
|
||||||
|
vlog.likes_count += 1
|
||||||
|
message = "Vlog liked"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": message, "likes_count": vlog.likes_count}
|
||||||
|
|
||||||
|
@router.post("/{vlog_id}/comment")
|
||||||
|
async def add_vlog_comment(
|
||||||
|
vlog_id: int,
|
||||||
|
comment_data: VlogCommentCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Add a comment to a vlog."""
|
||||||
|
vlog = db.query(Vlog).filter(Vlog.id == vlog_id).first()
|
||||||
|
if not vlog:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Vlog not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
comment = VlogComment(
|
||||||
|
vlog_id=vlog_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
content=comment_data.content
|
||||||
|
)
|
||||||
|
db.add(comment)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(comment)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Comment added successfully",
|
||||||
|
"comment": {
|
||||||
|
"id": comment.id,
|
||||||
|
"content": comment.content,
|
||||||
|
"user_id": comment.user_id,
|
||||||
|
"username": current_user.username,
|
||||||
|
"full_name": current_user.full_name,
|
||||||
|
"avatar_url": current_user.avatar_url,
|
||||||
|
"created_at": comment.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.delete("/{vlog_id}/comment/{comment_id}")
|
||||||
|
async def delete_vlog_comment(
|
||||||
|
vlog_id: int,
|
||||||
|
comment_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Delete a comment from a vlog."""
|
||||||
|
comment = db.query(VlogComment).filter(
|
||||||
|
VlogComment.id == comment_id,
|
||||||
|
VlogComment.vlog_id == vlog_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not comment:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Comment not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if comment.user_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to delete this comment"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.delete(comment)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Comment deleted successfully"}
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_vlog_video(
|
||||||
|
title: str = Form(...),
|
||||||
|
description: str = Form(None),
|
||||||
|
video: UploadFile = File(...),
|
||||||
|
thumbnail: UploadFile = File(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Upload a vlog video."""
|
||||||
|
# Validate video file
|
||||||
|
if not SettingsService.is_file_type_allowed(db, video.content_type or "unknown"):
|
||||||
|
allowed_types = SettingsService.get_setting(db, "allowed_video_types",
|
||||||
|
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid video type. Allowed types: {', '.join(allowed_types)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
video_content = await video.read()
|
||||||
|
max_size = SettingsService.get_max_upload_size(db, video.content_type or "video/mp4")
|
||||||
|
if len(video_content) > max_size:
|
||||||
|
max_size_mb = max_size // (1024 * 1024)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"Video file too large. Maximum size: {max_size_mb}MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create vlog directory
|
||||||
|
vlog_dir = Path(settings.UPLOAD_PATH) / "vlogs" / str(current_user.id)
|
||||||
|
vlog_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save video
|
||||||
|
video_extension = video.filename.split(".")[-1]
|
||||||
|
video_filename = f"{uuid.uuid4()}.{video_extension}"
|
||||||
|
video_path = vlog_dir / video_filename
|
||||||
|
|
||||||
|
with open(video_path, "wb") as f:
|
||||||
|
f.write(video_content)
|
||||||
|
|
||||||
|
# Process thumbnail
|
||||||
|
thumbnail_url = None
|
||||||
|
if thumbnail:
|
||||||
|
if thumbnail.content_type not in settings.ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid thumbnail type"
|
||||||
|
)
|
||||||
|
|
||||||
|
thumbnail_content = await thumbnail.read()
|
||||||
|
thumbnail_extension = thumbnail.filename.split(".")[-1]
|
||||||
|
thumbnail_filename = f"thumb_{uuid.uuid4()}.{thumbnail_extension}"
|
||||||
|
thumbnail_path = vlog_dir / thumbnail_filename
|
||||||
|
|
||||||
|
with open(thumbnail_path, "wb") as f:
|
||||||
|
f.write(thumbnail_content)
|
||||||
|
|
||||||
|
thumbnail_url = f"/vlogs/{current_user.id}/{thumbnail_filename}"
|
||||||
|
else:
|
||||||
|
# Generate automatic thumbnail from video
|
||||||
|
try:
|
||||||
|
thumbnail_filename = f"auto_thumb_{uuid.uuid4()}.jpg"
|
||||||
|
thumbnail_path = vlog_dir / thumbnail_filename
|
||||||
|
|
||||||
|
if generate_video_thumbnail(str(video_path), str(thumbnail_path)):
|
||||||
|
thumbnail_url = f"/vlogs/{current_user.id}/{thumbnail_filename}"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error generating automatic thumbnail: {e}")
|
||||||
|
# Continue without thumbnail if generation fails
|
||||||
|
|
||||||
|
# Get video duration
|
||||||
|
duration = None
|
||||||
|
try:
|
||||||
|
duration = int(get_video_duration(str(video_path)))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting video duration: {e}")
|
||||||
|
|
||||||
|
# Create vlog record
|
||||||
|
vlog = Vlog(
|
||||||
|
author_id=current_user.id,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
video_url=f"/vlogs/{current_user.id}/{video_filename}",
|
||||||
|
thumbnail_url=thumbnail_url,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
db.add(vlog)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(vlog)
|
||||||
|
|
||||||
|
return format_vlog_response(vlog, db, current_user.id)
|
||||||
|
|
||||||
|
def format_vlog_response(vlog: Vlog, db: Session, current_user_id: int) -> dict:
|
||||||
|
"""Format vlog response with author information, likes, and comments."""
|
||||||
|
# Check if current user liked this vlog
|
||||||
|
is_liked = db.query(VlogLike).filter(
|
||||||
|
VlogLike.vlog_id == vlog.id,
|
||||||
|
VlogLike.user_id == current_user_id
|
||||||
|
).first() is not None
|
||||||
|
|
||||||
|
# Format likes
|
||||||
|
likes = []
|
||||||
|
for like in vlog.likes:
|
||||||
|
user = db.query(User).filter(User.id == like.user_id).first()
|
||||||
|
if user:
|
||||||
|
likes.append({
|
||||||
|
"id": like.id,
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"avatar_url": user.avatar_url,
|
||||||
|
"created_at": like.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
# Format comments
|
||||||
|
comments = []
|
||||||
|
for comment in vlog.comments:
|
||||||
|
user = db.query(User).filter(User.id == comment.user_id).first()
|
||||||
|
if user:
|
||||||
|
comments.append({
|
||||||
|
"id": comment.id,
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"avatar_url": user.avatar_url,
|
||||||
|
"content": comment.content,
|
||||||
|
"created_at": comment.created_at,
|
||||||
|
"updated_at": comment.updated_at
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": vlog.id,
|
||||||
|
"author_id": vlog.author_id,
|
||||||
|
"title": vlog.title,
|
||||||
|
"description": vlog.description,
|
||||||
|
"video_url": vlog.video_url,
|
||||||
|
"thumbnail_url": vlog.thumbnail_url,
|
||||||
|
"duration": vlog.duration,
|
||||||
|
"views_count": vlog.views_count,
|
||||||
|
"likes_count": vlog.likes_count,
|
||||||
|
"created_at": vlog.created_at,
|
||||||
|
"updated_at": vlog.updated_at,
|
||||||
|
"author_name": vlog.author.full_name,
|
||||||
|
"author_avatar": vlog.author.avatar_url,
|
||||||
|
"is_liked": is_liked,
|
||||||
|
"likes": likes,
|
||||||
|
"comments": comments
|
||||||
|
}
|
||||||
230
backend/app.py
Normal file
230
backend/app.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config.settings import settings
|
||||||
|
from config.database import engine, Base
|
||||||
|
from api.routers import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings as settings_router, information, tickets
|
||||||
|
from utils.init_db import init_database
|
||||||
|
from utils.settings_service import SettingsService
|
||||||
|
from config.database import SessionLocal
|
||||||
|
|
||||||
|
# Create uploads directory if it doesn't exist
|
||||||
|
Path(settings.UPLOAD_PATH).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def init_default_settings():
|
||||||
|
"""Initialize default system settings."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Check if settings already exist
|
||||||
|
from models.settings import SystemSettings
|
||||||
|
existing_settings = db.query(SystemSettings).count()
|
||||||
|
|
||||||
|
if existing_settings == 0:
|
||||||
|
print("Initializing default system settings...")
|
||||||
|
|
||||||
|
default_settings = [
|
||||||
|
{
|
||||||
|
"key": "max_album_size_mb",
|
||||||
|
"value": "100",
|
||||||
|
"description": "Taille maximale des albums en MB",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_vlog_size_mb",
|
||||||
|
"value": "500",
|
||||||
|
"description": "Taille maximale des vlogs en MB",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_image_size_mb",
|
||||||
|
"value": "10",
|
||||||
|
"description": "Taille maximale des images en MB",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_video_size_mb",
|
||||||
|
"value": "100",
|
||||||
|
"description": "Taille maximale des vidéos en MB",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_media_per_album",
|
||||||
|
"value": "50",
|
||||||
|
"description": "Nombre maximum de médias par album",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "allowed_image_types",
|
||||||
|
"value": "image/jpeg,image/png,image/gif,image/webp",
|
||||||
|
"description": "Types d'images autorisés (séparés par des virgules)",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "allowed_video_types",
|
||||||
|
"value": "video/mp4,video/mpeg,video/quicktime,video/webm",
|
||||||
|
"description": "Types de vidéos autorisés (séparés par des virgules)",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_users",
|
||||||
|
"value": "50",
|
||||||
|
"description": "Nombre maximum d'utilisateurs",
|
||||||
|
"category": "general"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enable_registration",
|
||||||
|
"value": "true",
|
||||||
|
"description": "Autoriser les nouvelles inscriptions",
|
||||||
|
"category": "general"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
for setting_data in default_settings:
|
||||||
|
setting = SystemSettings(**setting_data)
|
||||||
|
db.add(setting)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f"Created {len(default_settings)} default settings")
|
||||||
|
else:
|
||||||
|
print("System settings already exist, checking for missing settings...")
|
||||||
|
|
||||||
|
# Check for missing settings and add them
|
||||||
|
all_settings = [
|
||||||
|
{
|
||||||
|
"key": "max_album_size_mb",
|
||||||
|
"value": "100",
|
||||||
|
"description": "Taille maximale des albums en MB",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_vlog_size_mb",
|
||||||
|
"value": "500",
|
||||||
|
"description": "Taille maximale des vlogs en MB",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_image_size_mb",
|
||||||
|
"value": "10",
|
||||||
|
"description": "Taille maximale des images en MB",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_video_size_mb",
|
||||||
|
"value": "100",
|
||||||
|
"description": "Taille maximale des vidéos en MB",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_media_per_album",
|
||||||
|
"value": "50",
|
||||||
|
"description": "Nombre maximum de médias par album",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "allowed_image_types",
|
||||||
|
"value": "image/jpeg,image/png,image/gif,image/webp",
|
||||||
|
"description": "Types d'images autorisés (séparés par des virgules)",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "allowed_video_types",
|
||||||
|
"value": "video/mp4,video/mpeg,video/quicktime,video/webm",
|
||||||
|
"description": "Types de vidéos autorisés (séparés par des virgules)",
|
||||||
|
"category": "uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_users",
|
||||||
|
"value": "50",
|
||||||
|
"description": "Nombre maximum d'utilisateurs",
|
||||||
|
"category": "general"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enable_registration",
|
||||||
|
"value": "true",
|
||||||
|
"description": "Autoriser les nouvelles inscriptions",
|
||||||
|
"category": "general"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
added_count = 0
|
||||||
|
for setting_data in all_settings:
|
||||||
|
existing = db.query(SystemSettings).filter(SystemSettings.key == setting_data["key"]).first()
|
||||||
|
if not existing:
|
||||||
|
setting = SystemSettings(**setting_data)
|
||||||
|
db.add(setting)
|
||||||
|
added_count += 1
|
||||||
|
|
||||||
|
if added_count > 0:
|
||||||
|
db.commit()
|
||||||
|
print(f"Added {added_count} missing settings")
|
||||||
|
else:
|
||||||
|
print("All settings are already present")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error initializing settings: {e}")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Startup
|
||||||
|
print("Starting LeDiscord backend...")
|
||||||
|
# Create tables
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
# Initialize database with admin user
|
||||||
|
init_database()
|
||||||
|
# Initialize default settings
|
||||||
|
init_default_settings()
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
print("Shutting down LeDiscord backend...")
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="LeDiscord API",
|
||||||
|
description="API pour la plateforme communautaire LeDiscord",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.CORS_ORIGINS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount static files for uploads
|
||||||
|
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_PATH), name="uploads")
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
|
||||||
|
app.include_router(users.router, prefix="/api/users", tags=["Users"])
|
||||||
|
app.include_router(events.router, prefix="/api/events", tags=["Events"])
|
||||||
|
app.include_router(albums.router, prefix="/api/albums", tags=["Albums"])
|
||||||
|
app.include_router(posts.router, prefix="/api/posts", tags=["Posts"])
|
||||||
|
app.include_router(vlogs.router, prefix="/api/vlogs", tags=["Vlogs"])
|
||||||
|
app.include_router(stats.router, prefix="/api/stats", tags=["Statistics"])
|
||||||
|
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
||||||
|
app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"])
|
||||||
|
app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"])
|
||||||
|
app.include_router(information.router, prefix="/api/information", tags=["Information"])
|
||||||
|
app.include_router(tickets.router, prefix="/api/tickets", tags=["Tickets"])
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {
|
||||||
|
"message": "Bienvenue sur LeDiscord API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"docs": "/docs"
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "healthy"}
|
||||||
16
backend/config/database.py
Normal file
16
backend/config/database.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from .settings import settings
|
||||||
|
|
||||||
|
engine = create_engine(settings.DATABASE_URL)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
37
backend/config/settings.py
Normal file
37
backend/config/settings.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from typing import List
|
||||||
|
import os
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://lediscord_user:lediscord_password@postgres:5432/lediscord")
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY: str = "your-super-secret-jwt-key-change-me"
|
||||||
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
JWT_EXPIRATION_MINUTES: int = 10080 # 7 days
|
||||||
|
|
||||||
|
# Upload
|
||||||
|
UPLOAD_PATH: str = "/app/uploads"
|
||||||
|
MAX_UPLOAD_SIZE: int = 100 * 1024 * 1024 # 100MB
|
||||||
|
ALLOWED_IMAGE_TYPES: List[str] = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
||||||
|
ALLOWED_VIDEO_TYPES: List[str] = ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"]
|
||||||
|
|
||||||
|
# CORS - Fixed list, no environment parsing
|
||||||
|
CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||||
|
|
||||||
|
# Email
|
||||||
|
SMTP_HOST: str = "smtp.gmail.com"
|
||||||
|
SMTP_PORT: int = 587
|
||||||
|
SMTP_USER: str = ""
|
||||||
|
SMTP_PASSWORD: str = ""
|
||||||
|
SMTP_FROM: str = "noreply@lediscord.com"
|
||||||
|
|
||||||
|
# Admin
|
||||||
|
ADMIN_EMAIL: str = "admin@lediscord.com"
|
||||||
|
ADMIN_PASSWORD: str = "admin123"
|
||||||
|
|
||||||
|
# App
|
||||||
|
APP_NAME: str = "LeDiscord"
|
||||||
|
APP_URL: str = "http://localhost:5173"
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
30
backend/models/__init__.py
Normal file
30
backend/models/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from .user import User
|
||||||
|
from .event import Event, EventParticipation
|
||||||
|
from .album import Album, Media, MediaLike
|
||||||
|
from .post import Post, PostMention
|
||||||
|
from .vlog import Vlog, VlogLike, VlogComment
|
||||||
|
from .notification import Notification
|
||||||
|
from .settings import SystemSettings
|
||||||
|
from .information import Information
|
||||||
|
from .ticket import Ticket, TicketType, TicketStatus, TicketPriority
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"Event",
|
||||||
|
"EventParticipation",
|
||||||
|
"Album",
|
||||||
|
"Media",
|
||||||
|
"MediaLike",
|
||||||
|
"Post",
|
||||||
|
"PostMention",
|
||||||
|
"Vlog",
|
||||||
|
"VlogLike",
|
||||||
|
"VlogComment",
|
||||||
|
"Notification",
|
||||||
|
"SystemSettings",
|
||||||
|
"Information",
|
||||||
|
"Ticket",
|
||||||
|
"TicketType",
|
||||||
|
"TicketStatus",
|
||||||
|
"TicketPriority"
|
||||||
|
]
|
||||||
58
backend/models/album.py
Normal file
58
backend/models/album.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum as SQLEnum
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
import enum
|
||||||
|
from config.database import Base
|
||||||
|
|
||||||
|
class MediaType(enum.Enum):
|
||||||
|
IMAGE = "image"
|
||||||
|
VIDEO = "video"
|
||||||
|
|
||||||
|
class Album(Base):
|
||||||
|
__tablename__ = "albums"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
creator_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
event_id = Column(Integer, ForeignKey("events.id"), nullable=True)
|
||||||
|
cover_image = Column(String)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
creator = relationship("User", back_populates="albums")
|
||||||
|
event = relationship("Event", back_populates="albums")
|
||||||
|
media = relationship("Media", back_populates="album", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class Media(Base):
|
||||||
|
__tablename__ = "media"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
album_id = Column(Integer, ForeignKey("albums.id"), nullable=False)
|
||||||
|
file_path = Column(String, nullable=False)
|
||||||
|
thumbnail_path = Column(String)
|
||||||
|
media_type = Column(SQLEnum(MediaType), nullable=False)
|
||||||
|
caption = Column(Text)
|
||||||
|
file_size = Column(Integer) # in bytes
|
||||||
|
width = Column(Integer)
|
||||||
|
height = Column(Integer)
|
||||||
|
duration = Column(Integer) # in seconds for videos
|
||||||
|
likes_count = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
album = relationship("Album", back_populates="media")
|
||||||
|
likes = relationship("MediaLike", back_populates="media", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class MediaLike(Base):
|
||||||
|
__tablename__ = "media_likes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
media_id = Column(Integer, ForeignKey("media.id"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
media = relationship("Media", back_populates="likes")
|
||||||
|
user = relationship("User", back_populates="media_likes")
|
||||||
46
backend/models/event.py
Normal file
46
backend/models/event.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum as SQLEnum, Float
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
import enum
|
||||||
|
from config.database import Base
|
||||||
|
|
||||||
|
class ParticipationStatus(enum.Enum):
|
||||||
|
PRESENT = "present"
|
||||||
|
ABSENT = "absent"
|
||||||
|
MAYBE = "maybe"
|
||||||
|
PENDING = "pending"
|
||||||
|
|
||||||
|
class Event(Base):
|
||||||
|
__tablename__ = "events"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
location = Column(String)
|
||||||
|
latitude = Column(Float, nullable=True) # Coordonnée latitude
|
||||||
|
longitude = Column(Float, nullable=True) # Coordonnée longitude
|
||||||
|
date = Column(DateTime, nullable=False)
|
||||||
|
end_date = Column(DateTime)
|
||||||
|
creator_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
cover_image = Column(String)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
creator = relationship("User", back_populates="created_events")
|
||||||
|
participations = relationship("EventParticipation", back_populates="event", cascade="all, delete-orphan")
|
||||||
|
albums = relationship("Album", back_populates="event")
|
||||||
|
|
||||||
|
class EventParticipation(Base):
|
||||||
|
__tablename__ = "event_participations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
event_id = Column(Integer, ForeignKey("events.id"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
status = Column(SQLEnum(ParticipationStatus), default=ParticipationStatus.PENDING)
|
||||||
|
response_date = Column(DateTime, default=datetime.utcnow)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
event = relationship("Event", back_populates="participations")
|
||||||
|
user = relationship("User", back_populates="event_participations")
|
||||||
15
backend/models/information.py
Normal file
15
backend/models/information.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean
|
||||||
|
from datetime import datetime
|
||||||
|
from config.database import Base
|
||||||
|
|
||||||
|
class Information(Base):
|
||||||
|
__tablename__ = "informations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
category = Column(String, default="general") # general, release, upcoming, etc.
|
||||||
|
is_published = Column(Boolean, default=True)
|
||||||
|
priority = Column(Integer, default=0) # Pour l'ordre d'affichage
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
29
backend/models/notification.py
Normal file
29
backend/models/notification.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, Text, Enum as SQLEnum
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
import enum
|
||||||
|
from config.database import Base
|
||||||
|
|
||||||
|
class NotificationType(enum.Enum):
|
||||||
|
EVENT_INVITATION = "event_invitation"
|
||||||
|
EVENT_REMINDER = "event_reminder"
|
||||||
|
POST_MENTION = "post_mention"
|
||||||
|
NEW_ALBUM = "new_album"
|
||||||
|
NEW_VLOG = "new_vlog"
|
||||||
|
SYSTEM = "system"
|
||||||
|
|
||||||
|
class Notification(Base):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
type = Column(SQLEnum(NotificationType), nullable=False)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
message = Column(Text, nullable=False)
|
||||||
|
link = Column(String) # Link to the related content
|
||||||
|
is_read = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
read_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", back_populates="notifications")
|
||||||
60
backend/models/post.py
Normal file
60
backend/models/post.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from config.database import Base
|
||||||
|
|
||||||
|
class Post(Base):
|
||||||
|
__tablename__ = "posts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
image_url = Column(String)
|
||||||
|
likes_count = Column(Integer, default=0)
|
||||||
|
comments_count = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
author = relationship("User", back_populates="posts")
|
||||||
|
mentions = relationship("PostMention", back_populates="post", cascade="all, delete-orphan")
|
||||||
|
likes = relationship("PostLike", back_populates="post", cascade="all, delete-orphan")
|
||||||
|
comments = relationship("PostComment", back_populates="post", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class PostMention(Base):
|
||||||
|
__tablename__ = "post_mentions"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
post_id = Column(Integer, ForeignKey("posts.id"), nullable=False)
|
||||||
|
mentioned_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
post = relationship("Post", back_populates="mentions")
|
||||||
|
mentioned_user = relationship("User", back_populates="mentions")
|
||||||
|
|
||||||
|
class PostLike(Base):
|
||||||
|
__tablename__ = "post_likes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
post_id = Column(Integer, ForeignKey("posts.id"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
post = relationship("Post", back_populates="likes")
|
||||||
|
user = relationship("User", back_populates="post_likes")
|
||||||
|
|
||||||
|
class PostComment(Base):
|
||||||
|
__tablename__ = "post_comments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
post_id = Column(Integer, ForeignKey("posts.id"), nullable=False)
|
||||||
|
author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
post = relationship("Post", back_populates="comments")
|
||||||
|
author = relationship("User", back_populates="post_comments")
|
||||||
15
backend/models/settings.py
Normal file
15
backend/models/settings.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime
|
||||||
|
from datetime import datetime
|
||||||
|
from config.database import Base
|
||||||
|
|
||||||
|
class SystemSettings(Base):
|
||||||
|
__tablename__ = "system_settings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
key = Column(String, unique=True, nullable=False, index=True)
|
||||||
|
value = Column(String, nullable=False)
|
||||||
|
description = Column(String)
|
||||||
|
category = Column(String, default="general") # general, uploads, notifications, etc.
|
||||||
|
is_editable = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
45
backend/models/ticket.py
Normal file
45
backend/models/ticket.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum as SQLEnum
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
import enum
|
||||||
|
from config.database import Base
|
||||||
|
|
||||||
|
class TicketType(enum.Enum):
|
||||||
|
BUG = "bug"
|
||||||
|
FEATURE_REQUEST = "feature_request"
|
||||||
|
IMPROVEMENT = "improvement"
|
||||||
|
SUPPORT = "support"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
class TicketStatus(enum.Enum):
|
||||||
|
OPEN = "open"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
RESOLVED = "resolved"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
class TicketPriority(enum.Enum):
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
|
URGENT = "urgent"
|
||||||
|
|
||||||
|
class Ticket(Base):
|
||||||
|
__tablename__ = "tickets"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
description = Column(Text, nullable=False)
|
||||||
|
ticket_type = Column(SQLEnum(TicketType), nullable=False, default=TicketType.OTHER)
|
||||||
|
status = Column(SQLEnum(TicketStatus), nullable=False, default=TicketStatus.OPEN)
|
||||||
|
priority = Column(SQLEnum(TicketPriority), nullable=False, default=TicketPriority.MEDIUM)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
assigned_to = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
screenshot_path = Column(String, nullable=True)
|
||||||
|
admin_notes = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
resolved_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", foreign_keys=[user_id], back_populates="tickets")
|
||||||
|
assigned_admin = relationship("User", foreign_keys=[assigned_to])
|
||||||
35
backend/models/user.py
Normal file
35
backend/models/user.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from config.database import Base
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
email = Column(String, unique=True, index=True, nullable=False)
|
||||||
|
username = Column(String, unique=True, index=True, nullable=False)
|
||||||
|
full_name = Column(String, nullable=False)
|
||||||
|
hashed_password = Column(String, nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
is_admin = Column(Boolean, default=False)
|
||||||
|
avatar_url = Column(String, nullable=True)
|
||||||
|
bio = Column(String, nullable=True)
|
||||||
|
attendance_rate = Column(Float, default=0.0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
event_participations = relationship("EventParticipation", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
created_events = relationship("Event", back_populates="creator", cascade="all, delete-orphan")
|
||||||
|
albums = relationship("Album", back_populates="creator", cascade="all, delete-orphan")
|
||||||
|
posts = relationship("Post", back_populates="author", 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")
|
||||||
|
notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
vlog_likes = relationship("VlogLike", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
vlog_comments = relationship("VlogComment", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
media_likes = relationship("MediaLike", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
post_likes = relationship("PostLike", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
post_comments = relationship("PostComment", back_populates="author", cascade="all, delete-orphan")
|
||||||
|
tickets = relationship("Ticket", foreign_keys="[Ticket.user_id]", back_populates="user")
|
||||||
50
backend/models/vlog.py
Normal file
50
backend/models/vlog.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from config.database import Base
|
||||||
|
|
||||||
|
class Vlog(Base):
|
||||||
|
__tablename__ = "vlogs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
video_url = Column(String, nullable=False)
|
||||||
|
thumbnail_url = Column(String)
|
||||||
|
duration = Column(Integer) # in seconds
|
||||||
|
views_count = Column(Integer, default=0)
|
||||||
|
likes_count = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
author = relationship("User", back_populates="vlogs")
|
||||||
|
likes = relationship("VlogLike", back_populates="vlog", cascade="all, delete-orphan")
|
||||||
|
comments = relationship("VlogComment", back_populates="vlog", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class VlogLike(Base):
|
||||||
|
__tablename__ = "vlog_likes"
|
||||||
|
|
||||||
|
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", back_populates="likes")
|
||||||
|
user = relationship("User", back_populates="vlog_likes")
|
||||||
|
|
||||||
|
class VlogComment(Base):
|
||||||
|
__tablename__ = "vlog_comments"
|
||||||
|
|
||||||
|
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)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
vlog = relationship("Vlog", back_populates="comments")
|
||||||
|
user = relationship("User", back_populates="vlog_comments")
|
||||||
21
backend/requirements.txt
Normal file
21
backend/requirements.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
alembic==1.12.1
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.6
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
pydantic==2.5.0
|
||||||
|
pydantic[email]==2.5.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
aiofiles==23.2.1
|
||||||
|
pillow==10.1.0
|
||||||
|
httpx==0.25.2
|
||||||
|
redis==5.0.1
|
||||||
|
celery==5.3.4
|
||||||
|
flower==2.0.1
|
||||||
|
python-magic==0.4.27
|
||||||
|
numpy==1.26.4
|
||||||
|
opencv-python==4.8.1.78
|
||||||
21
backend/schemas/__init__.py
Normal file
21
backend/schemas/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from .user import UserCreate, UserUpdate, UserResponse, UserLogin, Token
|
||||||
|
from .event import EventCreate, EventUpdate, EventResponse, ParticipationUpdate
|
||||||
|
from .album import AlbumCreate, AlbumUpdate, AlbumResponse, MediaResponse, MediaLikeResponse
|
||||||
|
from .post import PostCreate, PostUpdate, PostResponse
|
||||||
|
from .vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate, VlogLikeResponse, VlogCommentResponse
|
||||||
|
from .notification import NotificationResponse
|
||||||
|
from .settings import SystemSettingCreate, SystemSettingUpdate, SystemSettingResponse, SettingsCategoryResponse, UploadLimitsResponse
|
||||||
|
from .information import InformationCreate, InformationUpdate, InformationResponse
|
||||||
|
from .ticket import TicketCreate, TicketUpdate, TicketResponse, TicketAdminUpdate
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"UserCreate", "UserUpdate", "UserResponse", "UserLogin", "Token",
|
||||||
|
"EventCreate", "EventUpdate", "EventResponse", "ParticipationUpdate",
|
||||||
|
"AlbumCreate", "AlbumUpdate", "AlbumResponse", "MediaResponse", "MediaLikeResponse",
|
||||||
|
"PostCreate", "PostUpdate", "PostResponse",
|
||||||
|
"VlogCreate", "VlogUpdate", "VlogResponse", "VlogCommentCreate", "VlogLikeResponse", "VlogCommentResponse",
|
||||||
|
"NotificationResponse",
|
||||||
|
"SystemSettingCreate", "SystemSettingUpdate", "SystemSettingResponse", "SettingsCategoryResponse", "UploadLimitsResponse",
|
||||||
|
"InformationCreate", "InformationUpdate", "InformationResponse",
|
||||||
|
"TicketCreate", "TicketUpdate", "TicketResponse", "TicketAdminUpdate"
|
||||||
|
]
|
||||||
61
backend/schemas/album.py
Normal file
61
backend/schemas/album.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from models.album import MediaType
|
||||||
|
|
||||||
|
class AlbumBase(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
event_id: Optional[int] = None
|
||||||
|
|
||||||
|
class AlbumCreate(AlbumBase):
|
||||||
|
cover_image: Optional[str] = None
|
||||||
|
|
||||||
|
class AlbumUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
event_id: Optional[int] = None
|
||||||
|
cover_image: Optional[str] = None
|
||||||
|
|
||||||
|
class MediaLikeResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
full_name: str
|
||||||
|
avatar_url: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class MediaResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
file_path: str
|
||||||
|
thumbnail_path: Optional[str]
|
||||||
|
media_type: MediaType
|
||||||
|
caption: Optional[str]
|
||||||
|
file_size: int
|
||||||
|
width: Optional[int]
|
||||||
|
height: Optional[int]
|
||||||
|
duration: Optional[int]
|
||||||
|
likes_count: int
|
||||||
|
is_liked: Optional[bool] = None
|
||||||
|
likes: List[MediaLikeResponse] = []
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class AlbumResponse(AlbumBase):
|
||||||
|
id: int
|
||||||
|
creator_id: int
|
||||||
|
creator_name: str
|
||||||
|
cover_image: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
media_count: int = 0
|
||||||
|
media: List[MediaResponse] = []
|
||||||
|
event_title: Optional[str] = None
|
||||||
|
top_media: List[MediaResponse] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
55
backend/schemas/event.py
Normal file
55
backend/schemas/event.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from models.event import ParticipationStatus
|
||||||
|
|
||||||
|
class EventBase(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
latitude: Optional[float] = None
|
||||||
|
longitude: Optional[float] = None
|
||||||
|
date: datetime
|
||||||
|
end_date: Optional[datetime] = None
|
||||||
|
|
||||||
|
class EventCreate(EventBase):
|
||||||
|
cover_image: Optional[str] = None
|
||||||
|
|
||||||
|
class EventUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
latitude: Optional[float] = None
|
||||||
|
longitude: Optional[float] = None
|
||||||
|
date: Optional[datetime] = None
|
||||||
|
end_date: Optional[datetime] = None
|
||||||
|
cover_image: Optional[str] = None
|
||||||
|
|
||||||
|
class ParticipationResponse(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
full_name: str
|
||||||
|
avatar_url: Optional[str]
|
||||||
|
status: ParticipationStatus
|
||||||
|
response_date: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class EventResponse(EventBase):
|
||||||
|
id: int
|
||||||
|
creator_id: int
|
||||||
|
creator_name: str
|
||||||
|
cover_image: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
participations: List[ParticipationResponse] = []
|
||||||
|
present_count: int = 0
|
||||||
|
absent_count: int = 0
|
||||||
|
maybe_count: int = 0
|
||||||
|
pending_count: int = 0
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class ParticipationUpdate(BaseModel):
|
||||||
|
status: ParticipationStatus
|
||||||
28
backend/schemas/information.py
Normal file
28
backend/schemas/information.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class InformationBase(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
category: str = Field(default="general")
|
||||||
|
is_published: bool = Field(default=True)
|
||||||
|
priority: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
class InformationCreate(InformationBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class InformationUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||||
|
content: Optional[str] = Field(None, min_length=1)
|
||||||
|
category: Optional[str] = None
|
||||||
|
is_published: Optional[bool] = None
|
||||||
|
priority: Optional[int] = Field(None, ge=0)
|
||||||
|
|
||||||
|
class InformationResponse(InformationBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
17
backend/schemas/notification.py
Normal file
17
backend/schemas/notification.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from models.notification import NotificationType
|
||||||
|
|
||||||
|
class NotificationResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
type: NotificationType
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
link: Optional[str]
|
||||||
|
is_read: bool
|
||||||
|
created_at: datetime
|
||||||
|
read_at: Optional[datetime]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
54
backend/schemas/post.py
Normal file
54
backend/schemas/post.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class PostBase(BaseModel):
|
||||||
|
content: str = Field(..., min_length=1, max_length=5000)
|
||||||
|
|
||||||
|
class PostCreate(PostBase):
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
mentioned_user_ids: List[int] = []
|
||||||
|
|
||||||
|
class PostUpdate(BaseModel):
|
||||||
|
content: Optional[str] = Field(None, min_length=1, max_length=5000)
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
|
||||||
|
class PostCommentCreate(BaseModel):
|
||||||
|
content: str = Field(..., min_length=1, max_length=500)
|
||||||
|
|
||||||
|
class MentionedUser(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
full_name: str
|
||||||
|
avatar_url: Optional[str]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class PostCommentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
content: str
|
||||||
|
author_id: int
|
||||||
|
author_name: str
|
||||||
|
author_avatar: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class PostResponse(PostBase):
|
||||||
|
id: int
|
||||||
|
author_id: int
|
||||||
|
author_name: str
|
||||||
|
author_avatar: Optional[str]
|
||||||
|
image_url: Optional[str]
|
||||||
|
likes_count: int = 0
|
||||||
|
comments_count: int = 0
|
||||||
|
is_liked: Optional[bool] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
mentioned_users: List[MentionedUser] = []
|
||||||
|
comments: List[PostCommentResponse] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
40
backend/schemas/settings.py
Normal file
40
backend/schemas/settings.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class SystemSettingBase(BaseModel):
|
||||||
|
key: str = Field(..., description="Clé unique du paramètre")
|
||||||
|
value: str = Field(..., description="Valeur du paramètre")
|
||||||
|
description: Optional[str] = Field(None, description="Description du paramètre")
|
||||||
|
category: str = Field(default="general", description="Catégorie du paramètre")
|
||||||
|
|
||||||
|
class SystemSettingCreate(SystemSettingBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SystemSettingUpdate(BaseModel):
|
||||||
|
value: str = Field(..., description="Nouvelle valeur du paramètre")
|
||||||
|
|
||||||
|
class SystemSettingResponse(SystemSettingBase):
|
||||||
|
id: int
|
||||||
|
is_editable: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class SettingsCategoryResponse(BaseModel):
|
||||||
|
category: str
|
||||||
|
settings: list[SystemSettingResponse]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class UploadLimitsResponse(BaseModel):
|
||||||
|
max_album_size_mb: int
|
||||||
|
max_vlog_size_mb: int
|
||||||
|
max_image_size_mb: int
|
||||||
|
max_video_size_mb: int
|
||||||
|
max_media_per_album: int
|
||||||
|
allowed_image_types: list[str]
|
||||||
|
allowed_video_types: list[str]
|
||||||
47
backend/schemas/ticket.py
Normal file
47
backend/schemas/ticket.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from models.ticket import TicketType, TicketStatus, TicketPriority
|
||||||
|
|
||||||
|
class TicketBase(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
description: str = Field(..., min_length=1)
|
||||||
|
ticket_type: TicketType = Field(default=TicketType.OTHER)
|
||||||
|
priority: TicketPriority = Field(default=TicketPriority.MEDIUM)
|
||||||
|
|
||||||
|
class TicketCreate(TicketBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TicketUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||||
|
description: Optional[str] = Field(None, min_length=1)
|
||||||
|
ticket_type: Optional[TicketType] = None
|
||||||
|
status: Optional[TicketStatus] = None
|
||||||
|
priority: Optional[TicketPriority] = None
|
||||||
|
assigned_to: Optional[int] = None
|
||||||
|
admin_notes: Optional[str] = None
|
||||||
|
|
||||||
|
class TicketResponse(TicketBase):
|
||||||
|
id: int
|
||||||
|
status: TicketStatus
|
||||||
|
user_id: int
|
||||||
|
assigned_to: Optional[int]
|
||||||
|
screenshot_path: Optional[str]
|
||||||
|
admin_notes: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
resolved_at: Optional[datetime]
|
||||||
|
|
||||||
|
# User information
|
||||||
|
user_name: str
|
||||||
|
user_email: str
|
||||||
|
assigned_admin_name: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class TicketAdminUpdate(BaseModel):
|
||||||
|
status: Optional[TicketStatus] = None
|
||||||
|
priority: Optional[TicketPriority] = None
|
||||||
|
assigned_to: Optional[int] = None
|
||||||
|
admin_notes: Optional[str] = None
|
||||||
41
backend/schemas/user.py
Normal file
41
backend/schemas/user.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
username: str = Field(..., min_length=3, max_length=50)
|
||||||
|
full_name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str = Field(..., min_length=6)
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
full_name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
bio: Optional[str] = Field(None, max_length=500)
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
|
||||||
|
class UserResponse(UserBase):
|
||||||
|
id: int
|
||||||
|
is_active: bool
|
||||||
|
is_admin: bool
|
||||||
|
avatar_url: Optional[str]
|
||||||
|
bio: Optional[str]
|
||||||
|
attendance_rate: float
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: UserResponse
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
email: Optional[str] = None
|
||||||
63
backend/schemas/vlog.py
Normal file
63
backend/schemas/vlog.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class VlogBase(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
class VlogCreate(VlogBase):
|
||||||
|
video_url: str
|
||||||
|
thumbnail_url: Optional[str] = None
|
||||||
|
duration: Optional[int] = None
|
||||||
|
|
||||||
|
class VlogUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
thumbnail_url: Optional[str] = None
|
||||||
|
|
||||||
|
class VlogLikeResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
full_name: str
|
||||||
|
avatar_url: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class VlogCommentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
full_name: str
|
||||||
|
avatar_url: Optional[str]
|
||||||
|
content: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class VlogResponse(VlogBase):
|
||||||
|
id: int
|
||||||
|
author_id: int
|
||||||
|
author_name: str
|
||||||
|
author_avatar: Optional[str]
|
||||||
|
video_url: str
|
||||||
|
thumbnail_url: Optional[str]
|
||||||
|
duration: Optional[int]
|
||||||
|
views_count: int
|
||||||
|
likes_count: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
is_liked: Optional[bool] = None
|
||||||
|
likes: List[VlogLikeResponse] = []
|
||||||
|
comments: List[VlogCommentResponse] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class VlogCommentCreate(BaseModel):
|
||||||
|
content: str = Field(..., min_length=1, max_length=1000)
|
||||||
72
backend/utils/email.py
Normal file
72
backend/utils/email.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from config.settings import settings
|
||||||
|
|
||||||
|
def send_email(to_email: str, subject: str, body: str, html_body: str = None):
|
||||||
|
"""Send an email notification."""
|
||||||
|
if not settings.SMTP_USER or not settings.SMTP_PASSWORD:
|
||||||
|
print(f"Email configuration missing, skipping email to {to_email}")
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg['From'] = settings.SMTP_FROM
|
||||||
|
msg['To'] = to_email
|
||||||
|
|
||||||
|
# Add plain text part
|
||||||
|
text_part = MIMEText(body, 'plain')
|
||||||
|
msg.attach(text_part)
|
||||||
|
|
||||||
|
# Add HTML part if provided
|
||||||
|
if html_body:
|
||||||
|
html_part = MIMEText(html_body, 'html')
|
||||||
|
msg.attach(html_part)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
||||||
|
server.starttls()
|
||||||
|
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||||
|
server.send_message(msg)
|
||||||
|
print(f"Email sent successfully to {to_email}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to send email to {to_email}: {e}")
|
||||||
|
|
||||||
|
def send_event_notification(to_email: str, event):
|
||||||
|
"""Send event notification email."""
|
||||||
|
subject = f"Nouvel événement: {event.title}"
|
||||||
|
|
||||||
|
body = f"""
|
||||||
|
Bonjour,
|
||||||
|
|
||||||
|
Un nouvel événement a été créé sur LeDiscord:
|
||||||
|
|
||||||
|
{event.title}
|
||||||
|
Date: {event.date.strftime('%d/%m/%Y à %H:%M')}
|
||||||
|
Lieu: {event.location or 'Non spécifié'}
|
||||||
|
|
||||||
|
{event.description or ''}
|
||||||
|
|
||||||
|
Connectez-vous pour indiquer votre présence: {settings.APP_URL}/events/{event.id}
|
||||||
|
|
||||||
|
À bientôt !
|
||||||
|
L'équipe LeDiscord
|
||||||
|
"""
|
||||||
|
|
||||||
|
html_body = f"""
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif;">
|
||||||
|
<h2>Nouvel événement sur LeDiscord</h2>
|
||||||
|
<h3>{event.title}</h3>
|
||||||
|
<p><strong>Date:</strong> {event.date.strftime('%d/%m/%Y à %H:%M')}</p>
|
||||||
|
<p><strong>Lieu:</strong> {event.location or 'Non spécifié'}</p>
|
||||||
|
{f'<p>{event.description}</p>' if event.description else ''}
|
||||||
|
<a href="{settings.APP_URL}/events/{event.id}"
|
||||||
|
style="display: inline-block; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px;">
|
||||||
|
Indiquer ma présence
|
||||||
|
</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
send_email(to_email, subject, body, html_body)
|
||||||
32
backend/utils/init_db.py
Normal file
32
backend/utils/init_db.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from config.database import SessionLocal
|
||||||
|
from config.settings import settings
|
||||||
|
from models.user import User
|
||||||
|
from utils.security import get_password_hash
|
||||||
|
|
||||||
|
def init_database():
|
||||||
|
"""Initialize database with default admin user."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Check if admin user exists
|
||||||
|
admin = db.query(User).filter(User.email == settings.ADMIN_EMAIL).first()
|
||||||
|
if not admin:
|
||||||
|
# Create admin user
|
||||||
|
admin = User(
|
||||||
|
email=settings.ADMIN_EMAIL,
|
||||||
|
username="admin",
|
||||||
|
full_name="Administrator",
|
||||||
|
hashed_password=get_password_hash(settings.ADMIN_PASSWORD),
|
||||||
|
is_active=True,
|
||||||
|
is_admin=True
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.commit()
|
||||||
|
print(f"Admin user created: {settings.ADMIN_EMAIL}")
|
||||||
|
else:
|
||||||
|
print(f"Admin user already exists: {settings.ADMIN_EMAIL}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error initializing database: {e}")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
136
backend/utils/notification_service.py
Normal file
136
backend/utils/notification_service.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from models.notification import Notification, NotificationType
|
||||||
|
from models.user import User
|
||||||
|
from models.post import Post
|
||||||
|
from models.vlog import Vlog
|
||||||
|
from models.album import Album
|
||||||
|
from models.event import Event
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class NotificationService:
|
||||||
|
"""Service for managing notifications."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_mention_notification(
|
||||||
|
db: Session,
|
||||||
|
mentioned_user_id: int,
|
||||||
|
author: User,
|
||||||
|
content_type: str,
|
||||||
|
content_id: int,
|
||||||
|
content_preview: str = None
|
||||||
|
):
|
||||||
|
"""Create a notification for a user mention."""
|
||||||
|
if mentioned_user_id == author.id:
|
||||||
|
return # Don't notify self
|
||||||
|
|
||||||
|
notification = Notification(
|
||||||
|
user_id=mentioned_user_id,
|
||||||
|
type=NotificationType.POST_MENTION,
|
||||||
|
title="Vous avez été mentionné",
|
||||||
|
message=f"{author.full_name} vous a mentionné dans un(e) {content_type}",
|
||||||
|
link=f"/{content_type}s/{content_id}",
|
||||||
|
is_read=False,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(notification)
|
||||||
|
db.commit()
|
||||||
|
return notification
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_event_notification(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
event: Event,
|
||||||
|
author: User
|
||||||
|
):
|
||||||
|
"""Create a notification for a new event."""
|
||||||
|
if user_id == author.id:
|
||||||
|
return # Don't notify creator
|
||||||
|
|
||||||
|
notification = Notification(
|
||||||
|
user_id=user_id,
|
||||||
|
type=NotificationType.EVENT_INVITATION,
|
||||||
|
title="Nouvel événement",
|
||||||
|
message=f"{author.full_name} a créé un nouvel événement : {event.title}",
|
||||||
|
link=f"/events/{event.id}",
|
||||||
|
is_read=False,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(notification)
|
||||||
|
db.commit()
|
||||||
|
return notification
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_album_notification(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
album: Album,
|
||||||
|
author: User
|
||||||
|
):
|
||||||
|
"""Create a notification for a new album."""
|
||||||
|
if user_id == author.id:
|
||||||
|
return # Don't notify creator
|
||||||
|
|
||||||
|
notification = Notification(
|
||||||
|
user_id=user_id,
|
||||||
|
type=NotificationType.NEW_ALBUM,
|
||||||
|
title="Nouvel album",
|
||||||
|
message=f"{author.full_name} a créé un nouvel album : {album.title}",
|
||||||
|
link=f"/albums/{album.id}",
|
||||||
|
is_read=False,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(notification)
|
||||||
|
db.commit()
|
||||||
|
return notification
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_vlog_notification(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
vlog: Vlog,
|
||||||
|
author: User
|
||||||
|
):
|
||||||
|
"""Create a notification for a new vlog."""
|
||||||
|
if user_id == author.id:
|
||||||
|
return # Don't notify creator
|
||||||
|
|
||||||
|
notification = Notification(
|
||||||
|
user_id=user_id,
|
||||||
|
type=NotificationType.NEW_VLOG,
|
||||||
|
title="Nouveau vlog",
|
||||||
|
message=f"{author.full_name} a publié un nouveau vlog : {vlog.title}",
|
||||||
|
link=f"/vlogs/{vlog.id}",
|
||||||
|
is_read=False,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(notification)
|
||||||
|
db.commit()
|
||||||
|
return notification
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_system_notification(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
link: str = None
|
||||||
|
):
|
||||||
|
"""Create a system notification."""
|
||||||
|
notification = Notification(
|
||||||
|
user_id=user_id,
|
||||||
|
type=NotificationType.SYSTEM,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
link=link,
|
||||||
|
is_read=False,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(notification)
|
||||||
|
db.commit()
|
||||||
|
return notification
|
||||||
74
backend/utils/security.py
Normal file
74
backend/utils/security.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from config.settings import settings
|
||||||
|
from config.database import get_db
|
||||||
|
from models.user import User
|
||||||
|
from schemas.user import TokenData
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify a plain password against a hashed password."""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""Generate a password hash."""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""Create a JWT access token."""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRATION_MINUTES)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
def verify_token(token: str, credentials_exception) -> TokenData:
|
||||||
|
"""Verify a JWT token and return the token data."""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||||
|
user_id: int = payload.get("sub")
|
||||||
|
email: str = payload.get("email")
|
||||||
|
if user_id is None:
|
||||||
|
raise credentials_exception
|
||||||
|
token_data = TokenData(user_id=user_id, email=email)
|
||||||
|
return token_data
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
|
||||||
|
"""Get the current authenticated user."""
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
token_data = verify_token(token, credentials_exception)
|
||||||
|
user = db.query(User).filter(User.id == token_data.user_id).first()
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Get the current active user."""
|
||||||
|
if not current_user.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="Inactive user")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
async def get_admin_user(current_user: User = Depends(get_current_active_user)) -> User:
|
||||||
|
"""Get the current admin user."""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not enough permissions"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
92
backend/utils/settings_service.py
Normal file
92
backend/utils/settings_service.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from models.settings import SystemSettings
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import json
|
||||||
|
|
||||||
|
class SettingsService:
|
||||||
|
"""Service for managing system settings."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_setting(db: Session, key: str, default: Any = None) -> Any:
|
||||||
|
"""Get a setting value by key."""
|
||||||
|
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
|
||||||
|
if not setting:
|
||||||
|
return default
|
||||||
|
|
||||||
|
# Essayer de convertir en type approprié
|
||||||
|
value = setting.value
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
return json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Liste séparée par des virgules
|
||||||
|
if ',' in value and not value.startswith('{') and not value.startswith('['):
|
||||||
|
return [item.strip() for item in value.split(',')]
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_upload_limits(db: Session) -> Dict[str, Any]:
|
||||||
|
"""Get all upload-related settings."""
|
||||||
|
return {
|
||||||
|
"max_album_size_mb": SettingsService.get_setting(db, "max_album_size_mb", 100),
|
||||||
|
"max_vlog_size_mb": SettingsService.get_setting(db, "max_vlog_size_mb", 500),
|
||||||
|
"max_image_size_mb": SettingsService.get_setting(db, "max_image_size_mb", 10),
|
||||||
|
"max_video_size_mb": SettingsService.get_setting(db, "max_video_size_mb", 100),
|
||||||
|
"allowed_image_types": SettingsService.get_setting(db, "allowed_image_types",
|
||||||
|
["image/jpeg", "image/png", "image/gif", "image/webp"]),
|
||||||
|
"allowed_video_types": SettingsService.get_setting(db, "allowed_video_types",
|
||||||
|
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_max_upload_size(db: Session, content_type: str) -> int:
|
||||||
|
"""Get max upload size for a specific content type."""
|
||||||
|
if content_type.startswith('image/'):
|
||||||
|
max_size_mb = SettingsService.get_setting(db, "max_image_size_mb", 10)
|
||||||
|
max_size_bytes = max_size_mb * 1024 * 1024
|
||||||
|
print(f"DEBUG - Image upload limit: {max_size_mb}MB = {max_size_bytes} bytes")
|
||||||
|
return max_size_bytes
|
||||||
|
elif content_type.startswith('video/'):
|
||||||
|
max_size_mb = SettingsService.get_setting(db, "max_video_size_mb", 100)
|
||||||
|
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
|
||||||
|
else:
|
||||||
|
default_size = 10 * 1024 * 1024 # 10MB par défaut
|
||||||
|
print(f"DEBUG - Default upload limit: 10MB = {default_size} bytes")
|
||||||
|
return default_size
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_file_type_allowed(db: Session, content_type: str) -> bool:
|
||||||
|
"""Check if a file type is allowed."""
|
||||||
|
if content_type.startswith('image/'):
|
||||||
|
allowed_types = SettingsService.get_setting(db, "allowed_image_types",
|
||||||
|
["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||||
|
elif content_type.startswith('video/'):
|
||||||
|
allowed_types = SettingsService.get_setting(db, "allowed_video_types",
|
||||||
|
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return content_type in allowed_types
|
||||||
94
backend/utils/video_utils.py
Normal file
94
backend/utils/video_utils.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import cv2
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
def generate_video_thumbnail(video_path: str, output_path: str, frame_time: float = 1.0) -> bool:
|
||||||
|
"""
|
||||||
|
Generate a thumbnail from a video at a specific time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_path: Path to the video file
|
||||||
|
output_path: Path where to save the thumbnail
|
||||||
|
frame_time: Time in seconds to extract the frame (default: 1 second)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Open video file
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
|
||||||
|
if not cap.isOpened():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get video properties
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||||
|
|
||||||
|
if total_frames == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Calculate frame number for the specified time
|
||||||
|
frame_number = min(int(fps * frame_time), total_frames - 1)
|
||||||
|
|
||||||
|
# Set position to the specified frame
|
||||||
|
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
|
||||||
|
|
||||||
|
# Read the frame
|
||||||
|
ret, frame = cap.read()
|
||||||
|
|
||||||
|
if not ret:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Convert BGR to RGB
|
||||||
|
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||||
|
|
||||||
|
# Convert to PIL Image
|
||||||
|
pil_image = Image.fromarray(frame_rgb)
|
||||||
|
|
||||||
|
# Resize to thumbnail size (400x400)
|
||||||
|
pil_image.thumbnail((400, 400), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Save thumbnail
|
||||||
|
pil_image.save(output_path, "JPEG", quality=85, optimize=True)
|
||||||
|
|
||||||
|
# Release video capture
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error generating thumbnail: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_video_duration(video_path: str) -> float:
|
||||||
|
"""
|
||||||
|
Get the duration of a video file in seconds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_path: Path to the video file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float: Duration in seconds, or 0 if error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
|
||||||
|
if not cap.isOpened():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||||
|
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
if fps > 0 and total_frames > 0:
|
||||||
|
return total_frames / fps
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting video duration: {e}")
|
||||||
|
return 0
|
||||||
70
docker-compose.yml
Normal file
70
docker-compose.yml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: lediscord_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: lediscord
|
||||||
|
POSTGRES_USER: lediscord_user
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-lediscord_password}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:5432:5432"
|
||||||
|
networks:
|
||||||
|
- lediscord_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U lediscord_user -d lediscord"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: lediscord_backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://lediscord_user:${DB_PASSWORD:-lediscord_password}@postgres:5432/lediscord
|
||||||
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-your-super-secret-jwt-key-change-me}
|
||||||
|
JWT_ALGORITHM: HS256
|
||||||
|
JWT_EXPIRATION_MINUTES: 10080
|
||||||
|
UPLOAD_PATH: /app/uploads
|
||||||
|
CORS_ORIGINS: http://localhost:5173,http://localhost:3000
|
||||||
|
SMTP_HOST: ${SMTP_HOST:-smtp.gmail.com}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- ${UPLOAD_PATH:-./uploads}:/app/uploads
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- lediscord_network
|
||||||
|
command: uvicorn app:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: lediscord_frontend
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
networks:
|
||||||
|
- lediscord_network
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=http://localhost:8000
|
||||||
|
command: npm run dev -- --host
|
||||||
|
|
||||||
|
networks:
|
||||||
|
lediscord_network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
39
env.example
Normal file
39
env.example
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# ===========================================
|
||||||
|
# LeDiscord - Configuration d'environnement
|
||||||
|
# ===========================================
|
||||||
|
# Copiez ce fichier vers .env et modifiez les valeurs
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# BASE DE DONNÉES
|
||||||
|
# ===========================================
|
||||||
|
DB_PASSWORD=lediscord_password_change_me
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# JWT (SÉCURITÉ)
|
||||||
|
# ===========================================
|
||||||
|
JWT_SECRET_KEY=your-super-secret-jwt-key-change-me-to-something-very-long-and-random
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# EMAIL (OPTIONNEL)
|
||||||
|
# ===========================================
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASSWORD=your-app-password
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# UPLOAD ET STOCKAGE
|
||||||
|
# ===========================================
|
||||||
|
UPLOAD_PATH=./uploads
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# ADMIN PAR DÉFAUT
|
||||||
|
# ===========================================
|
||||||
|
ADMIN_EMAIL=admin@lediscord.com
|
||||||
|
ADMIN_PASSWORD=admin123_change_me
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# CORS (DÉVELOPPEMENT)
|
||||||
|
# ===========================================
|
||||||
|
# Ces valeurs sont utilisées par défaut dans le code
|
||||||
|
# CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LeDiscord - Notre espace</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "lediscord-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.3.8",
|
||||||
|
"vue-router": "^4.2.5",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"@vueuse/core": "^10.6.1",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"lucide-vue-next": "^0.294.0",
|
||||||
|
"vue-toastification": "^2.0.0-rc.5",
|
||||||
|
"video.js": "^8.6.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^4.5.0",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.31",
|
||||||
|
"tailwindcss": "^3.3.5",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
20
frontend/src/App.vue
Normal file
20
frontend/src/App.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<component :is="layout">
|
||||||
|
<router-view />
|
||||||
|
</component>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||||||
|
import AuthLayout from '@/layouts/AuthLayout.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const layout = computed(() => {
|
||||||
|
return route.meta.layout === 'auth' ? AuthLayout : DefaultLayout
|
||||||
|
})
|
||||||
|
</script>
|
||||||
230
frontend/src/components/MentionInput.vue
Normal file
230
frontend/src/components/MentionInput.vue
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mention-input-container">
|
||||||
|
<textarea
|
||||||
|
ref="textareaRef"
|
||||||
|
v-model="inputValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:rows="rows"
|
||||||
|
:class="inputClass"
|
||||||
|
@input="handleInput"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@focus="showSuggestions = true"
|
||||||
|
@blur="handleBlur"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Suggestions de mentions -->
|
||||||
|
<div
|
||||||
|
v-if="showSuggestions && filteredUsers.length > 0"
|
||||||
|
class="mention-suggestions"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(user, index) in filteredUsers"
|
||||||
|
:key="user.id"
|
||||||
|
:class="[
|
||||||
|
'mention-suggestion-item',
|
||||||
|
{ 'selected': index === selectedIndex }
|
||||||
|
]"
|
||||||
|
@click="selectUser(user)"
|
||||||
|
@mouseenter="selectedIndex = index"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="user.avatar_url"
|
||||||
|
:src="getMediaUrl(user.avatar_url)"
|
||||||
|
:alt="user.full_name"
|
||||||
|
class="w-6 h-6 rounded-full mr-2"
|
||||||
|
>
|
||||||
|
<div v-else class="w-6 h-6 rounded-full bg-primary-100 flex items-center justify-center mr-2">
|
||||||
|
<User class="w-3 h-3 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-sm">{{ user.full_name }}</div>
|
||||||
|
<div class="text-xs text-gray-500">@{{ user.username }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
|
import { User } from 'lucide-vue-next'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: 'Écrivez votre message...'
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: Number,
|
||||||
|
default: 3
|
||||||
|
},
|
||||||
|
inputClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'input resize-none'
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'mentions-changed'])
|
||||||
|
|
||||||
|
const textareaRef = ref(null)
|
||||||
|
const inputValue = ref(props.modelValue)
|
||||||
|
const showSuggestions = ref(false)
|
||||||
|
const selectedIndex = ref(0)
|
||||||
|
const currentMentionStart = ref(-1)
|
||||||
|
const currentMentionQuery = ref('')
|
||||||
|
|
||||||
|
// Synchroniser avec la valeur externe
|
||||||
|
watch(() => props.modelValue, (newValue) => {
|
||||||
|
inputValue.value = newValue
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(inputValue, (newValue) => {
|
||||||
|
emit('update:modelValue', newValue)
|
||||||
|
updateMentions()
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredUsers = computed(() => {
|
||||||
|
if (!currentMentionQuery.value) return []
|
||||||
|
|
||||||
|
return props.users.filter(user =>
|
||||||
|
user.username.toLowerCase().includes(currentMentionQuery.value.toLowerCase()) ||
|
||||||
|
user.full_name.toLowerCase().includes(currentMentionQuery.value.toLowerCase())
|
||||||
|
).slice(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
const text = inputValue.value
|
||||||
|
const cursorPos = textareaRef.value.selectionStart
|
||||||
|
|
||||||
|
// Chercher la mention en cours
|
||||||
|
const beforeCursor = text.substring(0, cursorPos)
|
||||||
|
const mentionMatch = beforeCursor.match(/@(\w*)$/)
|
||||||
|
|
||||||
|
if (mentionMatch) {
|
||||||
|
currentMentionStart.value = mentionMatch.index
|
||||||
|
currentMentionQuery.value = mentionMatch[1]
|
||||||
|
showSuggestions.value = true
|
||||||
|
selectedIndex.value = 0
|
||||||
|
} else {
|
||||||
|
showSuggestions.value = false
|
||||||
|
currentMentionStart.value = -1
|
||||||
|
currentMentionQuery.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event) {
|
||||||
|
if (!showSuggestions.value) return
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredUsers.value.length - 1)
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
|
||||||
|
} else if (event.key === 'Enter' && filteredUsers.value.length > 0) {
|
||||||
|
event.preventDefault()
|
||||||
|
selectUser(filteredUsers.value[selectedIndex.value])
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
showSuggestions.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUser(user) {
|
||||||
|
if (currentMentionStart.value === -1) return
|
||||||
|
|
||||||
|
const beforeMention = inputValue.value.substring(0, currentMentionStart.value)
|
||||||
|
const afterMention = inputValue.value.substring(currentMentionStart.value + currentMentionQuery.value.length + 1)
|
||||||
|
|
||||||
|
inputValue.value = beforeMention + '@' + user.username + ' ' + afterMention
|
||||||
|
|
||||||
|
// Positionner le curseur après la mention
|
||||||
|
nextTick(() => {
|
||||||
|
const newCursorPos = currentMentionStart.value + user.username.length + 2
|
||||||
|
textareaRef.value.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
textareaRef.value.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
showSuggestions.value = false
|
||||||
|
currentMentionStart.value = -1
|
||||||
|
currentMentionQuery.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
// Délai pour permettre le clic sur les suggestions
|
||||||
|
setTimeout(() => {
|
||||||
|
showSuggestions.value = false
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMentions() {
|
||||||
|
const mentions = []
|
||||||
|
const mentionRegex = /@(\w+)/g
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = mentionRegex.exec(inputValue.value)) !== null) {
|
||||||
|
const username = match[1]
|
||||||
|
const user = props.users.find(u => u.username === username)
|
||||||
|
if (user) {
|
||||||
|
mentions.push({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
full_name: user.full_name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('mentions-changed', mentions)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mention-input-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-suggestions {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 50;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-suggestion-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-suggestion-item:hover,
|
||||||
|
.mention-suggestion-item.selected {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-suggestion-item:first-child {
|
||||||
|
border-top-left-radius: 0.5rem;
|
||||||
|
border-top-right-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-suggestion-item:last-child {
|
||||||
|
border-bottom-left-radius: 0.5rem;
|
||||||
|
border-bottom-right-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
79
frontend/src/components/Mentions.vue
Normal file
79
frontend/src/components/Mentions.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<span class="mentions-container">
|
||||||
|
<template v-for="(part, index) in parsedContent" :key="index">
|
||||||
|
<router-link
|
||||||
|
v-if="part.type === 'mention'"
|
||||||
|
:to="`/profile/${part.userId}`"
|
||||||
|
class="mention-link"
|
||||||
|
>
|
||||||
|
@{{ part.username }}
|
||||||
|
</router-link>
|
||||||
|
<span v-else>{{ part.text }}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
mentions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const parsedContent = computed(() => {
|
||||||
|
if (!props.content) return []
|
||||||
|
|
||||||
|
const parts = []
|
||||||
|
let currentIndex = 0
|
||||||
|
|
||||||
|
// Parcourir le contenu pour trouver les mentions
|
||||||
|
props.content.split(/(@\w+)/).forEach((part, index) => {
|
||||||
|
if (part.startsWith('@')) {
|
||||||
|
const username = part.substring(1)
|
||||||
|
const mention = props.mentions.find(m => m.username === username)
|
||||||
|
|
||||||
|
if (mention) {
|
||||||
|
parts.push({
|
||||||
|
type: 'mention',
|
||||||
|
username: mention.username,
|
||||||
|
userId: mention.id,
|
||||||
|
text: part
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Mention non trouvée, traiter comme du texte normal
|
||||||
|
parts.push({ type: 'text', text: part })
|
||||||
|
}
|
||||||
|
} else if (part.trim()) {
|
||||||
|
parts.push({ type: 'text', text: part })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return parts
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mentions-container {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-link {
|
||||||
|
@apply font-medium transition-colors cursor-pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #8b5cf6;
|
||||||
|
filter: drop-shadow(0 0 2px rgba(139, 92, 246, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-link:hover {
|
||||||
|
color: #7c3aed;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(139, 92, 246, 0.5));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
226
frontend/src/components/TicketFloatingButton.vue
Normal file
226
frontend/src/components/TicketFloatingButton.vue
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Floating Button -->
|
||||||
|
<div class="fixed bottom-6 right-6 z-40">
|
||||||
|
<button
|
||||||
|
@click="showTicketModal = true"
|
||||||
|
class="bg-primary-600 hover:bg-primary-700 text-white rounded-full p-4 shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-110"
|
||||||
|
title="Signaler un problème ou une amélioration"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ticket Modal -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showTicketModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold">Nouveau ticket</h2>
|
||||||
|
<button
|
||||||
|
@click="closeModal"
|
||||||
|
class="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitTicket" class="space-y-6">
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<label class="label">Titre *</label>
|
||||||
|
<input
|
||||||
|
v-model="ticketForm.title"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
required
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="Titre de votre ticket"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type -->
|
||||||
|
<div>
|
||||||
|
<label class="label">Type</label>
|
||||||
|
<select v-model="ticketForm.ticket_type" class="input">
|
||||||
|
<option value="bug">🐛 Bug</option>
|
||||||
|
<option value="feature_request">💡 Demande de fonctionnalité</option>
|
||||||
|
<option value="improvement">✨ Amélioration</option>
|
||||||
|
<option value="support">❓ Support</option>
|
||||||
|
<option value="other">📝 Autre</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Priority -->
|
||||||
|
<div>
|
||||||
|
<label class="label">Priorité</label>
|
||||||
|
<select v-model="ticketForm.priority" class="input">
|
||||||
|
<option value="low">🟢 Faible</option>
|
||||||
|
<option value="medium">🟡 Moyenne</option>
|
||||||
|
<option value="high">🟠 Élevée</option>
|
||||||
|
<option value="urgent">🔴 Urgente</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<label class="label">Description *</label>
|
||||||
|
<textarea
|
||||||
|
v-model="ticketForm.description"
|
||||||
|
class="input resize-none"
|
||||||
|
rows="6"
|
||||||
|
required
|
||||||
|
placeholder="Décrivez votre problème ou votre demande en détail..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshot -->
|
||||||
|
<div>
|
||||||
|
<label class="label">Screenshot (optionnel)</label>
|
||||||
|
<input
|
||||||
|
ref="screenshotInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="input"
|
||||||
|
@change="handleScreenshotChange"
|
||||||
|
>
|
||||||
|
<div class="mt-1 text-sm text-gray-600">
|
||||||
|
Formats acceptés : JPG, PNG, GIF, WebP (max 5MB)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeModal"
|
||||||
|
class="flex-1 btn-secondary"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex-1 btn-primary"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
<svg v-if="submitting" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ submitting ? 'Envoi...' : 'Envoyer le ticket' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const showTicketModal = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const screenshotInput = ref(null)
|
||||||
|
|
||||||
|
const ticketForm = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
ticket_type: 'other',
|
||||||
|
priority: 'medium'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
function closeModal() {
|
||||||
|
showTicketModal.value = false
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
ticketForm.value = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
ticket_type: 'other',
|
||||||
|
priority: 'medium'
|
||||||
|
}
|
||||||
|
if (screenshotInput.value) {
|
||||||
|
screenshotInput.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScreenshotChange(event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (file && file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error('Le fichier est trop volumineux (max 5MB)')
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTicket() {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('title', ticketForm.value.title)
|
||||||
|
formData.append('description', ticketForm.value.description)
|
||||||
|
formData.append('ticket_type', ticketForm.value.ticket_type)
|
||||||
|
formData.append('priority', ticketForm.value.priority)
|
||||||
|
|
||||||
|
if (screenshotInput.value && screenshotInput.value.files[0]) {
|
||||||
|
formData.append('screenshot', screenshotInput.value.files[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: afficher les données envoyées
|
||||||
|
console.log('DEBUG - Ticket form data:')
|
||||||
|
console.log(' title:', ticketForm.value.title)
|
||||||
|
console.log(' description:', ticketForm.value.description)
|
||||||
|
console.log(' ticket_type:', ticketForm.value.ticket_type)
|
||||||
|
console.log(' priority:', ticketForm.value.priority)
|
||||||
|
console.log(' screenshot:', screenshotInput.value?.files[0])
|
||||||
|
|
||||||
|
// Debug: afficher le FormData
|
||||||
|
for (let [key, value] of formData.entries()) {
|
||||||
|
console.log(`DEBUG - FormData entry: ${key} = ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.post('/api/tickets/', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
toast.success('Ticket envoyé avec succès !')
|
||||||
|
closeModal()
|
||||||
|
|
||||||
|
// Redirect to MyTickets page
|
||||||
|
router.push('/my-tickets')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting ticket:', error)
|
||||||
|
if (error.response) {
|
||||||
|
console.error('Error response:', error.response.data)
|
||||||
|
console.error('Error status:', error.response.status)
|
||||||
|
}
|
||||||
|
toast.error('Erreur lors de l\'envoi du ticket')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
78
frontend/src/components/UserAvatar.vue
Normal file
78
frontend/src/components/UserAvatar.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<img
|
||||||
|
v-if="avatarUrl"
|
||||||
|
:src="avatarUrl"
|
||||||
|
:alt="alt"
|
||||||
|
:class="avatarClasses"
|
||||||
|
>
|
||||||
|
<div v-else :class="fallbackClasses">
|
||||||
|
<User class="w-4 h-4 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div v-if="showUserInfo">
|
||||||
|
<p class="font-medium text-gray-900">{{ userName }}</p>
|
||||||
|
<p class="text-sm text-gray-600">@{{ username }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { User } from 'lucide-vue-next'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md', // 'sm', 'md', 'lg'
|
||||||
|
validator: (value) => ['sm', 'md', 'lg'].includes(value)
|
||||||
|
},
|
||||||
|
showUserInfo: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const avatarUrl = computed(() => {
|
||||||
|
if (props.user?.avatar_url) {
|
||||||
|
return getMediaUrl(props.user.avatar_url)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const alt = computed(() => {
|
||||||
|
return props.user?.full_name || 'Avatar utilisateur'
|
||||||
|
})
|
||||||
|
|
||||||
|
const userName = computed(() => {
|
||||||
|
return props.user?.full_name || 'Utilisateur'
|
||||||
|
})
|
||||||
|
|
||||||
|
const username = computed(() => {
|
||||||
|
return props.user?.username || 'user'
|
||||||
|
})
|
||||||
|
|
||||||
|
const avatarClasses = computed(() => {
|
||||||
|
const baseClasses = 'rounded-full object-cover'
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-6 h-6',
|
||||||
|
md: 'w-8 h-8',
|
||||||
|
lg: 'w-10 h-10'
|
||||||
|
}
|
||||||
|
return `${sizeClasses[props.size]} ${baseClasses}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const fallbackClasses = computed(() => {
|
||||||
|
const baseClasses = 'rounded-full bg-primary-100 flex items-center justify-center'
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-6 h-6',
|
||||||
|
md: 'w-8 h-8',
|
||||||
|
lg: 'w-10 h-10'
|
||||||
|
}
|
||||||
|
return `${sizeClasses[props.size]} ${baseClasses}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
178
frontend/src/components/VideoPlayer.vue
Normal file
178
frontend/src/components/VideoPlayer.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<div class="video-player-container">
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Video.js Player -->
|
||||||
|
<video
|
||||||
|
ref="videoPlayer"
|
||||||
|
class="video-js vjs-default-skin vjs-big-play-centered w-full rounded-lg"
|
||||||
|
controls
|
||||||
|
preload="auto"
|
||||||
|
:poster="posterUrl"
|
||||||
|
data-setup="{}"
|
||||||
|
>
|
||||||
|
<source :src="videoUrl" type="video/mp4" />
|
||||||
|
<p class="vjs-no-js">
|
||||||
|
Pour voir cette vidéo, activez JavaScript et considérez passer à un navigateur web qui
|
||||||
|
<a href="https://videojs.com/html5-video-support/" target="_blank">supporte la vidéo HTML5</a>.
|
||||||
|
</p>
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video Stats -->
|
||||||
|
<div class="mt-4 flex items-center justify-between text-sm text-gray-600">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<Eye class="w-4 h-4 mr-1" />
|
||||||
|
{{ viewsCount }} vue{{ viewsCount > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<Clock class="w-4 h-4 mr-1" />
|
||||||
|
{{ formatDuration(duration) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
@click="toggleLike"
|
||||||
|
class="flex items-center space-x-1 px-3 py-1 rounded-full transition-colors"
|
||||||
|
:class="isLiked ? 'bg-red-100 text-red-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||||
|
>
|
||||||
|
<Heart :class="isLiked ? 'fill-current' : ''" class="w-4 h-4" />
|
||||||
|
<span>{{ likesCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="toggleComments"
|
||||||
|
class="flex items-center space-x-1 px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<MessageSquare class="w-4 h-4" />
|
||||||
|
<span>{{ commentsCount }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||||
|
import videojs from 'video.js'
|
||||||
|
import 'video.js/dist/video-js.css'
|
||||||
|
import { Eye, Clock, Heart, MessageSquare } from 'lucide-vue-next'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
poster: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
viewsCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
likesCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
commentsCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
isLiked: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['like', 'toggle-comments'])
|
||||||
|
|
||||||
|
const videoPlayer = ref(null)
|
||||||
|
const player = ref(null)
|
||||||
|
|
||||||
|
// Computed properties pour les URLs
|
||||||
|
const videoUrl = computed(() => getMediaUrl(props.src))
|
||||||
|
const posterUrl = computed(() => getMediaUrl(props.poster))
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (!seconds) return '--:--'
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const remainingSeconds = seconds % 60
|
||||||
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLike() {
|
||||||
|
emit('like')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleComments() {
|
||||||
|
emit('toggle-comments')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (videoPlayer.value) {
|
||||||
|
player.value = videojs(videoPlayer.value, {
|
||||||
|
controls: true,
|
||||||
|
fluid: true,
|
||||||
|
responsive: true,
|
||||||
|
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
|
||||||
|
controlBar: {
|
||||||
|
children: [
|
||||||
|
'playToggle',
|
||||||
|
'volumePanel',
|
||||||
|
'currentTimeDisplay',
|
||||||
|
'timeDivider',
|
||||||
|
'durationDisplay',
|
||||||
|
'progressControl',
|
||||||
|
'playbackRateMenuButton',
|
||||||
|
'fullscreenToggle'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
player.value.on('error', (error) => {
|
||||||
|
console.error('Video.js error:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (player.value) {
|
||||||
|
player.value.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for src changes to reload video
|
||||||
|
watch(() => props.src, () => {
|
||||||
|
if (player.value && videoUrl.value) {
|
||||||
|
player.value.src({ src: videoUrl.value, type: 'video/mp4' })
|
||||||
|
player.value.load()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-js {
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-js .vjs-big-play-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
203
frontend/src/components/VlogComments.vue
Normal file
203
frontend/src/components/VlogComments.vue
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<div class="vlog-comments">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
Commentaires ({{ comments.length }})
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
@click="showCommentForm = !showCommentForm"
|
||||||
|
class="btn-primary text-sm"
|
||||||
|
>
|
||||||
|
<MessageSquare class="w-4 h-4 mr-2" />
|
||||||
|
{{ showCommentForm ? 'Annuler' : 'Commenter' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment Form -->
|
||||||
|
<div v-if="showCommentForm" class="mb-6">
|
||||||
|
<form @submit.prevent="submitComment" class="space-y-3">
|
||||||
|
<MentionInput
|
||||||
|
v-model="newComment"
|
||||||
|
:users="commentUsers"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="Écrivez votre commentaire... (utilisez @username pour mentionner)"
|
||||||
|
@mentions-changed="handleCommentMentionsChanged"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
{{ newComment.length }}/1000 caractères
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="!newComment.trim() || submitting"
|
||||||
|
class="btn-primary text-sm"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Envoi...' : 'Envoyer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments List -->
|
||||||
|
<div v-if="comments.length === 0" class="text-center py-8 text-gray-500">
|
||||||
|
<MessageSquare class="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||||
|
<h4 class="text-lg font-medium mb-2">Aucun commentaire</h4>
|
||||||
|
<p>Soyez le premier à commenter ce vlog !</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="comment in comments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="flex space-x-3 p-4 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<!-- User Avatar -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<img
|
||||||
|
v-if="comment.avatar_url"
|
||||||
|
:src="getAvatarUrl(comment.avatar_url)"
|
||||||
|
:alt="comment.full_name"
|
||||||
|
class="w-10 h-10 rounded-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<User class="w-5 h-5 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment Content -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center space-x-2 mb-1">
|
||||||
|
<span class="font-medium text-gray-900">{{ comment.full_name }}</span>
|
||||||
|
<span class="text-sm text-gray-500">@{{ comment.username }}</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ formatDate(comment.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Mentions :content="comment.content" :mentions="comment.mentioned_users || []" class="text-gray-700 whitespace-pre-wrap" />
|
||||||
|
|
||||||
|
<!-- Comment Actions -->
|
||||||
|
<div class="flex items-center space-x-4 mt-2">
|
||||||
|
<button
|
||||||
|
v-if="canDeleteComment(comment)"
|
||||||
|
@click="deleteComment(comment.id)"
|
||||||
|
class="text-xs text-red-600 hover:text-red-800 transition-colors"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { MessageSquare, User } from 'lucide-vue-next'
|
||||||
|
import Mentions from '@/components/Mentions.vue'
|
||||||
|
import MentionInput from '@/components/MentionInput.vue'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
vlogId: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
commentUsers: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['comment-added', 'comment-deleted'])
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const showCommentForm = ref(false)
|
||||||
|
const newComment = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const commentMentions = ref([])
|
||||||
|
|
||||||
|
const currentUser = computed(() => authStore.user)
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvatarUrl(avatarUrl) {
|
||||||
|
return getMediaUrl(avatarUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDeleteComment(comment) {
|
||||||
|
return currentUser.value && (
|
||||||
|
comment.user_id === currentUser.value.id ||
|
||||||
|
currentUser.value.is_admin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommentMentionsChanged(mentions) {
|
||||||
|
commentMentions.value = mentions
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitComment() {
|
||||||
|
if (!newComment.value.trim()) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/vlogs/${props.vlogId}/comment`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${authStore.token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content: newComment.value.trim() })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
emit('comment-added', result.comment)
|
||||||
|
newComment.value = ''
|
||||||
|
showCommentForm.value = false
|
||||||
|
toast.success('Commentaire ajouté')
|
||||||
|
} else {
|
||||||
|
throw new Error('Erreur lors de l\'ajout du commentaire')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de l\'ajout du commentaire')
|
||||||
|
console.error('Error adding comment:', error)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComment(commentId) {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir supprimer ce commentaire ?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/vlogs/${props.vlogId}/comment/${commentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${authStore.token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
emit('comment-deleted', commentId)
|
||||||
|
toast.success('Commentaire supprimé')
|
||||||
|
} else {
|
||||||
|
throw new Error('Erreur lors de la suppression')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la suppression du commentaire')
|
||||||
|
console.error('Error deleting comment:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
17
frontend/src/layouts/AuthLayout.vue
Normal file
17
frontend/src/layouts/AuthLayout.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gradient-discord flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold bg-gradient-primary bg-clip-text text-transparent mb-2">LeDiscord</h1>
|
||||||
|
<p class="text-secondary-600">Notre espace privé</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
</script>
|
||||||
284
frontend/src/layouts/DefaultLayout.vue
Normal file
284
frontend/src/layouts/DefaultLayout.vue
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="bg-white shadow-sm border-b border-gray-100">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Logo -->
|
||||||
|
<router-link to="/" class="flex items-center">
|
||||||
|
<span class="text-2xl font-bold bg-gradient-primary bg-clip-text text-transparent">LeDiscord</span>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<!-- Navigation Links -->
|
||||||
|
<div class="hidden sm:ml-8 sm:flex sm:space-x-6">
|
||||||
|
<router-link
|
||||||
|
v-for="item in navigation"
|
||||||
|
:key="item.name"
|
||||||
|
: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"
|
||||||
|
active-class="!text-primary-600 !border-primary-600"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" class="w-4 h-4 mr-2" />
|
||||||
|
{{ item.name }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<!-- Notifications -->
|
||||||
|
<button
|
||||||
|
@click="showNotifications = !showNotifications"
|
||||||
|
class="relative p-2 text-gray-600 hover:text-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Bell class="w-5 h-5" />
|
||||||
|
<span
|
||||||
|
v-if="unreadNotifications > 0"
|
||||||
|
class="absolute top-0 right-0 block h-2 w-2 rounded-full bg-red-500"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- User Menu -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
@click="showUserMenu = !showUserMenu"
|
||||||
|
class="flex items-center space-x-2 text-sm font-medium text-gray-600 hover:text-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="user?.avatar_url"
|
||||||
|
:src="getMediaUrl(user.avatar_url)"
|
||||||
|
:alt="user.full_name"
|
||||||
|
class="w-8 h-8 rounded-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<User class="w-4 h-4 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<span class="hidden sm:block">{{ user?.full_name }}</span>
|
||||||
|
<ChevronDown class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Dropdown -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-100"
|
||||||
|
enter-from-class="transform opacity-0 scale-95"
|
||||||
|
enter-to-class="transform opacity-100 scale-100"
|
||||||
|
leave-active-class="transition ease-in duration-75"
|
||||||
|
leave-from-class="transform opacity-100 scale-100"
|
||||||
|
leave-to-class="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showUserMenu"
|
||||||
|
class="absolute right-0 mt-2 w-48 rounded-lg bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
to="/profile"
|
||||||
|
@click="showUserMenu = false"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Mon profil
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/stats"
|
||||||
|
@click="showUserMenu = false"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Statistiques
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/information"
|
||||||
|
@click="showUserMenu = false"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Informations
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/my-tickets"
|
||||||
|
@click="showUserMenu = false"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Mes tickets
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
v-if="authStore.isAdmin"
|
||||||
|
to="/admin"
|
||||||
|
@click="showUserMenu = false"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Administration
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<hr class="my-1">
|
||||||
|
<button
|
||||||
|
@click="logout"
|
||||||
|
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile menu -->
|
||||||
|
<div class="sm:hidden">
|
||||||
|
<div class="pt-2 pb-3 space-y-1">
|
||||||
|
<router-link
|
||||||
|
v-for="item in navigation"
|
||||||
|
:key="item.name"
|
||||||
|
: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"
|
||||||
|
active-class="!text-primary-600 bg-primary-50"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" class="w-5 h-5 mr-3" />
|
||||||
|
{{ item.name }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 bg-gray-50">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Ticket Floating Button -->
|
||||||
|
<TicketFloatingButton />
|
||||||
|
|
||||||
|
<!-- Notifications Panel -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="transform translate-x-full"
|
||||||
|
enter-to-class="transform translate-x-0"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="transform translate-x-0"
|
||||||
|
leave-to-class="transform translate-x-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showNotifications"
|
||||||
|
class="fixed right-0 top-16 h-full w-80 bg-white shadow-xl z-50 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold">Notifications</h3>
|
||||||
|
<button
|
||||||
|
@click="showNotifications = false"
|
||||||
|
class="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="notifications.length === 0" class="text-center py-8 text-gray-500">
|
||||||
|
Aucune notification
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="text-sm text-gray-600">{{ notifications.length }} notification(s)</span>
|
||||||
|
<button
|
||||||
|
@click="markAllRead"
|
||||||
|
class="text-xs text-primary-600 hover:text-primary-800"
|
||||||
|
>
|
||||||
|
Tout marquer comme lu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="notification in notifications"
|
||||||
|
:key="notification.id"
|
||||||
|
@click="handleNotificationClick(notification)"
|
||||||
|
class="p-3 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
|
||||||
|
:class="{ 'bg-blue-50 border-l-4 border-blue-500': !notification.is_read }"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-medium text-sm text-gray-900">{{ notification.title }}</h4>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">{{ notification.message }}</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-2">{{ formatDate(notification.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="!notification.is_read" class="w-2 h-2 bg-blue-500 rounded-full ml-2 flex-shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
Calendar,
|
||||||
|
Image,
|
||||||
|
Film,
|
||||||
|
MessageSquare,
|
||||||
|
Bell,
|
||||||
|
User,
|
||||||
|
ChevronDown,
|
||||||
|
X
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import TicketFloatingButton from '@/components/TicketFloatingButton.vue'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Accueil', to: '/', icon: Home },
|
||||||
|
{ name: 'Événements', to: '/events', icon: Calendar },
|
||||||
|
{ name: 'Albums', to: '/albums', icon: Image },
|
||||||
|
{ name: 'Vlogs', to: '/vlogs', icon: Film },
|
||||||
|
{ name: 'Publications', to: '/posts', icon: MessageSquare }
|
||||||
|
]
|
||||||
|
|
||||||
|
const showUserMenu = ref(false)
|
||||||
|
const showNotifications = ref(false)
|
||||||
|
|
||||||
|
const user = computed(() => authStore.user)
|
||||||
|
const notifications = computed(() => authStore.notifications)
|
||||||
|
const unreadNotifications = computed(() => authStore.unreadCount)
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return format(new Date(date), 'dd MMM à HH:mm', { locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
showUserMenu.value = false
|
||||||
|
await authStore.logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNotifications() {
|
||||||
|
await authStore.fetchNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead() {
|
||||||
|
await authStore.markAllNotificationsRead()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNotificationClick(notification) {
|
||||||
|
if (!notification.is_read) {
|
||||||
|
await authStore.markNotificationRead(notification.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.link) {
|
||||||
|
router.push(notification.link)
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotifications.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await authStore.fetchCurrentUser()
|
||||||
|
await fetchNotifications()
|
||||||
|
await authStore.fetchUnreadCount()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
33
frontend/src/main.js
Normal file
33
frontend/src/main.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import Toast from 'vue-toastification'
|
||||||
|
import 'vue-toastification/dist/index.css'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
// Toast configuration
|
||||||
|
const toastOptions = {
|
||||||
|
position: 'top-right',
|
||||||
|
timeout: 3000,
|
||||||
|
closeOnClick: true,
|
||||||
|
pauseOnFocusLoss: true,
|
||||||
|
pauseOnHover: true,
|
||||||
|
draggable: true,
|
||||||
|
draggablePercent: 0.6,
|
||||||
|
showCloseButtonOnHover: false,
|
||||||
|
hideProgressBar: false,
|
||||||
|
closeButton: 'button',
|
||||||
|
icon: true,
|
||||||
|
rtl: false
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(router)
|
||||||
|
app.use(Toast, toastOptions)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
143
frontend/src/router/index.js
Normal file
143
frontend/src/router/index.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
// Views
|
||||||
|
import Home from '@/views/Home.vue'
|
||||||
|
import Login from '@/views/Login.vue'
|
||||||
|
import Register from '@/views/Register.vue'
|
||||||
|
import Events from '@/views/Events.vue'
|
||||||
|
import EventDetail from '@/views/EventDetail.vue'
|
||||||
|
import Albums from '@/views/Albums.vue'
|
||||||
|
import AlbumDetail from '@/views/AlbumDetail.vue'
|
||||||
|
import Vlogs from '@/views/Vlogs.vue'
|
||||||
|
import VlogDetail from '@/views/VlogDetail.vue'
|
||||||
|
import Posts from '@/views/Posts.vue'
|
||||||
|
import Profile from '@/views/Profile.vue'
|
||||||
|
import UserProfile from '@/views/UserProfile.vue'
|
||||||
|
import Stats from '@/views/Stats.vue'
|
||||||
|
import Admin from '@/views/Admin.vue'
|
||||||
|
import Information from '@/views/Information.vue'
|
||||||
|
import MyTickets from '@/views/MyTickets.vue'
|
||||||
|
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: Home,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: Login,
|
||||||
|
meta: { layout: 'auth' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
name: 'Register',
|
||||||
|
component: Register,
|
||||||
|
meta: { layout: 'auth' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/events',
|
||||||
|
name: 'Events',
|
||||||
|
component: Events,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/events/:id',
|
||||||
|
name: 'EventDetail',
|
||||||
|
component: EventDetail,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/albums',
|
||||||
|
name: 'Albums',
|
||||||
|
component: Albums,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/albums/:id',
|
||||||
|
name: 'AlbumDetail',
|
||||||
|
component: AlbumDetail,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/vlogs',
|
||||||
|
name: 'Vlogs',
|
||||||
|
component: Vlogs,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/vlogs/:id',
|
||||||
|
name: 'VlogDetail',
|
||||||
|
component: VlogDetail,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/posts',
|
||||||
|
name: 'Posts',
|
||||||
|
component: Posts,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/profile',
|
||||||
|
name: 'Profile',
|
||||||
|
component: Profile,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/profile/:id',
|
||||||
|
name: 'UserProfile',
|
||||||
|
component: UserProfile,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/stats',
|
||||||
|
name: 'Stats',
|
||||||
|
component: Stats,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
name: 'Admin',
|
||||||
|
component: Admin,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/information',
|
||||||
|
name: 'Information',
|
||||||
|
component: Information,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/my-tickets',
|
||||||
|
name: 'MyTickets',
|
||||||
|
component: MyTickets,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation guard
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
|
next('/login')
|
||||||
|
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||||
|
next('/')
|
||||||
|
} else if ((to.name === 'Login' || to.name === 'Register') && authStore.isAuthenticated) {
|
||||||
|
next('/')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
197
frontend/src/stores/auth.js
Normal file
197
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
import router from '@/router'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const user = ref(null)
|
||||||
|
const token = ref(localStorage.getItem('token'))
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
const isAdmin = computed(() => user.value?.is_admin || false)
|
||||||
|
|
||||||
|
if (token.value) {
|
||||||
|
axios.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(email, password) {
|
||||||
|
try {
|
||||||
|
// Pour OAuth2PasswordRequestForm, on doit envoyer en format x-www-form-urlencoded
|
||||||
|
const formData = new URLSearchParams()
|
||||||
|
formData.append('username', email) // OAuth2PasswordRequestForm expects username field
|
||||||
|
formData.append('password', password)
|
||||||
|
|
||||||
|
const response = await axios.post('/api/auth/login', formData.toString(), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const { access_token, user: userData } = response.data
|
||||||
|
|
||||||
|
token.value = access_token
|
||||||
|
user.value = userData
|
||||||
|
localStorage.setItem('token', access_token)
|
||||||
|
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`
|
||||||
|
|
||||||
|
toast.success(`Bienvenue ${userData.full_name} !`)
|
||||||
|
router.push('/')
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Erreur de connexion')
|
||||||
|
return { success: false, error: error.response?.data?.detail }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(userData) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/auth/register', userData)
|
||||||
|
const { access_token, user: newUser } = response.data
|
||||||
|
|
||||||
|
token.value = access_token
|
||||||
|
user.value = newUser
|
||||||
|
localStorage.setItem('token', access_token)
|
||||||
|
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`
|
||||||
|
|
||||||
|
toast.success('Inscription réussie !')
|
||||||
|
router.push('/')
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Erreur lors de l\'inscription')
|
||||||
|
return { success: false, error: error.response?.data?.detail }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
token.value = null
|
||||||
|
user.value = null
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
delete axios.defaults.headers.common['Authorization']
|
||||||
|
router.push('/login')
|
||||||
|
toast.info('Déconnexion réussie')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCurrentUser() {
|
||||||
|
if (!token.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/users/me')
|
||||||
|
user.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user:', error)
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfile(profileData) {
|
||||||
|
try {
|
||||||
|
const response = await axios.put('/api/users/me', profileData)
|
||||||
|
user.value = response.data
|
||||||
|
toast.success('Profil mis à jour')
|
||||||
|
return { success: true, data: response.data }
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour du profil')
|
||||||
|
return { success: false, error: error.response?.data?.detail }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAvatar(file) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
const response = await axios.post('/api/users/me/avatar', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
user.value = response.data
|
||||||
|
toast.success('Avatar mis à jour')
|
||||||
|
return { success: true, data: response.data }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading avatar:', error)
|
||||||
|
toast.error('Erreur lors de l\'upload de l\'avatar')
|
||||||
|
return { success: false, error: error.response?.data?.detail || 'Erreur inconnue' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
const notifications = ref([])
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
|
||||||
|
async function fetchNotifications() {
|
||||||
|
if (!token.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/notifications?limit=50')
|
||||||
|
notifications.value = response.data
|
||||||
|
unreadCount.value = notifications.value.filter(n => !n.is_read).length
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching notifications:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markNotificationRead(notificationId) {
|
||||||
|
try {
|
||||||
|
await axios.put(`/api/notifications/${notificationId}/read`)
|
||||||
|
const notification = notifications.value.find(n => n.id === notificationId)
|
||||||
|
if (notification && !notification.is_read) {
|
||||||
|
notification.is_read = true
|
||||||
|
notification.read_at = new Date().toISOString()
|
||||||
|
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking notification read:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllNotificationsRead() {
|
||||||
|
try {
|
||||||
|
await axios.put('/api/notifications/read-all')
|
||||||
|
notifications.value.forEach(n => {
|
||||||
|
n.is_read = true
|
||||||
|
n.read_at = new Date().toISOString()
|
||||||
|
})
|
||||||
|
unreadCount.value = 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking all notifications read:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUnreadCount() {
|
||||||
|
if (!token.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/notifications/unread-count')
|
||||||
|
unreadCount.value = response.data.unread_count
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching unread count:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated,
|
||||||
|
isAdmin,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
fetchCurrentUser,
|
||||||
|
updateProfile,
|
||||||
|
uploadAvatar,
|
||||||
|
// Notifications
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
fetchNotifications,
|
||||||
|
markNotificationRead,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
fetchUnreadCount
|
||||||
|
}
|
||||||
|
})
|
||||||
72
frontend/src/style.css
Normal file
72
frontend/src/style.css
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 text-gray-900 antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500 shadow-lg hover:shadow-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply btn bg-white text-secondary-700 border-secondary-200 hover:bg-secondary-50 focus:ring-primary-500 hover:border-primary-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-xl shadow-sm border border-secondary-100 overflow-hidden hover:shadow-md transition-shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply block w-full px-3 py-2 border border-secondary-300 rounded-lg shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
@apply block text-sm font-medium text-secondary-700 mb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Discord-style gradients */
|
||||||
|
.bg-gradient-discord {
|
||||||
|
@apply bg-gradient-to-br from-primary-50 via-white to-secondary-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient-primary {
|
||||||
|
@apply bg-gradient-to-r from-primary-500 to-primary-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient-secondary {
|
||||||
|
@apply bg-gradient-to-r from-secondary-500 to-secondary-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status colors */
|
||||||
|
.status-online {
|
||||||
|
@apply bg-success-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-offline {
|
||||||
|
@apply bg-secondary-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-away {
|
||||||
|
@apply bg-warning-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dnd {
|
||||||
|
@apply bg-accent-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
frontend/src/utils/axios.js
Normal file
73
frontend/src/utils/axios.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
const instance = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000',
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request interceptor
|
||||||
|
instance.interceptors.request.use(
|
||||||
|
config => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response interceptor
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
error => {
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Ne pas rediriger si on est déjà sur une page d'auth
|
||||||
|
const currentRoute = router.currentRoute.value
|
||||||
|
if (!currentRoute.path.includes('/login') && !currentRoute.path.includes('/register')) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
router.push('/login')
|
||||||
|
toast.error('Session expirée, veuillez vous reconnecter')
|
||||||
|
}
|
||||||
|
} else if (error.response?.status === 403) {
|
||||||
|
toast.error('Accès non autorisé')
|
||||||
|
} else if (error.response?.status === 500) {
|
||||||
|
toast.error('Erreur serveur, veuillez réessayer plus tard')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default instance
|
||||||
|
|
||||||
|
// Fonction utilitaire pour construire les URLs des médias
|
||||||
|
export function getMediaUrl(path) {
|
||||||
|
if (!path) return null
|
||||||
|
if (typeof path !== 'string') return path
|
||||||
|
if (path.startsWith('http')) return path
|
||||||
|
|
||||||
|
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||||
|
|
||||||
|
// Déjà un chemin uploads complet
|
||||||
|
if (path.startsWith('/uploads/')) {
|
||||||
|
return `${baseUrl}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chemins relatifs issus de l'API (ex: /avatars/..., /vlogs/..., /albums/...)
|
||||||
|
if (path.startsWith('/')) {
|
||||||
|
return `${baseUrl}/uploads${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
return `${baseUrl}/uploads/${path}`
|
||||||
|
}
|
||||||
2129
frontend/src/views/Admin.vue
Normal file
2129
frontend/src/views/Admin.vue
Normal file
File diff suppressed because it is too large
Load Diff
862
frontend/src/views/AlbumDetail.vue
Normal file
862
frontend/src/views/AlbumDetail.vue
Normal file
@@ -0,0 +1,862 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="text-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||||
|
<p class="mt-4 text-gray-600">Chargement de l'album...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Album not found -->
|
||||||
|
<div v-else-if="!album" class="text-center py-12">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-4">Album non trouvé</h1>
|
||||||
|
<p class="text-gray-600 mb-6">L'album que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||||
|
<router-link to="/albums" class="btn-primary">
|
||||||
|
Retour aux albums
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Album details -->
|
||||||
|
<div v-else>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<router-link to="/albums" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||||
|
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||||
|
Retour aux albums
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<div v-if="canEdit" class="flex space-x-2">
|
||||||
|
<button
|
||||||
|
@click="showEditModal = true"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
<Edit class="w-4 h-4 mr-2" />
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="showUploadModal = true"
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
<Upload class="w-4 h-4 mr-2" />
|
||||||
|
Ajouter des médias
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="deleteAlbum"
|
||||||
|
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4 mr-2" />
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start space-x-6">
|
||||||
|
<!-- 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">
|
||||||
|
<Image v-if="!album.cover_image" class="w-16 h-16 text-white" />
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="getMediaUrl(album.cover_image)"
|
||||||
|
:alt="album.title"
|
||||||
|
class="w-full h-full object-cover rounded-xl"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Album Info -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 mb-4">{{ album.title }}</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div v-if="album.description" class="text-gray-700">
|
||||||
|
{{ album.description }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<User class="w-5 h-5 mr-3" />
|
||||||
|
<span>Créé par {{ album.creator_name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<Calendar class="w-5 h-5 mr-3" />
|
||||||
|
<span>{{ formatDate(album.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="album.event_title" class="flex items-center text-primary-600">
|
||||||
|
<Calendar class="w-5 h-5 mr-3" />
|
||||||
|
<router-link :to="`/events/${album.event_id}`" class="hover:underline">
|
||||||
|
{{ album.event_title }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<h3 class="font-semibold text-gray-900 mb-3">Statistiques</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-primary-600">{{ album.media_count || 0 }}</div>
|
||||||
|
<div class="text-gray-600">Médias</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-success-600">{{ formatBytes(totalSize) }}</div>
|
||||||
|
<div class="text-gray-600">Taille totale</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Media Section -->
|
||||||
|
<div v-if="album.top_media && album.top_media.length > 0" class="card p-6 mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Top Media</h2>
|
||||||
|
<p class="text-gray-600 mb-4">Les médias les plus appréciés de cet album</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
|
<div
|
||||||
|
v-for="media in album.top_media"
|
||||||
|
:key="media.id"
|
||||||
|
class="group relative aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer"
|
||||||
|
@click="openMediaViewer(media)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="media.media_type === 'image'"
|
||||||
|
:src="getMediaUrl(media.thumbnail_path) || getMediaUrl(media.file_path)"
|
||||||
|
:alt="media.caption || 'Image'"
|
||||||
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
v-else
|
||||||
|
:src="getMediaUrl(media.file_path)"
|
||||||
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Media Type Badge -->
|
||||||
|
<div class="absolute top-1 left-1 bg-black bg-opacity-70 text-white text-xs px-1 py-0.5 rounded">
|
||||||
|
{{ media.media_type === 'image' ? '📷' : '🎥' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Likes Badge -->
|
||||||
|
<div class="absolute top-1 right-1 bg-primary-600 text-white text-xs px-1 py-0.5 rounded flex items-center">
|
||||||
|
<Heart class="w-2 h-2 mr-1" />
|
||||||
|
{{ media.likes_count }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Caption -->
|
||||||
|
<div v-if="media.caption" class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-70 text-white text-xs p-1 truncate">
|
||||||
|
{{ media.caption }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Gallery -->
|
||||||
|
<div class="card p-6 mb-8">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">Galerie</h2>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
@click="viewMode = 'grid'"
|
||||||
|
class="p-2 rounded-lg transition-colors"
|
||||||
|
:class="viewMode === 'grid' ? 'bg-primary-100 text-primary-600' : 'text-gray-400 hover:text-gray-600'"
|
||||||
|
>
|
||||||
|
<Grid class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="viewMode = 'list'"
|
||||||
|
class="p-2 rounded-lg transition-colors"
|
||||||
|
:class="viewMode === 'list' ? 'bg-primary-100 text-primary-600' : 'text-gray-400 hover:text-gray-600'"
|
||||||
|
>
|
||||||
|
<List class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="album.media.length === 0" class="text-center py-12 text-gray-500">
|
||||||
|
<Image class="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||||
|
<h3 class="text-lg font-medium mb-2">Aucun média</h3>
|
||||||
|
<p>Cet album ne contient pas encore de photos ou vidéos</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid View -->
|
||||||
|
<div v-else-if="viewMode === 'grid'" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="media in album.media"
|
||||||
|
:key="media.id"
|
||||||
|
class="group relative aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer"
|
||||||
|
@click="openMediaViewer(media)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="media.media_type === 'image'"
|
||||||
|
:src="getMediaUrl(media.thumbnail_path) || getMediaUrl(media.file_path)"
|
||||||
|
:alt="media.caption || 'Image'"
|
||||||
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
v-else
|
||||||
|
:src="getMediaUrl(media.file_path)"
|
||||||
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Media Type Badge -->
|
||||||
|
<div class="absolute top-2 left-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||||
|
{{ media.media_type === 'image' ? '📷' : '🎥' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Caption -->
|
||||||
|
<div v-if="media.caption" class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-70 text-white text-xs p-2">
|
||||||
|
{{ media.caption }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div v-if="canEdit" class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
@click.stop="deleteMedia(media.id)"
|
||||||
|
class="bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Like Button -->
|
||||||
|
<div class="absolute bottom-2 right-2 opacity-100 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
@click.stop="toggleMediaLike(media)"
|
||||||
|
class="flex items-center space-x-2 px-3 py-2 rounded-full text-sm transition-colors shadow-lg"
|
||||||
|
:class="media.is_liked ? 'bg-red-500 text-white hover:bg-red-600' : 'bg-black bg-opacity-80 text-white hover:bg-opacity-90'"
|
||||||
|
>
|
||||||
|
<Heart :class="media.is_liked ? 'fill-current' : ''" class="w-4 h-4" />
|
||||||
|
<span class="font-medium">{{ media.likes_count }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List View -->
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="media in album.media"
|
||||||
|
:key="media.id"
|
||||||
|
class="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="w-16 h-16 bg-gray-200 rounded overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="media.media_type === 'image'"
|
||||||
|
:src="getMediaUrl(media.thumbnail_path) || getMediaUrl(media.file_path)"
|
||||||
|
:alt="media.caption || 'Image'"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
v-else
|
||||||
|
:src="getMediaUrl(media.file_path)"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-gray-900">{{ media.caption || 'Sans titre' }}</p>
|
||||||
|
<p class="text-sm text-gray-600">{{ formatBytes(media.file_size) }} • {{ media.media_type === 'image' ? 'Image' : 'Vidéo' }}</p>
|
||||||
|
<p class="text-xs text-gray-500">{{ formatDate(media.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
@click="openMediaViewer(media)"
|
||||||
|
class="p-2 text-gray-400 hover:text-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Eye class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="toggleMediaLike(media)"
|
||||||
|
class="p-2 text-gray-400 hover:text-red-600 transition-colors"
|
||||||
|
:class="{ 'text-red-600': media.is_liked }"
|
||||||
|
>
|
||||||
|
<Heart :class="media.is_liked ? 'fill-current' : ''" class="w-5 h-5" />
|
||||||
|
<span class="ml-2 text-sm font-medium">{{ media.likes_count }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
@click="deleteMedia(media.id)"
|
||||||
|
class="p-2 text-gray-400 hover:text-accent-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Album Modal -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showEditModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Modifier l'album</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="updateAlbum" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="label">Titre</label>
|
||||||
|
<input
|
||||||
|
v-model="editForm.title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Description</label>
|
||||||
|
<textarea
|
||||||
|
v-model="editForm.description"
|
||||||
|
rows="3"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showEditModal = false"
|
||||||
|
class="flex-1 btn-secondary"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="updating"
|
||||||
|
class="flex-1 btn-primary"
|
||||||
|
>
|
||||||
|
{{ updating ? 'Mise à jour...' : 'Mettre à jour' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Upload Media Modal -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showUploadModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Ajouter des médias</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="uploadMedia" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="label">Photos et vidéos</label>
|
||||||
|
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||||
|
<input
|
||||||
|
ref="mediaInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*,video/*"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
@change="handleMediaChange"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div v-if="newMedia.length === 0" class="space-y-2">
|
||||||
|
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
|
||||||
|
<p class="text-gray-600">Glissez-déposez ou cliquez pour sélectionner</p>
|
||||||
|
<p class="text-sm text-gray-500">Images et vidéos (max 100MB par fichier)</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$refs.mediaInput.click()"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Sélectionner des fichiers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(media, index) in newMedia"
|
||||||
|
:key="index"
|
||||||
|
class="relative aspect-square bg-gray-100 rounded overflow-hidden"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="media.type === 'image'"
|
||||||
|
:src="media.preview"
|
||||||
|
:alt="media.name"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
v-else
|
||||||
|
:src="media.preview"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="removeMedia(index)"
|
||||||
|
class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="absolute bottom-1 left-1 bg-black bg-opacity-70 text-white text-xs px-1 py-0.5 rounded">
|
||||||
|
{{ media.type === 'image' ? '📷' : '🎥' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$refs.mediaInput.click()"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
Ajouter plus de fichiers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showUploadModal = false"
|
||||||
|
class="flex-1 btn-secondary"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="uploading || newMedia.length === 0"
|
||||||
|
class="flex-1 btn-primary"
|
||||||
|
>
|
||||||
|
{{ uploading ? 'Upload...' : 'Ajouter les médias' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Media Viewer Modal -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="selectedMedia"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-90 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="relative max-w-7xl max-h-[95vh] w-full h-full">
|
||||||
|
<!-- Close Button -->
|
||||||
|
<button
|
||||||
|
@click="closeMediaViewer"
|
||||||
|
class="absolute top-6 right-6 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"
|
||||||
|
>
|
||||||
|
<X class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Navigation Buttons -->
|
||||||
|
<button
|
||||||
|
v-if="album.media.length > 1"
|
||||||
|
@click="previousMedia"
|
||||||
|
class="absolute left-6 top-1/2 transform -translate-y-1/2 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"
|
||||||
|
>
|
||||||
|
<ChevronLeft class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="album.media.length > 1"
|
||||||
|
@click="nextMedia"
|
||||||
|
class="absolute right-6 top-1/2 transform -translate-y-1/2 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"
|
||||||
|
>
|
||||||
|
<ChevronRight class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Position Indicator -->
|
||||||
|
<div v-if="album.media.length > 1" class="absolute top-6 left-1/2 transform -translate-x-1/2 z-10 bg-black bg-opacity-70 text-white px-4 py-2 rounded-full text-sm font-medium backdrop-blur-sm">
|
||||||
|
{{ getCurrentMediaIndex() + 1 }} / {{ album.media.length }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Content -->
|
||||||
|
<div class="flex flex-col items-center h-full">
|
||||||
|
<!-- Image or Video -->
|
||||||
|
<div class="w-full h-full flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
v-if="selectedMedia.media_type === 'image'"
|
||||||
|
:src="getMediaUrl(selectedMedia.thumbnail_path) || getMediaUrl(selectedMedia.file_path)"
|
||||||
|
:alt="selectedMedia.caption || 'Image'"
|
||||||
|
class="w-auto h-auto max-w-none max-h-none object-contain rounded-lg shadow-2xl"
|
||||||
|
:style="getOptimalMediaSize()"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
v-else
|
||||||
|
:src="getMediaUrl(selectedMedia.file_path)"
|
||||||
|
controls
|
||||||
|
class="w-auto h-auto max-w-none max-h-none rounded-lg shadow-2xl"
|
||||||
|
:style="getOptimalMediaSize()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Info -->
|
||||||
|
<div class="mt-6 text-center text-white bg-black bg-opacity-50 rounded-xl p-6 backdrop-blur-sm">
|
||||||
|
<h3 v-if="selectedMedia.caption" class="text-xl font-semibold mb-3 text-white">
|
||||||
|
{{ selectedMedia.caption }}
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center justify-center space-x-6 text-sm text-gray-200 mb-4">
|
||||||
|
<span class="flex items-center space-x-2">
|
||||||
|
<span class="w-2 h-2 bg-blue-400 rounded-full"></span>
|
||||||
|
<span>{{ formatBytes(selectedMedia.file_size) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center space-x-2">
|
||||||
|
<span class="w-2 h-2 bg-green-400 rounded-full"></span>
|
||||||
|
<span>{{ selectedMedia.media_type === 'image' ? '📷 Image' : '🎥 Vidéo' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center space-x-2">
|
||||||
|
<span class="w-2 h-2 bg-purple-400 rounded-full"></span>
|
||||||
|
<span>{{ formatDate(selectedMedia.created_at) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Like Button in Viewer -->
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
@click="toggleMediaLikeFromViewer(selectedMedia)"
|
||||||
|
class="flex items-center space-x-3 px-6 py-3 rounded-full transition-all duration-300 transform hover:scale-105"
|
||||||
|
:class="selectedMedia.is_liked ? 'bg-red-500 text-white shadow-lg hover:bg-red-600' : 'bg-white bg-opacity-20 text-white hover:bg-opacity-40 hover:shadow-lg'"
|
||||||
|
>
|
||||||
|
<Heart :class="selectedMedia.is_liked ? 'fill-current' : ''" class="w-6 h-6" />
|
||||||
|
<span class="font-medium text-lg">{{ selectedMedia.likes_count }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
import { format, formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Image,
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
Edit,
|
||||||
|
Upload,
|
||||||
|
Trash2,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
X,
|
||||||
|
Eye,
|
||||||
|
Heart,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const album = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const updating = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const showEditModal = ref(false)
|
||||||
|
const showUploadModal = ref(false)
|
||||||
|
const viewMode = ref('grid')
|
||||||
|
const selectedMedia = ref(null)
|
||||||
|
|
||||||
|
|
||||||
|
const editForm = ref({
|
||||||
|
title: '',
|
||||||
|
description: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const newMedia = ref([])
|
||||||
|
|
||||||
|
const canEdit = computed(() =>
|
||||||
|
album.value && (album.value.creator_id === authStore.user?.id || authStore.user?.is_admin)
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalSize = computed(() =>
|
||||||
|
album.value?.media?.reduce((sum, media) => sum + (media.file_size || 0), 0) || 0
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return format(new Date(date), 'dd MMMM yyyy', { locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlbum() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/albums/${route.params.id}`)
|
||||||
|
album.value = response.data
|
||||||
|
|
||||||
|
// Initialize edit form
|
||||||
|
editForm.value = {
|
||||||
|
title: album.value.title,
|
||||||
|
description: album.value.description || ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors du chargement de l\'album')
|
||||||
|
console.error('Error fetching album:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAlbum() {
|
||||||
|
updating.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.put(`/api/albums/${album.value.id}`, editForm.value)
|
||||||
|
album.value = response.data
|
||||||
|
showEditModal.value = false
|
||||||
|
toast.success('Album mis à jour')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour')
|
||||||
|
}
|
||||||
|
updating.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAlbum() {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir supprimer cet album ?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/albums/${album.value.id}`)
|
||||||
|
toast.success('Album supprimé')
|
||||||
|
router.push('/albums')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la suppression')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMediaChange(event) {
|
||||||
|
const files = Array.from(event.target.files)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
|
||||||
|
toast.error(`${file.name} n'est pas un fichier image ou vidéo valide`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 100 * 1024 * 1024) {
|
||||||
|
toast.error(`${file.name} est trop volumineux (max 100MB)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = {
|
||||||
|
file: file,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type.startsWith('image/') ? 'image' : 'video',
|
||||||
|
preview: URL.createObjectURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
newMedia.value.push(media)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMedia(index) {
|
||||||
|
const media = newMedia.value[index]
|
||||||
|
if (media.preview && media.preview.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(media.preview)
|
||||||
|
}
|
||||||
|
newMedia.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadMedia() {
|
||||||
|
if (newMedia.value.length === 0) return
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
newMedia.value.forEach(media => {
|
||||||
|
formData.append('files', media.file)
|
||||||
|
})
|
||||||
|
|
||||||
|
await axios.post(`/api/albums/${album.value.id}/media`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh album data
|
||||||
|
await fetchAlbum()
|
||||||
|
|
||||||
|
showUploadModal.value = false
|
||||||
|
newMedia.value.forEach(media => {
|
||||||
|
if (media.preview && media.preview.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(media.preview)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
newMedia.value = []
|
||||||
|
|
||||||
|
toast.success('Médias ajoutés avec succès')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de l\'upload')
|
||||||
|
}
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMedia(mediaId) {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir supprimer ce média ?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/albums/${album.value.id}/media/${mediaId}`)
|
||||||
|
await fetchAlbum()
|
||||||
|
toast.success('Média supprimé')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la suppression')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMediaLike(media) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`/api/albums/${album.value.id}/media/${media.id}/like`)
|
||||||
|
media.is_liked = response.data.is_liked
|
||||||
|
media.likes_count = response.data.likes_count
|
||||||
|
toast.success('Like mis à jour')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour du like')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMediaLikeFromViewer(media) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`/api/albums/${album.value.id}/media/${media.id}/like`)
|
||||||
|
media.is_liked = response.data.is_liked
|
||||||
|
media.likes_count = response.data.likes_count
|
||||||
|
toast.success('Like mis à jour')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour du like')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMediaViewer(media) {
|
||||||
|
selectedMedia.value = media
|
||||||
|
|
||||||
|
// Add keyboard event listeners
|
||||||
|
document.addEventListener('keydown', handleKeyboardNavigation)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMediaViewer() {
|
||||||
|
selectedMedia.value = null
|
||||||
|
|
||||||
|
// Remove keyboard event listeners
|
||||||
|
document.removeEventListener('keydown', handleKeyboardNavigation)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyboardNavigation(event) {
|
||||||
|
if (!selectedMedia.value) return
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Escape':
|
||||||
|
closeMediaViewer()
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
if (album.value.media.length > 1) {
|
||||||
|
previousMedia()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
if (album.value.media.length > 1) {
|
||||||
|
nextMedia()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousMedia() {
|
||||||
|
const currentIndex = album.value.media.findIndex(media => media.id === selectedMedia.value.id)
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
selectedMedia.value = album.value.media[currentIndex - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMedia() {
|
||||||
|
const currentIndex = album.value.media.findIndex(media => media.id === selectedMedia.value.id)
|
||||||
|
if (currentIndex < album.value.media.length - 1) {
|
||||||
|
selectedMedia.value = album.value.media[currentIndex + 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentMediaIndex() {
|
||||||
|
if (!selectedMedia.value || !album.value) return 0
|
||||||
|
return album.value.media.findIndex(media => media.id === selectedMedia.value.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptimalMediaSize() {
|
||||||
|
if (!selectedMedia.value) return {}
|
||||||
|
|
||||||
|
// Utilisation MAXIMALE de l'espace disponible
|
||||||
|
if (selectedMedia.value.media_type === 'image') {
|
||||||
|
// Images : 100% de l'écran avec juste une petite marge
|
||||||
|
return {
|
||||||
|
'max-width': '98vw',
|
||||||
|
'max-height': '96vh',
|
||||||
|
'width': 'auto',
|
||||||
|
'height': 'auto',
|
||||||
|
'object-fit': 'contain'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vidéos : presque plein écran
|
||||||
|
return {
|
||||||
|
'max-width': '96vw',
|
||||||
|
'max-height': '94vh',
|
||||||
|
'width': 'auto',
|
||||||
|
'height': 'auto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchAlbum()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// Clean up event listeners
|
||||||
|
document.removeEventListener('keydown', handleKeyboardNavigation)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
786
frontend/src/views/Albums.vue
Normal file
786
frontend/src/views/Albums.vue
Normal file
@@ -0,0 +1,786 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="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>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
|
Nouvel album
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Albums Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div
|
||||||
|
v-for="album in albums"
|
||||||
|
:key="album.id"
|
||||||
|
class="card hover:shadow-lg transition-shadow cursor-pointer"
|
||||||
|
@click="openAlbum(album)"
|
||||||
|
>
|
||||||
|
<!-- Cover Image -->
|
||||||
|
<div class="aspect-square bg-gray-100 relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="album.cover_image"
|
||||||
|
:src="getMediaUrl(album.cover_image)"
|
||||||
|
:alt="album.title"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<Image class="w-16 h-16 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Count Badge -->
|
||||||
|
<div class="absolute top-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||||
|
{{ album.media_count }} média{{ album.media_count > 1 ? 's' : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-semibold text-gray-900 mb-2">{{ album.title }}</h3>
|
||||||
|
|
||||||
|
<div v-if="album.description" class="mb-3">
|
||||||
|
<Mentions :content="album.description" :mentions="getMentionsFromContent(album.description)" class="text-sm text-gray-600 line-clamp-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2 text-sm text-gray-600 mb-3">
|
||||||
|
<img
|
||||||
|
v-if="album.creator_avatar"
|
||||||
|
:src="getMediaUrl(album.creator_avatar)"
|
||||||
|
:alt="album.creator_name"
|
||||||
|
class="w-5 h-5 rounded-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center">
|
||||||
|
<User class="w-3 h-3 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
:to="`/profile/${album.creator_id}`"
|
||||||
|
class="text-gray-900 hover:text-primary-600 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ album.creator_name }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-sm text-gray-500">
|
||||||
|
<span>{{ formatRelativeDate(album.created_at) }}</span>
|
||||||
|
<div v-if="album.event_title" class="text-primary-600">
|
||||||
|
📅 {{ album.event_title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load more -->
|
||||||
|
<div v-if="hasMoreAlbums" class="text-center mt-8">
|
||||||
|
<button
|
||||||
|
@click="loadMoreAlbums"
|
||||||
|
:disabled="loading"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="albums.length === 0 && !loading" class="text-center py-12">
|
||||||
|
<Image class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun album</h3>
|
||||||
|
<p class="text-gray-600">Créez le premier album pour partager vos photos !</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Album Modal -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showCreateModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Créer un nouvel album</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="createAlbum" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="label">Titre</label>
|
||||||
|
<input
|
||||||
|
v-model="newAlbum.title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
placeholder="Titre de l'album..."
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Description</label>
|
||||||
|
<MentionInput
|
||||||
|
v-model="newAlbum.description"
|
||||||
|
:users="users"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="Décrivez votre album... (utilisez @username pour mentionner)"
|
||||||
|
@mentions-changed="handleAlbumMentionsChanged"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Lier à un événement (optionnel)</label>
|
||||||
|
<select
|
||||||
|
v-model="newAlbum.event_id"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
<option value="">Aucun événement</option>
|
||||||
|
<option
|
||||||
|
v-for="event in events"
|
||||||
|
:key="event.id"
|
||||||
|
:value="event.id"
|
||||||
|
>
|
||||||
|
{{ event.title }} - {{ formatDate(event.date) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Photos et vidéos</label>
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center transition-colors"
|
||||||
|
:class="{ 'border-primary-400 bg-primary-50': isDragOver }"
|
||||||
|
@dragover.prevent="isDragOver = true"
|
||||||
|
@dragleave.prevent="isDragOver = false"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="mediaInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*,video/*"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
@change="handleMediaChange"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div v-if="newAlbum.media.length === 0" class="space-y-2">
|
||||||
|
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
|
||||||
|
<p class="text-gray-600">Glissez-déposez ou cliquez pour sélectionner</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Images et vidéos (max {{ uploadLimits.max_image_size_mb }}MB pour images, {{ uploadLimits.max_video_size_mb }}MB pour vidéos)
|
||||||
|
<br>
|
||||||
|
<span class="text-xs text-gray-400">Maximum {{ uploadLimits.max_media_per_album }} fichiers par album</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$refs.mediaInput.click()"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Sélectionner des fichiers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(media, index) in newAlbum.media"
|
||||||
|
:key="index"
|
||||||
|
class="relative aspect-square bg-gray-100 rounded overflow-hidden group"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="media.type === 'image'"
|
||||||
|
:src="media.preview"
|
||||||
|
:alt="media.name"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
v-else
|
||||||
|
:src="media.preview"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- File Info Overlay -->
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-all duration-200 flex items-end">
|
||||||
|
<div class="w-full p-2 text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
<div class="font-medium truncate">{{ media.name }}</div>
|
||||||
|
<div class="text-gray-300">
|
||||||
|
{{ formatFileSize(media.size) }}
|
||||||
|
<span v-if="media.originalSize && media.originalSize > media.size" class="text-green-300">
|
||||||
|
({{ Math.round((1 - media.size / media.originalSize) * 100) }}% compression)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Caption Input -->
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-70 p-2">
|
||||||
|
<input
|
||||||
|
v-model="media.caption"
|
||||||
|
type="text"
|
||||||
|
:placeholder="`Légende pour ${media.name}`"
|
||||||
|
class="w-full text-xs bg-transparent text-white placeholder-gray-300 border-none outline-none"
|
||||||
|
maxlength="100"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="removeMedia(index)"
|
||||||
|
class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="absolute top-1 left-1 bg-black bg-opacity-70 text-white text-xs px-1 py-0.5 rounded">
|
||||||
|
{{ media.type === 'image' ? '📷' : '🎥' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$refs.mediaInput.click()"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
Ajouter plus de fichiers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Progress Bar -->
|
||||||
|
<div v-if="isUploading" class="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-sm font-medium text-blue-900">{{ uploadStatus }}</span>
|
||||||
|
<span class="text-sm text-blue-700">{{ currentFileIndex }}/{{ totalFiles }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-blue-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
:style="`width: ${uploadProgress}%`"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xs text-blue-600">
|
||||||
|
{{ Math.round(uploadProgress) }}% terminé
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Results Summary -->
|
||||||
|
<div v-if="uploadSuccess.length > 0 || uploadErrors.length > 0" class="mt-4 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div v-if="uploadSuccess.length > 0" class="mb-2">
|
||||||
|
<div class="text-sm font-medium text-green-700">
|
||||||
|
✅ {{ uploadSuccess.length }} fichier(s) uploadé(s) avec succès
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="uploadErrors.length > 0" class="mb-2">
|
||||||
|
<div class="text-sm font-medium text-red-700">
|
||||||
|
❌ {{ uploadErrors.length }} erreur(s) lors de l'upload
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-red-600 mt-1">
|
||||||
|
<div v-for="error in uploadErrors" :key="error.file" class="mb-1">
|
||||||
|
{{ error.file }}: {{ error.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showCreateModal = false"
|
||||||
|
:disabled="isUploading"
|
||||||
|
class="flex-1 btn-secondary"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="creating || newAlbum.media.length === 0 || isUploading"
|
||||||
|
class="flex-1 btn-primary"
|
||||||
|
>
|
||||||
|
<span v-if="isUploading">
|
||||||
|
<Upload class="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Upload en cours...
|
||||||
|
</span>
|
||||||
|
<span v-else-if="creating">
|
||||||
|
Création...
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
Créer l'album
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
import { formatDistanceToNow, format } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Image,
|
||||||
|
User,
|
||||||
|
Upload,
|
||||||
|
X
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import Mentions from '@/components/Mentions.vue'
|
||||||
|
import MentionInput from '@/components/MentionInput.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const albums = ref([])
|
||||||
|
const events = ref([])
|
||||||
|
const users = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const hasMoreAlbums = ref(true)
|
||||||
|
const offset = ref(0)
|
||||||
|
const uploadLimits = ref({
|
||||||
|
max_image_size_mb: 10,
|
||||||
|
max_video_size_mb: 100,
|
||||||
|
max_media_per_album: 50
|
||||||
|
})
|
||||||
|
|
||||||
|
const newAlbum = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
event_id: '',
|
||||||
|
media: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const albumMentions = ref([])
|
||||||
|
|
||||||
|
// Upload optimization states
|
||||||
|
const uploadProgress = ref(0)
|
||||||
|
const isUploading = ref(false)
|
||||||
|
const uploadStatus = ref('')
|
||||||
|
const currentFileIndex = ref(0)
|
||||||
|
const totalFiles = ref(0)
|
||||||
|
const uploadErrors = ref([])
|
||||||
|
const uploadSuccess = ref([])
|
||||||
|
const isDragOver = ref(false)
|
||||||
|
|
||||||
|
function formatRelativeDate(date) {
|
||||||
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return format(new Date(date), 'dd/MM/yyyy', { locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAlbum(album) {
|
||||||
|
router.push(`/albums/${album.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAlbumMentionsChanged(mentions) {
|
||||||
|
albumMentions.value = mentions
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMentionsFromContent(content) {
|
||||||
|
if (!content) return []
|
||||||
|
|
||||||
|
const mentions = []
|
||||||
|
const mentionRegex = /@(\w+)/g
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = mentionRegex.exec(content)) !== null) {
|
||||||
|
const username = match[1]
|
||||||
|
const user = users.value.find(u => u.username === username)
|
||||||
|
if (user) {
|
||||||
|
mentions.push({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
full_name: user.full_name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mentions
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsers() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/users')
|
||||||
|
users.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUploadLimits() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/settings/upload-limits')
|
||||||
|
uploadLimits.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching upload limits:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMediaChange(event) {
|
||||||
|
const files = Array.from(event.target.files)
|
||||||
|
const maxFiles = uploadLimits.value.max_media_per_album
|
||||||
|
|
||||||
|
if (newAlbum.value.media.length + files.length > maxFiles) {
|
||||||
|
toast.error(`Maximum ${maxFiles} fichiers autorisés par album`)
|
||||||
|
event.target.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let validFiles = 0
|
||||||
|
let skippedFiles = 0
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
|
||||||
|
toast.error(`${file.name} n'est pas un fichier image ou vidéo valide`)
|
||||||
|
skippedFiles++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size
|
||||||
|
const maxSizeMB = file.type.startsWith('image/') ? uploadLimits.value.max_image_size_mb : uploadLimits.value.max_video_size_mb
|
||||||
|
const maxSizeBytes = maxSizeMB * 1024 * 1024
|
||||||
|
|
||||||
|
if (file.size > maxSizeBytes) {
|
||||||
|
toast.error(`${file.name} est trop volumineux (max ${maxSizeMB}MB)`)
|
||||||
|
skippedFiles++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file name (prevent special characters issues)
|
||||||
|
if (file.name.length > 100) {
|
||||||
|
toast.error(`${file.name} a un nom trop long (max 100 caractères)`)
|
||||||
|
skippedFiles++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimize image if it's an image file
|
||||||
|
let optimizedFile = file
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
try {
|
||||||
|
optimizedFile = await optimizeImage(file)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Could not optimize ${file.name}:`, error)
|
||||||
|
optimizedFile = file // Fallback to original file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create media object with optimized preview
|
||||||
|
const media = {
|
||||||
|
file: optimizedFile,
|
||||||
|
originalFile: file, // Keep reference to original for size display
|
||||||
|
name: file.name,
|
||||||
|
type: file.type.startsWith('image/') ? 'image' : 'video',
|
||||||
|
size: optimizedFile.size,
|
||||||
|
originalSize: file.size,
|
||||||
|
preview: URL.createObjectURL(optimizedFile),
|
||||||
|
caption: '' // Add caption field for user input
|
||||||
|
}
|
||||||
|
|
||||||
|
newAlbum.value.media.push(media)
|
||||||
|
validFiles++
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing file ${file.name}:`, error)
|
||||||
|
toast.error(`Erreur lors du traitement de ${file.name}`)
|
||||||
|
skippedFiles++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show summary
|
||||||
|
if (validFiles > 0) {
|
||||||
|
if (skippedFiles > 0) {
|
||||||
|
toast.info(`${validFiles} fichier(s) ajouté(s), ${skippedFiles} ignoré(s)`)
|
||||||
|
} else {
|
||||||
|
toast.success(`${validFiles} fichier(s) ajouté(s)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event) {
|
||||||
|
isDragOver.value = false
|
||||||
|
const files = Array.from(event.dataTransfer.files)
|
||||||
|
if (files.length > 0) {
|
||||||
|
// Simulate file input change
|
||||||
|
const fakeEvent = { target: { files: files } }
|
||||||
|
handleMediaChange(fakeEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizeImage(file) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
resolve(file) // Return original file for non-images
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const img = new Image()
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// Calculate optimal dimensions (much higher for modern screens)
|
||||||
|
const maxWidth = 3840 // 4K support
|
||||||
|
const maxHeight = 2160 // 4K support
|
||||||
|
let { width, height } = img
|
||||||
|
|
||||||
|
// Only resize if image is REALLY too large
|
||||||
|
if (width > maxWidth || height > maxHeight) {
|
||||||
|
const ratio = Math.min(maxWidth / width, maxHeight / height)
|
||||||
|
width *= ratio
|
||||||
|
height *= ratio
|
||||||
|
} else {
|
||||||
|
// Keep original dimensions for most images
|
||||||
|
resolve(file)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
|
||||||
|
// Draw optimized image
|
||||||
|
ctx.drawImage(img, 0, 0, width, height)
|
||||||
|
|
||||||
|
// Convert to blob with HIGH quality
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
const optimizedFile = new File([blob], file.name, {
|
||||||
|
type: file.type,
|
||||||
|
lastModified: Date.now()
|
||||||
|
})
|
||||||
|
resolve(optimizedFile)
|
||||||
|
}, file.type, 0.95) // 95% quality for minimal loss
|
||||||
|
}
|
||||||
|
|
||||||
|
img.src = URL.createObjectURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMedia(index) {
|
||||||
|
const media = newAlbum.value.media[index]
|
||||||
|
|
||||||
|
// Clean up preview URLs
|
||||||
|
if (media.preview && media.preview.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(media.preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up original file preview if different
|
||||||
|
if (media.originalFile && media.originalFile !== media.file) {
|
||||||
|
// Note: originalFile doesn't have preview, but we could add cleanup here if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
newAlbum.value.media.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAlbum() {
|
||||||
|
if (!newAlbum.value.title || newAlbum.value.media.length === 0) return
|
||||||
|
|
||||||
|
// Check max media per album limit
|
||||||
|
if (uploadLimits.value.max_media_per_album && newAlbum.value.media.length > uploadLimits.value.max_media_per_album) {
|
||||||
|
toast.error(`Nombre maximum de médias par album dépassé : ${uploadLimits.value.max_media_per_album}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creating.value = true
|
||||||
|
isUploading.value = true
|
||||||
|
uploadProgress.value = 0
|
||||||
|
currentFileIndex.value = 0
|
||||||
|
totalFiles.value = newAlbum.value.media.length
|
||||||
|
uploadErrors.value = []
|
||||||
|
uploadSuccess.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First create the album
|
||||||
|
const albumData = {
|
||||||
|
title: newAlbum.value.title,
|
||||||
|
description: newAlbum.value.description,
|
||||||
|
event_id: newAlbum.value.event_id || null
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadStatus.value = 'Création de l\'album...'
|
||||||
|
const albumResponse = await axios.post('/api/albums', albumData)
|
||||||
|
const album = albumResponse.data
|
||||||
|
|
||||||
|
// Upload media files in batches for better performance
|
||||||
|
const batchSize = 5 // Upload 5 files at a time
|
||||||
|
const totalBatches = Math.ceil(newAlbum.value.media.length / batchSize)
|
||||||
|
|
||||||
|
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
|
||||||
|
const startIndex = batchIndex * batchSize
|
||||||
|
const endIndex = Math.min(startIndex + batchSize, newAlbum.value.media.length)
|
||||||
|
const batch = newAlbum.value.media.slice(startIndex, endIndex)
|
||||||
|
|
||||||
|
uploadStatus.value = `Upload du lot ${batchIndex + 1}/${totalBatches}...`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
batch.forEach((media, index) => {
|
||||||
|
formData.append('files', media.file)
|
||||||
|
if (media.caption) {
|
||||||
|
formData.append('captions', media.caption)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await axios.post(`/api/albums/${album.id}/media`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
// Update progress for this batch
|
||||||
|
const batchProgress = (progressEvent.loaded / progressEvent.total) * 100
|
||||||
|
const overallProgress = ((batchIndex * batchSize + batch.length) / newAlbum.value.media.length) * 100
|
||||||
|
uploadProgress.value = Math.min(overallProgress, 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark batch as successful
|
||||||
|
batch.forEach((media, index) => {
|
||||||
|
const globalIndex = startIndex + index
|
||||||
|
currentFileIndex.value = globalIndex + 1
|
||||||
|
uploadSuccess.value.push({
|
||||||
|
file: media.name,
|
||||||
|
type: media.type
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error uploading batch ${batchIndex + 1}:`, error)
|
||||||
|
|
||||||
|
// Mark batch as failed
|
||||||
|
batch.forEach((media, index) => {
|
||||||
|
const globalIndex = startIndex + index
|
||||||
|
currentFileIndex.value = globalIndex + 1
|
||||||
|
uploadErrors.value.push({
|
||||||
|
file: media.name,
|
||||||
|
message: error.response?.data?.detail || 'Erreur lors de l\'upload'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay between batches to avoid overwhelming the server
|
||||||
|
if (batchIndex < totalBatches - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadStatus.value = 'Finalisation...'
|
||||||
|
uploadProgress.value = 100
|
||||||
|
|
||||||
|
// Refresh albums list
|
||||||
|
await fetchAlbums()
|
||||||
|
|
||||||
|
// Show results
|
||||||
|
if (uploadErrors.value.length === 0) {
|
||||||
|
toast.success(`Album créé avec succès ! ${uploadSuccess.value.length} fichier(s) uploadé(s)`)
|
||||||
|
} else if (uploadSuccess.value.length > 0) {
|
||||||
|
toast.warning(`Album créé avec ${uploadSuccess.value.length} fichier(s) uploadé(s) et ${uploadErrors.value.length} erreur(s)`)
|
||||||
|
} else {
|
||||||
|
toast.error('Erreur lors de l\'upload de tous les fichiers')
|
||||||
|
}
|
||||||
|
|
||||||
|
showCreateModal.value = false
|
||||||
|
resetForm()
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating album:', error)
|
||||||
|
toast.error('Erreur lors de la création de l\'album')
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
isUploading.value = false
|
||||||
|
uploadProgress.value = 0
|
||||||
|
currentFileIndex.value = 0
|
||||||
|
totalFiles.value = 0
|
||||||
|
uploadStatus.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
// Clean up media previews
|
||||||
|
newAlbum.value.media.forEach(media => {
|
||||||
|
if (media.preview && media.preview.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(media.preview)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset form data
|
||||||
|
newAlbum.value = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
event_id: '',
|
||||||
|
media: []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset upload states
|
||||||
|
uploadProgress.value = 0
|
||||||
|
isUploading.value = false
|
||||||
|
uploadStatus.value = ''
|
||||||
|
currentFileIndex.value = 0
|
||||||
|
totalFiles.value = 0
|
||||||
|
uploadErrors.value = []
|
||||||
|
uploadSuccess.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlbums() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/albums?limit=12&offset=${offset.value}`)
|
||||||
|
if (offset.value === 0) {
|
||||||
|
albums.value = response.data
|
||||||
|
} else {
|
||||||
|
albums.value.push(...response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMoreAlbums.value = response.data.length === 12
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors du chargement des albums')
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEvents() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/events')
|
||||||
|
events.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching events:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreAlbums() {
|
||||||
|
offset.value += 12
|
||||||
|
await fetchAlbums()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchAlbums()
|
||||||
|
fetchEvents()
|
||||||
|
fetchUsers()
|
||||||
|
fetchUploadLimits()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
510
frontend/src/views/EventDetail.vue
Normal file
510
frontend/src/views/EventDetail.vue
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="text-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||||
|
<p class="mt-4 text-gray-600">Chargement de l'événement...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event not found -->
|
||||||
|
<div v-else-if="!event" class="text-center py-12">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-4">Événement non trouvé</h1>
|
||||||
|
<p class="text-gray-600 mb-6">L'événement que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||||
|
<router-link to="/events" class="btn-primary">
|
||||||
|
Retour aux événements
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event details -->
|
||||||
|
<div v-else>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<router-link to="/events" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||||
|
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||||
|
Retour aux événements
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<div v-if="canEdit" class="flex space-x-2">
|
||||||
|
<button
|
||||||
|
@click="showEditModal = true"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
<Edit class="w-4 h-4 mr-2" />
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="deleteEvent"
|
||||||
|
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4 mr-2" />
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start space-x-6">
|
||||||
|
<!-- 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">
|
||||||
|
<Calendar v-if="!event.cover_image" class="w-16 h-16 text-white" />
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="getMediaUrl(event.cover_image)"
|
||||||
|
:alt="event.title"
|
||||||
|
class="w-full h-full object-cover rounded-xl"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event Info -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 mb-4">{{ event.title }}</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<Clock class="w-5 h-5 mr-3" />
|
||||||
|
<span>{{ formatDate(event.date) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="event.end_date" class="flex items-center text-gray-600">
|
||||||
|
<Clock class="w-5 h-5 mr-3" />
|
||||||
|
<span>Fin : {{ formatDate(event.end_date) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="event.location" class="flex items-center text-gray-600">
|
||||||
|
<MapPin class="w-5 h-5 mr-3" />
|
||||||
|
<span>{{ event.location }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<User class="w-5 h-5 mr-3" />
|
||||||
|
<span>Organisé par {{ event.creator_name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Participation Stats -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<h3 class="font-semibold text-gray-900 mb-3">Participation</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-success-600">{{ event.present_count || 0 }}</div>
|
||||||
|
<div class="text-gray-600">Présents</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-warning-600">{{ event.maybe_count || 0 }}</div>
|
||||||
|
<div class="text-gray-600">Peut-être</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-accent-600">{{ event.absent_count || 0 }}</div>
|
||||||
|
<div class="text-gray-600">Absents</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-secondary-600">{{ event.pending_count || 0 }}</div>
|
||||||
|
<div class="text-gray-600">En attente</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div v-if="event.description" class="card p-6 mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Description</h2>
|
||||||
|
<p class="text-gray-700 whitespace-pre-wrap">{{ event.description }}</p>
|
||||||
|
</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 -->
|
||||||
|
<div class="card p-6 mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Ma participation</h2>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
@click="updateParticipation('present')"
|
||||||
|
class="flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
:class="getParticipationClass('present')"
|
||||||
|
>
|
||||||
|
✓ Présent
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="updateParticipation('maybe')"
|
||||||
|
class="flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
:class="getParticipationClass('maybe')"
|
||||||
|
>
|
||||||
|
? Peut-être
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="updateParticipation('absent')"
|
||||||
|
class="flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
:class="getParticipationClass('absent')"
|
||||||
|
>
|
||||||
|
✗ Absent
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Participants -->
|
||||||
|
<div class="card p-6 mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Participants</h2>
|
||||||
|
|
||||||
|
<div v-if="event.participations.length === 0" class="text-center py-8 text-gray-500">
|
||||||
|
Aucun participant pour le moment
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="participation in event.participations"
|
||||||
|
:key="participation.user_id"
|
||||||
|
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<img
|
||||||
|
v-if="participation.avatar_url"
|
||||||
|
:src="getMediaUrl(participation.avatar_url)"
|
||||||
|
:alt="participation.full_name"
|
||||||
|
class="w-10 h-10 rounded-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<User class="w-5 h-5 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900">{{ participation.full_name }}</p>
|
||||||
|
<p class="text-sm text-gray-600">@{{ participation.username }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span
|
||||||
|
class="px-3 py-1 rounded-full text-xs font-medium"
|
||||||
|
:class="getStatusClass(participation.status)"
|
||||||
|
>
|
||||||
|
{{ getStatusText(participation.status) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="participation.response_date" class="text-xs text-gray-500">
|
||||||
|
{{ formatRelativeDate(participation.response_date) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Related Albums -->
|
||||||
|
<div class="card p-6 mb-8">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">Albums liés</h2>
|
||||||
|
<router-link
|
||||||
|
:to="`/albums?event_id=${event.id}`"
|
||||||
|
class="text-primary-600 hover:text-primary-700 text-sm"
|
||||||
|
>
|
||||||
|
Voir tous les albums →
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="relatedAlbums.length === 0" class="text-center py-8 text-gray-500">
|
||||||
|
Aucun album lié à cet événement
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<router-link
|
||||||
|
v-for="album in relatedAlbums"
|
||||||
|
:key="album.id"
|
||||||
|
:to="`/albums/${album.id}`"
|
||||||
|
class="block hover:shadow-lg transition-all duration-300 rounded-xl overflow-hidden bg-white border border-gray-200 hover:border-primary-300 hover:scale-105 transform"
|
||||||
|
>
|
||||||
|
<div class="aspect-[4/3] bg-gray-100 relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="album.cover_image"
|
||||||
|
:src="getMediaUrl(album.cover_image)"
|
||||||
|
:alt="album.title"
|
||||||
|
class="w-full h-full object-cover hover:scale-110 transition-transform duration-300"
|
||||||
|
>
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<Image class="w-16 h-16 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Count Badge -->
|
||||||
|
<div class="absolute top-3 right-3 bg-black bg-opacity-80 text-white text-sm px-3 py-1.5 rounded-full font-medium">
|
||||||
|
{{ album.media_count }} média{{ album.media_count > 1 ? 's' : '' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hover Overlay -->
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-0 hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center">
|
||||||
|
<div class="opacity-0 hover:opacity-100 transition-opacity duration-300">
|
||||||
|
<div class="bg-white bg-opacity-90 text-gray-900 px-4 py-2 rounded-full font-medium">
|
||||||
|
Voir l'album →
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-semibold text-gray-900 text-lg mb-2 line-clamp-2">{{ album.title }}</h3>
|
||||||
|
<div class="flex items-center space-x-2 text-sm text-gray-600 mb-2">
|
||||||
|
<User class="w-4 h-4" />
|
||||||
|
<span>{{ album.creator_name }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="album.description" class="text-sm text-gray-500 line-clamp-2">{{ album.description }}</p>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Event Modal -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showEditModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Modifier l'événement</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="updateEvent" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="label">Titre</label>
|
||||||
|
<input
|
||||||
|
v-model="editForm.title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Description</label>
|
||||||
|
<textarea
|
||||||
|
v-model="editForm.description"
|
||||||
|
rows="3"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Date et heure</label>
|
||||||
|
<input
|
||||||
|
v-model="editForm.date"
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Lieu</label>
|
||||||
|
<input
|
||||||
|
v-model="editForm.location"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showEditModal = false"
|
||||||
|
class="flex-1 btn-secondary"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="updating"
|
||||||
|
class="flex-1 btn-primary"
|
||||||
|
>
|
||||||
|
{{ updating ? 'Mise à jour...' : 'Mettre à jour' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
import { format, formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
MapPin,
|
||||||
|
User,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Image
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const event = ref(null)
|
||||||
|
const relatedAlbums = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const updating = ref(false)
|
||||||
|
const showEditModal = ref(false)
|
||||||
|
|
||||||
|
const editForm = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
date: '',
|
||||||
|
location: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const canEdit = computed(() =>
|
||||||
|
event.value && (event.value.creator_id === authStore.user?.id || authStore.user?.is_admin)
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return format(new Date(date), 'EEEE d MMMM yyyy à HH:mm', { locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeDate(date) {
|
||||||
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParticipationClass(status) {
|
||||||
|
const participation = event.value?.participations.find(p => p.user_id === authStore.user?.id)
|
||||||
|
const isSelected = participation?.status === status
|
||||||
|
|
||||||
|
if (status === 'present') {
|
||||||
|
return isSelected
|
||||||
|
? 'bg-success-100 text-success-700 border-success-300'
|
||||||
|
: 'bg-white text-gray-700 border-gray-300 hover:bg-success-50'
|
||||||
|
} else if (status === 'maybe') {
|
||||||
|
return isSelected
|
||||||
|
? 'bg-warning-100 text-warning-700 border-warning-300'
|
||||||
|
: 'bg-white text-gray-700 border-gray-300 hover:bg-warning-50'
|
||||||
|
} else {
|
||||||
|
return isSelected
|
||||||
|
? 'bg-accent-100 text-accent-700 border-accent-300'
|
||||||
|
: 'bg-white text-gray-700 border-gray-300 hover:bg-accent-50'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusClass(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'present':
|
||||||
|
return 'bg-success-100 text-success-700'
|
||||||
|
case 'maybe':
|
||||||
|
return 'bg-warning-100 text-warning-700'
|
||||||
|
case 'absent':
|
||||||
|
return 'bg-accent-100 text-accent-700'
|
||||||
|
default:
|
||||||
|
return 'bg-secondary-100 text-secondary-700'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'present':
|
||||||
|
return 'Présent'
|
||||||
|
case 'maybe':
|
||||||
|
return 'Peut-être'
|
||||||
|
case 'absent':
|
||||||
|
return 'Absent'
|
||||||
|
default:
|
||||||
|
return 'En attente'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEvent() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/events/${route.params.id}`)
|
||||||
|
event.value = response.data
|
||||||
|
|
||||||
|
// Fetch related albums
|
||||||
|
const albumsResponse = await axios.get(`/api/albums?event_id=${event.value.id}`)
|
||||||
|
relatedAlbums.value = albumsResponse.data
|
||||||
|
|
||||||
|
// Initialize edit form
|
||||||
|
editForm.value = {
|
||||||
|
title: event.value.title,
|
||||||
|
description: event.value.description || '',
|
||||||
|
date: format(new Date(event.value.date), "yyyy-MM-dd'T'HH:mm", { locale: fr }),
|
||||||
|
location: event.value.location || ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors du chargement de l\'événement')
|
||||||
|
console.error('Error fetching event:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateParticipation(status) {
|
||||||
|
try {
|
||||||
|
const response = await axios.put(`/api/events/${event.value.id}/participation`, { status })
|
||||||
|
event.value = response.data
|
||||||
|
toast.success('Participation mise à jour')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateEvent() {
|
||||||
|
updating.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.put(`/api/events/${event.value.id}`, {
|
||||||
|
...editForm.value,
|
||||||
|
date: new Date(editForm.value.date).toISOString()
|
||||||
|
})
|
||||||
|
event.value = response.data
|
||||||
|
showEditModal.value = false
|
||||||
|
toast.success('Événement mis à jour')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour')
|
||||||
|
}
|
||||||
|
updating.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEvent() {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/events/${event.value.id}`)
|
||||||
|
toast.success('Événement supprimé')
|
||||||
|
router.push('/events')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la suppression')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchEvent()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
505
frontend/src/views/Events.vue
Normal file
505
frontend/src/views/Events.vue
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="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>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
|
Nouvel événement
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-gray-200 mb-8">
|
||||||
|
<nav class="-mb-px flex space-x-8">
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'upcoming'"
|
||||||
|
class="py-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||||
|
:class="activeTab === 'upcoming'
|
||||||
|
? 'border-primary-500 text-primary-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||||
|
>
|
||||||
|
À venir
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'past'"
|
||||||
|
class="py-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||||
|
:class="activeTab === 'past'
|
||||||
|
? 'border-primary-500 text-primary-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||||
|
>
|
||||||
|
Passés
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'all'"
|
||||||
|
class="py-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||||
|
:class="activeTab === 'all'
|
||||||
|
? 'border-primary-500 text-primary-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||||
|
>
|
||||||
|
Tous
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Events Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div v-for="event in filteredEvents" :key="event.id" class="card hover:shadow-lg transition-shadow cursor-pointer" @click="openEvent(event)">
|
||||||
|
<div class="aspect-video bg-gray-100 relative overflow-hidden">
|
||||||
|
<img v-if="event.cover_image" :src="getMediaUrl(event.cover_image)" :alt="event.title" class="w-full h-full object-cover">
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<Calendar class="w-16 h-16 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Badge -->
|
||||||
|
<div class="absolute top-2 left-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||||
|
{{ formatDate(event.date) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Participation Badge -->
|
||||||
|
<div class="absolute top-2 right-2">
|
||||||
|
<span
|
||||||
|
v-if="getUserParticipation(event)"
|
||||||
|
class="px-2 py-1 rounded-full text-xs font-medium text-white"
|
||||||
|
:class="getParticipationBadgeClass(getUserParticipation(event))"
|
||||||
|
>
|
||||||
|
{{ getParticipationText(getUserParticipation(event)) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-semibold text-gray-900 mb-2 line-clamp-2">{{ event.title }}</h3>
|
||||||
|
|
||||||
|
<!-- Description with mentions -->
|
||||||
|
<div v-if="event.description" class="mb-3">
|
||||||
|
<Mentions :content="event.description" :mentions="getMentionsFromContent(event.description)" class="text-sm text-gray-600 line-clamp-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center text-gray-600 mb-3">
|
||||||
|
<MapPin class="w-4 h-4 mr-2" />
|
||||||
|
<span class="text-sm">{{ event.location || 'Lieu non spécifié' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center text-gray-600 mb-4">
|
||||||
|
<User class="w-4 h-4 mr-2" />
|
||||||
|
<router-link
|
||||||
|
:to="`/profile/${event.creator_id}`"
|
||||||
|
class="text-sm text-gray-900 hover:text-primary-600 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ event.creator_name }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Participation Buttons -->
|
||||||
|
<div class="flex gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
@click.stop="quickParticipation(event.id, 'present')"
|
||||||
|
class="flex-1 py-2 px-3 rounded text-xs font-medium transition-colors"
|
||||||
|
:class="getUserParticipation(event) === 'present' ? 'bg-success-100 text-success-700' : 'bg-gray-100 text-gray-700 hover:bg-success-50'"
|
||||||
|
>
|
||||||
|
✓ Présent
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click.stop="quickParticipation(event.id, 'maybe')"
|
||||||
|
class="flex-1 py-2 px-3 rounded text-xs font-medium transition-colors"
|
||||||
|
:class="getUserParticipation(event) === 'maybe' ? 'bg-warning-100 text-warning-700' : 'bg-gray-100 text-gray-700 hover:bg-warning-50'"
|
||||||
|
>
|
||||||
|
? Peut-être
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click.stop="quickParticipation(event.id, 'absent')"
|
||||||
|
class="flex-1 py-2 px-3 rounded text-xs font-medium transition-colors"
|
||||||
|
:class="getUserParticipation(event) === 'absent' ? 'bg-accent-100 text-accent-700' : 'bg-gray-100 text-gray-700 hover:bg-accent-50'"
|
||||||
|
>
|
||||||
|
✗ Absent
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Participation Stats -->
|
||||||
|
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<span>{{ event.present_count || 0 }} présents</span>
|
||||||
|
<span>{{ event.maybe_count || 0 }} peut-être</span>
|
||||||
|
<span>{{ event.absent_count || 0 }} absents</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load more -->
|
||||||
|
<div v-if="hasMoreEvents" class="text-center mt-8">
|
||||||
|
<button
|
||||||
|
@click="loadMoreEvents"
|
||||||
|
:disabled="loading"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="filteredEvents.length === 0 && !loading" class="text-center py-12">
|
||||||
|
<Calendar class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
{{ activeTab === 'upcoming' ? 'Aucun événement à venir' :
|
||||||
|
activeTab === 'past' ? 'Aucun événement passé' : 'Aucun événement' }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
{{ activeTab === 'upcoming' ? 'Créez le premier événement pour commencer !' :
|
||||||
|
activeTab === 'past' ? 'Les événements passés apparaîtront ici' :
|
||||||
|
'Créez le premier événement pour commencer !' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Event Modal -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showCreateModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Créer un nouvel événement</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="createEvent" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="label">Titre</label>
|
||||||
|
<input
|
||||||
|
v-model="newEvent.title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
placeholder="Titre de l'événement..."
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Description (optionnel)</label>
|
||||||
|
<MentionInput
|
||||||
|
v-model="newEvent.description"
|
||||||
|
:users="users"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="Décrivez votre événement... (utilisez @username pour mentionner)"
|
||||||
|
@mentions-changed="handleEventMentionsChanged"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Lieu</label>
|
||||||
|
<input
|
||||||
|
v-model="newEvent.location"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
placeholder="Adresse ou lieu de l'événement..."
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Section -->
|
||||||
|
<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>
|
||||||
|
<label class="label">Date et heure</label>
|
||||||
|
<input
|
||||||
|
v-model="newEvent.date"
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Date de fin (optionnel)</label>
|
||||||
|
<input
|
||||||
|
v-model="newEvent.end_date"
|
||||||
|
type="datetime-local"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showCreateModal = false"
|
||||||
|
class="flex-1 btn-secondary"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="creating"
|
||||||
|
class="flex-1 btn-primary"
|
||||||
|
>
|
||||||
|
{{ creating ? 'Création...' : 'Créer l\'événement' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
import { formatDistanceToNow, format } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
MapPin
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import Mentions from '@/components/Mentions.vue'
|
||||||
|
import MentionInput from '@/components/MentionInput.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const events = ref([])
|
||||||
|
const users = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const hasMoreEvents = ref(true)
|
||||||
|
const offset = ref(0)
|
||||||
|
const activeTab = ref('upcoming')
|
||||||
|
|
||||||
|
const newEvent = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
date: '',
|
||||||
|
location: '',
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
end_date: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventMentions = ref([])
|
||||||
|
|
||||||
|
const filteredEvents = computed(() => {
|
||||||
|
if (activeTab.value === 'upcoming') {
|
||||||
|
return events.value.filter(event => new Date(event.date) >= new Date())
|
||||||
|
} else if (activeTab.value === 'past') {
|
||||||
|
return events.value.filter(event => new Date(event.date) < new Date())
|
||||||
|
}
|
||||||
|
return events.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatRelativeDate(date) {
|
||||||
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return format(new Date(date), 'dd/MM/yyyy', { locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEvent(event) {
|
||||||
|
router.push(`/events/${event.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEventMentionsChanged(mentions) {
|
||||||
|
eventMentions.value = mentions
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsers() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/users')
|
||||||
|
users.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMentionsFromContent(content) {
|
||||||
|
if (!content) return []
|
||||||
|
|
||||||
|
const mentions = []
|
||||||
|
const mentionRegex = /@(\w+)/g
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = mentionRegex.exec(content)) !== null) {
|
||||||
|
const username = match[1]
|
||||||
|
const user = users.value.find(u => u.username === username)
|
||||||
|
if (user) {
|
||||||
|
mentions.push({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
full_name: user.full_name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mentions
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserParticipation(event) {
|
||||||
|
const participation = event.participations?.find(p => p.user_id === authStore.user?.id)
|
||||||
|
return participation?.status || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParticipationBadgeClass(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'present': return 'bg-success-600'
|
||||||
|
case 'maybe': return 'bg-warning-600'
|
||||||
|
case 'absent': return 'bg-accent-600'
|
||||||
|
default: return 'bg-gray-600'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParticipationText(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'present': return 'Présent'
|
||||||
|
case 'maybe': return 'Peut-être'
|
||||||
|
case 'absent': return 'Absent'
|
||||||
|
default: return 'En attente'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickParticipation(eventId, status) {
|
||||||
|
try {
|
||||||
|
const response = await axios.put(`/api/events/${eventId}/participation`, {
|
||||||
|
status: status
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update the event in the list
|
||||||
|
const eventIndex = events.value.findIndex(e => e.id === eventId)
|
||||||
|
if (eventIndex !== -1) {
|
||||||
|
events.value[eventIndex] = response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Participation mise à jour : ${getParticipationText(status)}`)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour de la participation')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEvent() {
|
||||||
|
if (!newEvent.value.title || !newEvent.value.date) return
|
||||||
|
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const eventData = {
|
||||||
|
title: newEvent.value.title,
|
||||||
|
description: newEvent.value.description,
|
||||||
|
date: new Date(newEvent.value.date).toISOString(),
|
||||||
|
location: newEvent.value.location,
|
||||||
|
latitude: newEvent.value.latitude,
|
||||||
|
longitude: newEvent.value.longitude,
|
||||||
|
end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.post('/api/events', eventData)
|
||||||
|
|
||||||
|
// Refresh events list
|
||||||
|
await fetchEvents()
|
||||||
|
|
||||||
|
showCreateModal.value = false
|
||||||
|
resetForm()
|
||||||
|
toast.success('Événement créé avec succès')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la création de l\'événement')
|
||||||
|
}
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
newEvent.value = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
date: '',
|
||||||
|
location: '',
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
end_date: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEvents() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/events?limit=12&offset=${offset.value}`)
|
||||||
|
if (offset.value === 0) {
|
||||||
|
events.value = response.data
|
||||||
|
} else {
|
||||||
|
events.value.push(...response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMoreEvents.value = response.data.length === 12
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors du chargement des événements')
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreEvents() {
|
||||||
|
offset.value += 12
|
||||||
|
await fetchEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchEvents()
|
||||||
|
fetchUsers()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
304
frontend/src/views/Home.vue
Normal file
304
frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Welcome Section -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
Salut {{ user?.full_name }} ! 👋
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600">Voici ce qui se passe dans le groupe</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Prochain événement</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">{{ nextEvent?.title || 'Aucun' }}</p>
|
||||||
|
<p v-if="nextEvent" class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ formatDate(nextEvent.date) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Calendar class="w-8 h-8 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Taux de présence</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">{{ Math.round(user?.attendance_rate || 0) }}%</p>
|
||||||
|
</div>
|
||||||
|
<TrendingUp class="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Nouveaux posts</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">{{ recentPosts }}</p>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Cette semaine</p>
|
||||||
|
</div>
|
||||||
|
<MessageSquare class="w-8 h-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Membres actifs</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">{{ activeMembers }}</p>
|
||||||
|
</div>
|
||||||
|
<Users class="w-8 h-8 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<!-- Recent Posts -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="p-6 border-b border-gray-100">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Publications récentes</h2>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
<div v-if="posts.length === 0" class="p-6 text-center text-gray-500">
|
||||||
|
Aucune publication récente
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="post in posts"
|
||||||
|
:key="post.id"
|
||||||
|
class="p-6 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-start space-x-3">
|
||||||
|
<img
|
||||||
|
v-if="post.author_avatar"
|
||||||
|
:src="getMediaUrl(post.author_avatar)"
|
||||||
|
:alt="post.author_name"
|
||||||
|
class="w-10 h-10 rounded-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<User class="w-5 h-5 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<p class="font-medium text-gray-900">{{ post.author_name }}</p>
|
||||||
|
<span class="text-xs text-gray-500">{{ formatRelativeDate(post.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-gray-700">
|
||||||
|
<Mentions :content="post.content" :mentions="post.mentioned_users || []" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Image -->
|
||||||
|
<img
|
||||||
|
v-if="post.image_url"
|
||||||
|
:src="getMediaUrl(post.image_url)"
|
||||||
|
:alt="post.content"
|
||||||
|
class="mt-3 rounded-lg max-w-full max-h-48 object-cover"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Post Actions -->
|
||||||
|
<div class="flex items-center space-x-4 mt-3 text-sm text-gray-500">
|
||||||
|
<button
|
||||||
|
@click="togglePostLike(post)"
|
||||||
|
class="flex items-center space-x-1 hover:text-primary-600 transition-colors"
|
||||||
|
:class="{ 'text-primary-600': post.is_liked }"
|
||||||
|
>
|
||||||
|
<Heart :class="post.is_liked ? 'fill-current' : ''" class="w-4 h-4" />
|
||||||
|
<span>{{ post.likes_count || 0 }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="toggleComments(post.id)"
|
||||||
|
class="flex items-center space-x-1 hover:text-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
<MessageCircle class="w-4 h-4" />
|
||||||
|
<span>{{ post.comments_count || 0 }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
to="/posts"
|
||||||
|
class="block p-4 text-center text-primary-600 hover:bg-gray-50 font-medium"
|
||||||
|
>
|
||||||
|
Voir toutes les publications →
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upcoming Events -->
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="p-6 border-b border-gray-100">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Événements à venir</h2>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
<div v-if="upcomingEvents.length === 0" class="p-6 text-center text-gray-500">
|
||||||
|
Aucun événement prévu
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
v-for="event in upcomingEvents"
|
||||||
|
:key="event.id"
|
||||||
|
:to="`/events/${event.id}`"
|
||||||
|
class="block p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<h3 class="font-medium text-gray-900">{{ event.title }}</h3>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">{{ formatDate(event.date) }}</p>
|
||||||
|
<p v-if="event.location" class="text-sm text-gray-500 mt-1">
|
||||||
|
📍 {{ event.location }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-3 flex items-center space-x-4 text-xs">
|
||||||
|
<span class="text-green-600">
|
||||||
|
✓ {{ event.present_count }} présents
|
||||||
|
</span>
|
||||||
|
<span class="text-yellow-600">
|
||||||
|
? {{ event.maybe_count }} peut-être
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
to="/events"
|
||||||
|
class="block p-4 text-center text-primary-600 hover:bg-gray-50 font-medium"
|
||||||
|
>
|
||||||
|
Voir tous les événements →
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Vlogs -->
|
||||||
|
<div class="card mt-6">
|
||||||
|
<div class="p-6 border-b border-gray-100">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Derniers vlogs</h2>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
<div v-if="recentVlogs.length === 0" class="p-6 text-center text-gray-500">
|
||||||
|
Aucun vlog récent
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
v-for="vlog in recentVlogs"
|
||||||
|
:key="vlog.id"
|
||||||
|
:to="`/vlogs/${vlog.id}`"
|
||||||
|
class="block p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="aspect-video bg-gray-100 rounded-lg mb-3 relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="vlog.thumbnail_url"
|
||||||
|
:src="getMediaUrl(vlog.thumbnail_url)"
|
||||||
|
:alt="vlog.title"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<Film class="w-8 h-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
||||||
|
<Play class="w-12 h-12 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-medium text-gray-900">{{ vlog.title }}</h3>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">Par {{ vlog.author_name }}</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{ vlog.views_count }} vues</p>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
to="/vlogs"
|
||||||
|
class="block p-4 text-center text-primary-600 hover:bg-gray-50 font-medium"
|
||||||
|
>
|
||||||
|
Voir tous les vlogs →
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
import { format, formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
TrendingUp,
|
||||||
|
MessageSquare,
|
||||||
|
Users,
|
||||||
|
User,
|
||||||
|
Film,
|
||||||
|
Play,
|
||||||
|
Heart,
|
||||||
|
MessageCircle
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import Mentions from '@/components/Mentions.vue'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const posts = ref([])
|
||||||
|
const upcomingEvents = ref([])
|
||||||
|
const recentVlogs = ref([])
|
||||||
|
const stats = ref({})
|
||||||
|
|
||||||
|
const user = computed(() => authStore.user)
|
||||||
|
const nextEvent = computed(() => upcomingEvents.value[0])
|
||||||
|
const recentPosts = computed(() => stats.value.recent_posts || 0)
|
||||||
|
const activeMembers = computed(() => stats.value.active_members || 0)
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return format(new Date(date), 'EEEE d MMMM à HH:mm', { locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeDate(date) {
|
||||||
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDashboardData() {
|
||||||
|
try {
|
||||||
|
// Fetch recent posts
|
||||||
|
const postsResponse = await axios.get('/api/posts?limit=5')
|
||||||
|
posts.value = postsResponse.data
|
||||||
|
|
||||||
|
// Fetch upcoming events
|
||||||
|
const eventsResponse = await axios.get('/api/events?upcoming=true')
|
||||||
|
upcomingEvents.value = eventsResponse.data.slice(0, 3)
|
||||||
|
|
||||||
|
// Fetch recent vlogs
|
||||||
|
const vlogsResponse = await axios.get('/api/vlogs?limit=2')
|
||||||
|
recentVlogs.value = vlogsResponse.data
|
||||||
|
|
||||||
|
// Fetch stats
|
||||||
|
const statsResponse = await axios.get('/api/stats/overview')
|
||||||
|
stats.value = statsResponse.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePostLike(post) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`/api/posts/${post.id}/like`)
|
||||||
|
post.is_liked = response.data.is_liked
|
||||||
|
post.likes_count = response.data.likes_count
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling like:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleComments(postId) {
|
||||||
|
const post = posts.value.find(p => p.id === postId)
|
||||||
|
if (post) {
|
||||||
|
post.showComments = !post.showComments
|
||||||
|
if (post.showComments && !post.comments) {
|
||||||
|
post.comments = []
|
||||||
|
post.newComment = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDashboardData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
177
frontend/src/views/Information.vue
Normal file
177
frontend/src/views/Information.vue
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 mb-4">Informations</h1>
|
||||||
|
<p class="text-lg text-gray-600">Restez informés des dernières nouvelles de LeDiscord</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Filter -->
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center mb-8">
|
||||||
|
<button
|
||||||
|
@click="selectedCategory = null"
|
||||||
|
:class="[
|
||||||
|
'px-4 py-2 rounded-full text-sm font-medium transition-colors',
|
||||||
|
selectedCategory === null
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Toutes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="category in availableCategories"
|
||||||
|
:key="category"
|
||||||
|
@click="selectedCategory = category"
|
||||||
|
:class="[
|
||||||
|
'px-4 py-2 rounded-full text-sm font-medium transition-colors',
|
||||||
|
selectedCategory === category
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getCategoryLabel(category) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading" class="text-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||||
|
<p class="text-gray-600">Chargement des informations...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Information -->
|
||||||
|
<div v-else-if="filteredInformations.length === 0" class="text-center py-12">
|
||||||
|
<div class="text-gray-400 mb-4">
|
||||||
|
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucune information</h3>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
{{ selectedCategory ? `Aucune information dans la catégorie "${getCategoryLabel(selectedCategory)}"` : 'Aucune information disponible pour le moment' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Information List -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<div
|
||||||
|
v-for="info in filteredInformations"
|
||||||
|
:key="info.id"
|
||||||
|
class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'px-3 py-1 rounded-full text-xs font-medium',
|
||||||
|
getCategoryBadgeClass(info.category)
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getCategoryLabel(info.category) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
{{ formatDate(info.created_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-2">{{ info.title }}</h2>
|
||||||
|
</div>
|
||||||
|
<div v-if="info.priority > 0" class="flex items-center gap-1 text-amber-600">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium">Important</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="prose prose-gray max-w-none">
|
||||||
|
<div class="whitespace-pre-wrap text-gray-700 leading-relaxed">{{ info.content }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm text-gray-500">
|
||||||
|
<span>Mis à jour le {{ formatDate(info.updated_at) }}</span>
|
||||||
|
<span v-if="!info.is_published" class="text-amber-600 font-medium">Brouillon</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const informations = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const selectedCategory = ref(null)
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const availableCategories = computed(() => {
|
||||||
|
const categories = [...new Set(informations.value.map(info => info.category))]
|
||||||
|
return categories.sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredInformations = computed(() => {
|
||||||
|
if (!selectedCategory.value) {
|
||||||
|
return informations.value
|
||||||
|
}
|
||||||
|
return informations.value.filter(info => info.category === selectedCategory.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
function getCategoryLabel(category) {
|
||||||
|
const labels = {
|
||||||
|
'general': 'Général',
|
||||||
|
'release': 'Nouvelle version',
|
||||||
|
'upcoming': 'À venir',
|
||||||
|
'maintenance': 'Maintenance',
|
||||||
|
'feature': 'Nouvelle fonctionnalité',
|
||||||
|
'bugfix': 'Correction de bug'
|
||||||
|
}
|
||||||
|
return labels[category] || category
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryBadgeClass(category) {
|
||||||
|
const classes = {
|
||||||
|
'general': 'bg-gray-100 text-gray-800',
|
||||||
|
'release': 'bg-green-100 text-green-800',
|
||||||
|
'upcoming': 'bg-blue-100 text-blue-800',
|
||||||
|
'maintenance': 'bg-yellow-100 text-yellow-800',
|
||||||
|
'feature': 'bg-purple-100 text-purple-800',
|
||||||
|
'bugfix': 'bg-red-100 text-red-800'
|
||||||
|
}
|
||||||
|
return classes[category] || 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchInformations() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await axios.get('/api/information/public')
|
||||||
|
informations.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching informations:', error)
|
||||||
|
toast.error('Erreur lors du chargement des informations')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchInformations()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
89
frontend/src/views/Login.vue
Normal file
89
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-6">Connexion</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="label">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
placeholder="ton.email@example.com"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="label">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
placeholder="••••••••"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full btn-primary"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Connexion...' : 'Se connecter' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<p v-if="registrationEnabled" class="text-sm text-gray-600">
|
||||||
|
Pas encore de compte ?
|
||||||
|
<router-link to="/register" class="font-medium text-primary-600 hover:text-primary-500">
|
||||||
|
S'inscrire
|
||||||
|
</router-link>
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-gray-500">
|
||||||
|
Les nouvelles inscriptions sont temporairement désactivées
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const registrationEnabled = ref(true)
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
loading.value = true
|
||||||
|
await authStore.login(form.value.email, form.value.password)
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRegistrationStatus() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/settings/public/registration-status')
|
||||||
|
const status = response.data
|
||||||
|
registrationEnabled.value = status.can_register
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking registration status:', error)
|
||||||
|
// En cas d'erreur, on active l'inscription par défaut
|
||||||
|
registrationEnabled.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkRegistrationStatus()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
533
frontend/src/views/MyTickets.vue
Normal file
533
frontend/src/views/MyTickets.vue
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 mb-4">Mes tickets</h1>
|
||||||
|
<p class="text-lg text-gray-600">Suivez vos demandes et signalements</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-blue-600">{{ ticketStats.open }}</div>
|
||||||
|
<div class="text-sm text-gray-600">Ouverts</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-yellow-600">{{ ticketStats.in_progress }}</div>
|
||||||
|
<div class="text-sm text-gray-600">En cours</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-green-600">{{ ticketStats.resolved }}</div>
|
||||||
|
<div class="text-sm text-gray-600">Résolus</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-gray-600">{{ ticketStats.total }}</div>
|
||||||
|
<div class="text-sm text-gray-600">Total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center mb-6">
|
||||||
|
<button
|
||||||
|
@click="selectedStatus = null"
|
||||||
|
:class="[
|
||||||
|
'px-4 py-2 rounded-full text-sm font-medium transition-colors',
|
||||||
|
selectedStatus === null
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Tous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="status in availableStatuses"
|
||||||
|
:key="status"
|
||||||
|
@click="selectedStatus = status"
|
||||||
|
:class="[
|
||||||
|
'px-4 py-2 rounded-full text-sm font-medium transition-colors',
|
||||||
|
selectedStatus === status
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getStatusLabel(status) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading" class="text-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||||
|
<p class="text-gray-600">Chargement de vos tickets...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Tickets -->
|
||||||
|
<div v-else-if="filteredTickets.length === 0" class="text-center py-12">
|
||||||
|
<div class="text-gray-400 mb-4">
|
||||||
|
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun ticket</h3>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
{{ selectedStatus ? `Aucun ticket avec le statut "${getStatusLabel(selectedStatus)}"` : 'Vous n\'avez pas encore créé de ticket' }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
@click="showTicketModal = true"
|
||||||
|
class="mt-4 btn-primary"
|
||||||
|
>
|
||||||
|
Créer mon premier ticket
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tickets List -->
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="ticket in filteredTickets"
|
||||||
|
:key="ticket.id"
|
||||||
|
class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'px-3 py-1 rounded-full text-xs font-medium',
|
||||||
|
getTypeBadgeClass(ticket.ticket_type)
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getTypeLabel(ticket.ticket_type) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'px-3 py-1 rounded-full text-xs font-medium',
|
||||||
|
getStatusBadgeClass(ticket.status)
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getStatusLabel(ticket.status) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'px-3 py-1 rounded-full text-xs font-medium',
|
||||||
|
getPriorityBadgeClass(ticket.priority)
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getPriorityLabel(ticket.priority) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
{{ formatDate(ticket.created_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-2">{{ ticket.title }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button
|
||||||
|
@click="editTicket(ticket)"
|
||||||
|
class="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="deleteTicket(ticket.id)"
|
||||||
|
class="text-red-600 hover:text-red-800 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="prose prose-gray max-w-none mb-4">
|
||||||
|
<div class="whitespace-pre-wrap text-gray-700 leading-relaxed">{{ ticket.description }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshot -->
|
||||||
|
<div v-if="ticket.screenshot_path" class="mb-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Screenshot :</h4>
|
||||||
|
<img
|
||||||
|
:src="getMediaUrl(ticket.screenshot_path)"
|
||||||
|
:alt="ticket.title"
|
||||||
|
class="max-w-md rounded-lg border border-gray-200 cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
@click="viewScreenshot(ticket.screenshot_path)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Notes -->
|
||||||
|
<div v-if="ticket.admin_notes" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<h4 class="text-sm font-medium text-blue-800 mb-2">Réponse de l'équipe :</h4>
|
||||||
|
<div class="text-sm text-blue-700 whitespace-pre-wrap">{{ ticket.admin_notes }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm text-gray-500">
|
||||||
|
<span>Mis à jour le {{ formatDate(ticket.updated_at) }}</span>
|
||||||
|
<span v-if="ticket.assigned_admin_name" class="text-blue-600">
|
||||||
|
Assigné à {{ ticket.assigned_admin_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ticket Modal -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showTicketModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold">
|
||||||
|
{{ editingTicket ? 'Modifier le ticket' : 'Nouveau ticket' }}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
@click="showTicketModal = false"
|
||||||
|
class="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="saveTicket" class="space-y-6">
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<label class="label">Titre *</label>
|
||||||
|
<input
|
||||||
|
v-model="ticketForm.title"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
required
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="Titre de votre ticket"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type -->
|
||||||
|
<div>
|
||||||
|
<label class="label">Type</label>
|
||||||
|
<select v-model="ticketForm.ticket_type" class="input">
|
||||||
|
<option value="bug">🐛 Bug</option>
|
||||||
|
<option value="feature_request">💡 Demande de fonctionnalité</option>
|
||||||
|
<option value="improvement">✨ Amélioration</option>
|
||||||
|
<option value="support">❓ Support</option>
|
||||||
|
<option value="other">📝 Autre</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Priority -->
|
||||||
|
<div>
|
||||||
|
<label class="label">Priorité</label>
|
||||||
|
<select v-model="ticketForm.priority" class="input">
|
||||||
|
<option value="low">🟢 Faible</option>
|
||||||
|
<option value="medium">🟡 Moyenne</option>
|
||||||
|
<option value="high">🟠 Élevée</option>
|
||||||
|
<option value="urgent">🔴 Urgente</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<label class="label">Description *</label>
|
||||||
|
<textarea
|
||||||
|
v-model="ticketForm.description"
|
||||||
|
class="input resize-none"
|
||||||
|
rows="6"
|
||||||
|
required
|
||||||
|
placeholder="Décrivez votre problème ou votre demande en détail..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshot -->
|
||||||
|
<div>
|
||||||
|
<label class="label">Screenshot (optionnel)</label>
|
||||||
|
<input
|
||||||
|
ref="screenshotInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="input"
|
||||||
|
@change="handleScreenshotChange"
|
||||||
|
>
|
||||||
|
<div class="mt-1 text-sm text-gray-600">
|
||||||
|
Formats acceptés : JPG, PNG, GIF, WebP (max 5MB)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showTicketModal = false"
|
||||||
|
class="flex-1 btn-secondary"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex-1 btn-primary"
|
||||||
|
:disabled="savingTicket"
|
||||||
|
>
|
||||||
|
<Save class="w-4 h-4 mr-2" />
|
||||||
|
{{ savingTicket ? 'Sauvegarde...' : 'Sauvegarder' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Screenshot Viewer Modal -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showScreenshotModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4"
|
||||||
|
@click="showScreenshotModal = false"
|
||||||
|
>
|
||||||
|
<div class="max-w-4xl max-h-[90vh] overflow-auto">
|
||||||
|
<img
|
||||||
|
:src="selectedScreenshot"
|
||||||
|
alt="Screenshot"
|
||||||
|
class="max-w-full h-auto rounded-lg"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import { Save } from 'lucide-vue-next'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const tickets = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const selectedStatus = ref(null)
|
||||||
|
const showTicketModal = ref(false)
|
||||||
|
const editingTicket = ref(null)
|
||||||
|
const savingTicket = ref(false)
|
||||||
|
const showScreenshotModal = ref(false)
|
||||||
|
const selectedScreenshot = ref('')
|
||||||
|
const screenshotInput = ref(null)
|
||||||
|
|
||||||
|
const ticketForm = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
ticket_type: 'other',
|
||||||
|
priority: 'medium'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const availableStatuses = computed(() => {
|
||||||
|
const statuses = [...new Set(tickets.value.map(ticket => ticket.status))]
|
||||||
|
return statuses.sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredTickets = computed(() => {
|
||||||
|
if (!selectedStatus.value) {
|
||||||
|
return tickets.value
|
||||||
|
}
|
||||||
|
return tickets.value.filter(ticket => ticket.status === selectedStatus.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ticketStats = computed(() => {
|
||||||
|
const stats = {
|
||||||
|
open: tickets.value.filter(t => t.status === 'open').length,
|
||||||
|
in_progress: tickets.value.filter(t => t.status === 'in_progress').length,
|
||||||
|
resolved: tickets.value.filter(t => t.status === 'resolved').length,
|
||||||
|
total: tickets.value.length
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
function getTypeLabel(type) {
|
||||||
|
const labels = {
|
||||||
|
'bug': 'Bug',
|
||||||
|
'feature_request': 'Fonctionnalité',
|
||||||
|
'improvement': 'Amélioration',
|
||||||
|
'support': 'Support',
|
||||||
|
'other': 'Autre'
|
||||||
|
}
|
||||||
|
return labels[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status) {
|
||||||
|
const labels = {
|
||||||
|
'open': 'Ouvert',
|
||||||
|
'in_progress': 'En cours',
|
||||||
|
'resolved': 'Résolu',
|
||||||
|
'closed': 'Fermé'
|
||||||
|
}
|
||||||
|
return labels[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityLabel(priority) {
|
||||||
|
const labels = {
|
||||||
|
'low': 'Faible',
|
||||||
|
'medium': 'Moyenne',
|
||||||
|
'high': 'Élevée',
|
||||||
|
'urgent': 'Urgente'
|
||||||
|
}
|
||||||
|
return labels[priority] || priority
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeBadgeClass(type) {
|
||||||
|
const classes = {
|
||||||
|
'bug': 'bg-red-100 text-red-800',
|
||||||
|
'feature_request': 'bg-blue-100 text-blue-800',
|
||||||
|
'improvement': 'bg-green-100 text-green-800',
|
||||||
|
'support': 'bg-yellow-100 text-yellow-800',
|
||||||
|
'other': 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
return classes[type] || 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status) {
|
||||||
|
const classes = {
|
||||||
|
'open': 'bg-blue-100 text-blue-800',
|
||||||
|
'in_progress': 'bg-yellow-100 text-yellow-800',
|
||||||
|
'resolved': 'bg-green-100 text-green-800',
|
||||||
|
'closed': 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
return classes[status] || 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityBadgeClass(priority) {
|
||||||
|
const classes = {
|
||||||
|
'low': 'bg-green-100 text-green-800',
|
||||||
|
'medium': 'bg-yellow-100 text-yellow-800',
|
||||||
|
'high': 'bg-orange-100 text-orange-800',
|
||||||
|
'urgent': 'bg-red-100 text-red-800'
|
||||||
|
}
|
||||||
|
return classes[priority] || 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTicketForm() {
|
||||||
|
ticketForm.value = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
ticket_type: 'other',
|
||||||
|
priority: 'medium'
|
||||||
|
}
|
||||||
|
editingTicket.value = null
|
||||||
|
if (screenshotInput.value) {
|
||||||
|
screenshotInput.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTicket(ticket) {
|
||||||
|
editingTicket.value = ticket
|
||||||
|
ticketForm.value = {
|
||||||
|
title: ticket.title,
|
||||||
|
description: ticket.description,
|
||||||
|
ticket_type: ticket.ticket_type,
|
||||||
|
priority: ticket.priority
|
||||||
|
}
|
||||||
|
showTicketModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScreenshotChange(event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (file && file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error('Le fichier est trop volumineux (max 5MB)')
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewScreenshot(screenshotPath) {
|
||||||
|
selectedScreenshot.value = getMediaUrl(screenshotPath)
|
||||||
|
showScreenshotModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTicket() {
|
||||||
|
savingTicket.value = true
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('title', ticketForm.value.title)
|
||||||
|
formData.append('description', ticketForm.value.description)
|
||||||
|
formData.append('ticket_type', ticketForm.value.ticket_type)
|
||||||
|
formData.append('priority', ticketForm.value.priority)
|
||||||
|
|
||||||
|
if (screenshotInput.value && screenshotInput.value.files[0]) {
|
||||||
|
formData.append('screenshot', screenshotInput.value.files[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingTicket.value) {
|
||||||
|
// Update existing ticket
|
||||||
|
await axios.put(`/api/tickets/${editingTicket.value.id}`, ticketForm.value)
|
||||||
|
toast.success('Ticket mis à jour')
|
||||||
|
} else {
|
||||||
|
// Create new ticket
|
||||||
|
await axios.post('/api/tickets/', formData)
|
||||||
|
toast.success('Ticket créé avec succès')
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchTickets()
|
||||||
|
showTicketModal.value = false
|
||||||
|
resetTicketForm()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la sauvegarde')
|
||||||
|
console.error('Error saving ticket:', error)
|
||||||
|
} finally {
|
||||||
|
savingTicket.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTicket(ticketId) {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/tickets/${ticketId}`)
|
||||||
|
await fetchTickets()
|
||||||
|
toast.success('Ticket supprimé')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la suppression')
|
||||||
|
console.error('Error deleting ticket:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTickets() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await axios.get('/api/tickets/')
|
||||||
|
tickets.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tickets:', error)
|
||||||
|
toast.error('Erreur lors du chargement des tickets')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTickets()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
521
frontend/src/views/Posts.vue
Normal file
521
frontend/src/views/Posts.vue
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Publications</h1>
|
||||||
|
<p class="text-gray-600 mt-1">Partagez vos moments avec le groupe</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
|
Nouvelle publication
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Post Form -->
|
||||||
|
<div class="card p-6 mb-8">
|
||||||
|
<div class="flex items-start space-x-3">
|
||||||
|
<img
|
||||||
|
v-if="user?.avatar_url"
|
||||||
|
:src="getMediaUrl(user.avatar_url)"
|
||||||
|
:alt="user?.full_name"
|
||||||
|
class="w-10 h-10 rounded-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<User class="w-5 h-5 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<MentionInput
|
||||||
|
v-model="newPost.content"
|
||||||
|
:users="users"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="Quoi de neuf ? Mentionnez des amis avec @..."
|
||||||
|
@mentions-changed="handleMentionsChanged"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-3">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
@click="showImageUpload = !showImageUpload"
|
||||||
|
class="text-gray-500 hover:text-primary-600 transition-colors"
|
||||||
|
title="Ajouter une image"
|
||||||
|
>
|
||||||
|
<Image class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-500">{{ newPost.content.length }}/5000</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="createPost"
|
||||||
|
:disabled="!newPost.content.trim() || creating"
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
{{ creating ? 'Publication...' : 'Publier' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image upload -->
|
||||||
|
<div v-if="showImageUpload" class="mt-3">
|
||||||
|
<input
|
||||||
|
ref="imageInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleImageChange"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="$refs.imageInput.click()"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
<Upload class="w-4 h-4 mr-2" />
|
||||||
|
Sélectionner une image
|
||||||
|
</button>
|
||||||
|
<div v-if="newPost.image_url" class="mt-2 relative inline-block">
|
||||||
|
<img
|
||||||
|
:src="getMediaUrl(newPost.image_url)"
|
||||||
|
:alt="newPost.content"
|
||||||
|
class="max-w-xs max-h-32 rounded-lg object-cover"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="removeImage"
|
||||||
|
class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Posts List -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div
|
||||||
|
v-for="post in posts"
|
||||||
|
:key="post.id"
|
||||||
|
class="card p-6"
|
||||||
|
>
|
||||||
|
<!-- Post Header -->
|
||||||
|
<div class="flex items-start space-x-3 mb-4">
|
||||||
|
<img
|
||||||
|
v-if="post.author_avatar"
|
||||||
|
:src="getMediaUrl(post.author_avatar)"
|
||||||
|
:alt="post.author_name"
|
||||||
|
class="w-10 h-10 rounded-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<User class="w-5 h-5 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<router-link
|
||||||
|
:to="`/profile/${post.author_id}`"
|
||||||
|
class="font-medium text-gray-900 hover:text-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
{{ post.author_name }}
|
||||||
|
</router-link>
|
||||||
|
<span class="text-sm text-gray-500">{{ formatRelativeDate(post.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mentions -->
|
||||||
|
<div v-if="post.mentioned_users && post.mentioned_users.length > 0" class="mt-1">
|
||||||
|
<span class="text-xs text-gray-500">Mentionne : </span>
|
||||||
|
<span
|
||||||
|
v-for="mentionedUser in post.mentioned_users"
|
||||||
|
:key="mentionedUser.id"
|
||||||
|
class="text-xs text-primary-600 hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
@{{ mentionedUser.username }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete button for author or admin -->
|
||||||
|
<button
|
||||||
|
v-if="canDeletePost(post)"
|
||||||
|
@click="deletePost(post.id)"
|
||||||
|
class="text-gray-400 hover:text-red-600 transition-colors"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Content -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<Mentions :content="post.content" :mentions="post.mentioned_users" />
|
||||||
|
|
||||||
|
<!-- Post Image -->
|
||||||
|
<img
|
||||||
|
v-if="post.image_url"
|
||||||
|
:src="getMediaUrl(post.image_url)"
|
||||||
|
:alt="post.content"
|
||||||
|
class="mt-3 rounded-lg max-w-full max-h-96 object-cover"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Actions -->
|
||||||
|
<div class="flex items-center space-x-6 text-sm text-gray-500">
|
||||||
|
<button
|
||||||
|
@click="togglePostLike(post)"
|
||||||
|
class="flex items-center space-x-2 hover:text-primary-600 transition-colors"
|
||||||
|
:class="{ 'text-primary-600': post.is_liked }"
|
||||||
|
>
|
||||||
|
<Heart :class="post.is_liked ? 'fill-current' : ''" class="w-4 h-4" />
|
||||||
|
<span>{{ post.likes_count || 0 }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="toggleComments(post.id)"
|
||||||
|
class="flex items-center space-x-2 hover:text-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
<MessageCircle class="w-4 h-4" />
|
||||||
|
<span>{{ post.comments_count || 0 }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments Section -->
|
||||||
|
<div v-if="post.showComments" class="mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<!-- Add Comment -->
|
||||||
|
<div class="flex items-start space-x-3 mb-4">
|
||||||
|
<img
|
||||||
|
v-if="user?.avatar_url"
|
||||||
|
:src="getMediaUrl(user.avatar_url)"
|
||||||
|
:alt="user?.full_name"
|
||||||
|
class="w-8 h-8 rounded-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<User class="w-4 h-4 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<MentionInput
|
||||||
|
v-model="post.newComment"
|
||||||
|
:users="users"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="Ajouter un commentaire... (utilisez @username pour mentionner)"
|
||||||
|
@mentions-changed="(mentions) => handleCommentMentionsChanged(post.id, mentions)"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center justify-between mt-2">
|
||||||
|
<span class="text-xs text-gray-500">{{ (post.newComment || '').length }}/500</span>
|
||||||
|
<button
|
||||||
|
@click="addComment(post)"
|
||||||
|
:disabled="!post.newComment?.trim()"
|
||||||
|
class="btn-primary text-sm px-3 py-1"
|
||||||
|
>
|
||||||
|
Commenter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments List -->
|
||||||
|
<div v-if="post.comments && post.comments.length > 0" class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="comment in post.comments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="comment.author_avatar"
|
||||||
|
:src="getMediaUrl(comment.author_avatar)"
|
||||||
|
:alt="comment.author_name"
|
||||||
|
class="w-6 h-6 rounded-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center">
|
||||||
|
<User class="w-3 h-3 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<router-link
|
||||||
|
:to="`/profile/${comment.author_id}`"
|
||||||
|
class="font-medium text-sm text-gray-900 hover:text-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
{{ comment.author_name }}
|
||||||
|
</router-link>
|
||||||
|
<span class="text-xs text-gray-500">{{ formatRelativeDate(comment.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<Mentions :content="comment.content" :mentions="comment.mentioned_users || []" class="text-sm text-gray-700 mt-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete comment button -->
|
||||||
|
<button
|
||||||
|
v-if="canDeleteComment(comment)"
|
||||||
|
@click="deleteComment(post.id, comment.id)"
|
||||||
|
class="text-gray-400 hover:text-red-600 transition-colors"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<X class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load more -->
|
||||||
|
<div v-if="hasMorePosts" class="text-center mt-8">
|
||||||
|
<button
|
||||||
|
@click="loadMorePosts"
|
||||||
|
:disabled="loading"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="posts.length === 0 && !loading" class="text-center py-12">
|
||||||
|
<MessageSquare class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucune publication</h3>
|
||||||
|
<p class="text-gray-600">Soyez le premier à partager quelque chose !</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
User,
|
||||||
|
Image,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
Heart,
|
||||||
|
MessageCircle,
|
||||||
|
Trash2,
|
||||||
|
MessageSquare
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import Mentions from '@/components/Mentions.vue'
|
||||||
|
import MentionInput from '@/components/MentionInput.vue'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const user = computed(() => authStore.user)
|
||||||
|
const posts = ref([])
|
||||||
|
const users = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const showImageUpload = ref(false)
|
||||||
|
|
||||||
|
const offset = ref(0)
|
||||||
|
const hasMorePosts = ref(true)
|
||||||
|
|
||||||
|
const newPost = ref({
|
||||||
|
content: '',
|
||||||
|
image_url: '',
|
||||||
|
mentioned_user_ids: []
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatRelativeDate(date) {
|
||||||
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMentionsChanged(mentions) {
|
||||||
|
newPost.value.mentioned_user_ids = mentions.map(m => m.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommentMentionsChanged(postId, mentions) {
|
||||||
|
const post = posts.value.find(p => p.id === postId)
|
||||||
|
if (post) {
|
||||||
|
post.commentMentions = mentions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function createPost() {
|
||||||
|
if (!newPost.value.content.trim()) return
|
||||||
|
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/posts', {
|
||||||
|
content: newPost.value.content,
|
||||||
|
image_url: newPost.value.image_url,
|
||||||
|
mentioned_user_ids: newPost.value.mentioned_user_ids
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add new post to the beginning of the list
|
||||||
|
posts.value.unshift(response.data)
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
newPost.value = {
|
||||||
|
content: '',
|
||||||
|
image_url: '',
|
||||||
|
mentioned_user_ids: []
|
||||||
|
}
|
||||||
|
showImageUpload.value = false
|
||||||
|
|
||||||
|
toast.success('Publication créée avec succès')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la création de la publication')
|
||||||
|
}
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePostLike(post) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`/api/posts/${post.id}/like`)
|
||||||
|
post.is_liked = response.data.is_liked
|
||||||
|
post.likes_count = response.data.likes_count
|
||||||
|
toast.success(post.is_liked ? 'Post liké' : 'Like retiré')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour du like')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleComments(postId) {
|
||||||
|
const post = posts.value.find(p => p.id === postId)
|
||||||
|
if (post) {
|
||||||
|
post.showComments = !post.showComments
|
||||||
|
if (post.showComments && !post.comments) {
|
||||||
|
post.comments = []
|
||||||
|
post.newComment = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addComment(post) {
|
||||||
|
if (!post.newComment?.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`/api/posts/${post.id}/comment`, {
|
||||||
|
content: post.newComment.trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add new comment to the post
|
||||||
|
if (!post.comments) post.comments = []
|
||||||
|
post.comments.push(response.data.comment)
|
||||||
|
post.comments_count = (post.comments_count || 0) + 1
|
||||||
|
|
||||||
|
// Reset comment input
|
||||||
|
post.newComment = ''
|
||||||
|
|
||||||
|
toast.success('Commentaire ajouté')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de l\'ajout du commentaire')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComment(postId, commentId) {
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/posts/${postId}/comment/${commentId}`)
|
||||||
|
|
||||||
|
const post = posts.value.find(p => p.id === postId)
|
||||||
|
if (post && post.comments) {
|
||||||
|
post.comments = post.comments.filter(c => c.id !== commentId)
|
||||||
|
post.comments_count = Math.max(0, (post.comments_count || 1) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Commentaire supprimé')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la suppression du commentaire')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImageChange(event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error('Veuillez sélectionner une image')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error('L\'image est trop volumineuse (max 5MB)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
const response = await axios.post('/api/posts/upload-image', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
|
||||||
|
newPost.value.image_url = response.data.image_url
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de l\'upload de l\'image')
|
||||||
|
}
|
||||||
|
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage() {
|
||||||
|
newPost.value.image_url = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPosts() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/posts?limit=10&offset=${offset.value}`)
|
||||||
|
if (offset.value === 0) {
|
||||||
|
posts.value = response.data
|
||||||
|
} else {
|
||||||
|
posts.value.push(...response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMorePosts.value = response.data.length === 10
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors du chargement des publications')
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsers() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/users')
|
||||||
|
users.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMorePosts() {
|
||||||
|
offset.value += 10
|
||||||
|
await fetchPosts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePost(postId) {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir supprimer cette publication ?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/posts/${postId}`)
|
||||||
|
posts.value = posts.value.filter(p => p.id !== postId)
|
||||||
|
toast.success('Publication supprimée')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la suppression')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDeletePost(post) {
|
||||||
|
return user.value && (post.author_id === user.value.id || user.value.is_admin)
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDeleteComment(comment) {
|
||||||
|
return user.value && (comment.author_id === user.value.id || user.value.is_admin)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchPosts()
|
||||||
|
fetchUsers()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
253
frontend/src/views/Profile.vue
Normal file
253
frontend/src/views/Profile.vue
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-8">Mon profil</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<!-- Avatar Section -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="relative inline-block">
|
||||||
|
<img
|
||||||
|
v-if="user?.avatar_url"
|
||||||
|
:src="getMediaUrl(user.avatar_url)"
|
||||||
|
:alt="user?.full_name"
|
||||||
|
class="w-32 h-32 rounded-full object-cover border-4 border-white shadow-lg"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="w-32 h-32 rounded-full bg-primary-100 flex items-center justify-center border-4 border-white shadow-lg"
|
||||||
|
>
|
||||||
|
<User class="w-16 h-16 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Button Overlay -->
|
||||||
|
<button
|
||||||
|
@click="$refs.avatarInput.click()"
|
||||||
|
class="absolute bottom-0 right-0 bg-primary-600 text-white p-2 rounded-full shadow-lg hover:bg-primary-700 transition-colors"
|
||||||
|
title="Changer l'avatar"
|
||||||
|
>
|
||||||
|
<Camera class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="avatarInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleAvatarChange"
|
||||||
|
>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mt-4">{{ user?.full_name }}</h2>
|
||||||
|
<p class="text-gray-600">@{{ user?.username }}</p>
|
||||||
|
|
||||||
|
<div class="mt-4 text-sm text-gray-500">
|
||||||
|
<p>Membre depuis {{ formatDate(user?.created_at) }}</p>
|
||||||
|
<p>Taux de présence : {{ Math.round(user?.attendance_rate || 0) }}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Form -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations personnelles</h3>
|
||||||
|
|
||||||
|
<form @submit.prevent="updateProfile" class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="label">Nom complet</label>
|
||||||
|
<input
|
||||||
|
v-model="form.full_name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
placeholder="Prénom Nom"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Nom d'utilisateur</label>
|
||||||
|
<input
|
||||||
|
v-model="form.username"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
class="input bg-gray-50"
|
||||||
|
title="Le nom d'utilisateur ne peut pas être modifié"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Email</label>
|
||||||
|
<input
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
disabled
|
||||||
|
class="input bg-gray-50"
|
||||||
|
title="L'email ne peut pas être modifié"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Bio</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.bio"
|
||||||
|
rows="4"
|
||||||
|
class="input"
|
||||||
|
placeholder="Parlez-nous un peu de vous..."
|
||||||
|
maxlength="500"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{ (form.bio || '').length }}/500 caractères</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="updating"
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
{{ updating ? 'Mise à jour...' : 'Mettre à jour' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="resetForm"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Réinitialiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Section -->
|
||||||
|
<div class="card p-6 mt-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-6">Mes statistiques</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div class="text-2xl font-bold text-primary-600">{{ stats.posts_count || 0 }}</div>
|
||||||
|
<div class="text-sm text-gray-600">Publications</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div class="text-2xl font-bold text-green-600">{{ stats.vlogs_count || 0 }}</div>
|
||||||
|
<div class="text-sm text-gray-600">Vlogs</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div class="text-2xl font-bold text-blue-600">{{ stats.albums_count || 0 }}</div>
|
||||||
|
<div class="text-sm text-gray-600">Albums</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div class="text-2xl font-bold text-purple-600">{{ stats.events_created || 0 }}</div>
|
||||||
|
<div class="text-sm text-gray-600">Événements créés</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import { User, Camera } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const user = computed(() => authStore.user)
|
||||||
|
const updating = ref(false)
|
||||||
|
const stats = ref({})
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
full_name: '',
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
bio: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
if (!date) return ''
|
||||||
|
return format(new Date(date), 'MMMM yyyy', { locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.value = {
|
||||||
|
full_name: user.value?.full_name || '',
|
||||||
|
username: user.value?.username || '',
|
||||||
|
email: user.value?.email || '',
|
||||||
|
bio: user.value?.bio || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfile() {
|
||||||
|
updating.value = true
|
||||||
|
try {
|
||||||
|
const result = await authStore.updateProfile({
|
||||||
|
full_name: form.value.full_name,
|
||||||
|
bio: form.value.bio
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Profil mis à jour avec succès')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour du profil')
|
||||||
|
}
|
||||||
|
updating.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAvatarChange(event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error('Veuillez sélectionner une image')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error('L\'image est trop volumineuse (max 5MB)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authStore.uploadAvatar(file)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Avatar mis à jour avec succès')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de l\'upload de l\'avatar')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset input
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserStats() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/stats/user/${user.value.id}`)
|
||||||
|
stats.value = response.data.content_stats
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user stats:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resetForm()
|
||||||
|
fetchUserStats()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
190
frontend/src/views/Register.vue
Normal file
190
frontend/src/views/Register.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-6">Inscription</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleRegister" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="label">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
:disabled="!registrationEnabled"
|
||||||
|
class="input"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||||
|
placeholder="ton.email@example.com"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="username" class="label">Nom d'utilisateur</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
v-model="form.username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
maxlength="50"
|
||||||
|
:disabled="!registrationEnabled"
|
||||||
|
class="input"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||||
|
placeholder="tonpseudo"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="full_name" class="label">Nom complet</label>
|
||||||
|
<input
|
||||||
|
id="full_name"
|
||||||
|
v-model="form.full_name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
:disabled="!registrationEnabled"
|
||||||
|
class="input"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||||
|
placeholder="Prénom Nom"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="label">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
:disabled="!registrationEnabled"
|
||||||
|
class="input"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||||
|
placeholder="••••••••"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password_confirm" class="label">Confirmer le mot de passe</label>
|
||||||
|
<input
|
||||||
|
id="password_confirm"
|
||||||
|
v-model="form.password_confirm"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
:disabled="!registrationEnabled"
|
||||||
|
class="input"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||||
|
placeholder="••••••••"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="text-red-600 text-sm">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading || !registrationEnabled"
|
||||||
|
class="w-full btn-primary"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Inscription...' : !registrationEnabled ? 'Inscriptions désactivées' : 'S\'inscrire' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Déjà un compte ?
|
||||||
|
<router-link to="/login" class="font-medium text-primary-600 hover:text-primary-500">
|
||||||
|
Se connecter
|
||||||
|
</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vérification du statut d'inscription -->
|
||||||
|
<div v-if="!registrationEnabled" class="mt-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-yellow-800">Inscriptions désactivées</h3>
|
||||||
|
<p class="text-sm text-yellow-700 mt-1">
|
||||||
|
Les nouvelles inscriptions sont temporairement désactivées. Veuillez contacter l'administrateur.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
email: '',
|
||||||
|
username: '',
|
||||||
|
full_name: '',
|
||||||
|
password: '',
|
||||||
|
password_confirm: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const registrationEnabled = ref(true)
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (!registrationEnabled.value) {
|
||||||
|
error.value = 'Les inscriptions sont actuellement désactivées'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.value.password !== form.value.password_confirm) {
|
||||||
|
error.value = 'Les mots de passe ne correspondent pas'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
const result = await authStore.register({
|
||||||
|
email: form.value.email,
|
||||||
|
username: form.value.username,
|
||||||
|
full_name: form.value.full_name,
|
||||||
|
password: form.value.password
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
error.value = result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRegistrationStatus() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/settings/public/registration-status')
|
||||||
|
const status = response.data
|
||||||
|
registrationEnabled.value = status.can_register
|
||||||
|
|
||||||
|
// Afficher des informations supplémentaires si l'inscription est désactivée
|
||||||
|
if (!status.registration_enabled) {
|
||||||
|
console.log('Registration disabled by admin')
|
||||||
|
} else if (status.current_users_count >= status.max_users) {
|
||||||
|
console.log(`Maximum users reached: ${status.current_users_count}/${status.max_users}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking registration status:', error)
|
||||||
|
// En cas d'erreur, on désactive l'inscription pour éviter les problèmes de sécurité
|
||||||
|
registrationEnabled.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkRegistrationStatus()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
307
frontend/src/views/Stats.vue
Normal file
307
frontend/src/views/Stats.vue
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-8">Statistiques du groupe</h1>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="text-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||||
|
<p class="mt-4 text-gray-600">Chargement des statistiques...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats content -->
|
||||||
|
<div v-else>
|
||||||
|
<!-- Overview Stats -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Membres actifs</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">{{ overview.total_users }}</p>
|
||||||
|
</div>
|
||||||
|
<Users class="w-8 h-8 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Événements</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">{{ overview.total_events }}</p>
|
||||||
|
</div>
|
||||||
|
<Calendar class="w-8 h-8 text-success-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Albums</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">{{ overview.total_albums }}</p>
|
||||||
|
</div>
|
||||||
|
<Image class="w-8 h-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Vlogs</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">{{ overview.total_vlogs }}</p>
|
||||||
|
</div>
|
||||||
|
<Film class="w-8 h-8 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<!-- Attendance Stats -->
|
||||||
|
<div class="card p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-6">Taux de présence</h2>
|
||||||
|
|
||||||
|
<div v-if="attendanceStats.attendance_stats.length === 0" class="text-center py-8 text-gray-500">
|
||||||
|
Aucune donnée de présence disponible
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="(user, index) in attendanceStats.attendance_stats.slice(0, 5)"
|
||||||
|
:key="user.user_id"
|
||||||
|
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center text-sm font-medium text-primary-600">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
|
<UserAvatar :user="user" size="md" :show-user-info="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-lg font-bold text-primary-600">{{ Math.round(user.attendance_rate) }}%</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ user.present_count }}/{{ user.total_past_events }} événements</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="attendanceStats.best_attendee" class="mt-4 p-3 bg-success-50 rounded-lg border border-success-200">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Trophy class="w-5 h-5 text-success-600" />
|
||||||
|
<span class="text-sm font-medium text-success-800">
|
||||||
|
🏆 {{ attendanceStats.best_attendee.full_name }} est le plus assidu avec {{ Math.round(attendanceStats.best_attendee.attendance_rate) }}% de présence !
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fun Stats -->
|
||||||
|
<div class="card p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-6">Statistiques fun</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Most Active Poster -->
|
||||||
|
<div v-if="funStats.most_active_poster" class="p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<MessageSquare class="w-5 h-5 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-blue-800">Posteur le plus actif</p>
|
||||||
|
<p class="text-xs text-blue-600">{{ funStats.most_active_poster.full_name }} ({{ funStats.most_active_poster.post_count }} posts)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Most Mentioned -->
|
||||||
|
<div v-if="funStats.most_mentioned" class="p-3 bg-green-50 rounded-lg border border-green-200">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<AtSign class="w-5 h-5 text-green-600" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-green-800">Le plus mentionné</p>
|
||||||
|
<p class="text-xs text-green-600">{{ funStats.most_mentioned.full_name }} ({{ funStats.most_mentioned.mention_count }} mentions)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Biggest Vlogger -->
|
||||||
|
<div v-if="funStats.biggest_vlogger" class="p-3 bg-purple-50 rounded-lg border border-purple-200">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Film class="w-5 h-5 text-purple-600" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-purple-800">Vlogger le plus prolifique</p>
|
||||||
|
<p class="text-xs text-purple-600">{{ funStats.biggest_vlogger.full_name }} ({{ funStats.biggest_vlogger.vlog_count }} vlogs)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo Addict -->
|
||||||
|
<div v-if="funStats.photo_addict" class="p-3 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Image class="w-5 h-5 text-yellow-600" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-yellow-800">Accro aux photos</p>
|
||||||
|
<p class="text-xs text-yellow-600">{{ funStats.photo_addict.full_name }} ({{ funStats.photo_addict.album_count }} albums)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event Organizer -->
|
||||||
|
<div v-if="funStats.event_organizer" class="p-3 bg-red-50 rounded-lg border border-red-200">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Calendar class="w-5 h-5 text-red-600" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-red-800">Organisateur d'événements</p>
|
||||||
|
<p class="text-xs text-red-600">{{ funStats.event_organizer.full_name }} ({{ funStats.event_organizer.event_count }} événements)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Most Viewed Vlog -->
|
||||||
|
<div v-if="funStats.most_viewed_vlog" class="p-3 bg-indigo-50 rounded-lg border border-indigo-200">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Eye class="w-5 h-5 text-indigo-600" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-indigo-800">Vlog le plus vu</p>
|
||||||
|
<p class="text-xs text-indigo-600">{{ funStats.most_viewed_vlog.title }} ({{ funStats.most_viewed_vlog.views_count }} vues)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<div class="card p-6 mt-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-6">Activité récente (30 derniers jours)</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div class="text-2xl font-bold text-primary-600">{{ overview.recent_events || 0 }}</div>
|
||||||
|
<div class="text-sm text-gray-600">Nouveaux événements</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div class="text-2xl font-bold text-success-600">{{ overview.recent_posts || 0 }}</div>
|
||||||
|
<div class="text-sm text-gray-600">Nouvelles publications</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div class="text-2xl font-bold text-purple-600">{{ overview.recent_vlogs || 0 }}</div>
|
||||||
|
<div class="text-sm text-gray-600">Nouveaux vlogs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Stats -->
|
||||||
|
<div class="card p-6 mt-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-6">Statistiques par utilisateur</h2>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Utilisateur</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Taux de présence</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Publications</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Vlogs</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Albums</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Événements créés</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr
|
||||||
|
v-for="user in userStats"
|
||||||
|
:key="user.user.id"
|
||||||
|
class="hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<UserAvatar :user="user.user" size="md" :show-user-info="true" />
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="text-sm font-medium text-gray-900">{{ Math.round(user.user.attendance_rate) }}%</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ user.content_stats.posts_count }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ user.content_stats.vlogs_count }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ user.content_stats.albums_count }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ user.content_stats.events_created }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Calendar,
|
||||||
|
Image,
|
||||||
|
Film,
|
||||||
|
Trophy,
|
||||||
|
MessageSquare,
|
||||||
|
AtSign,
|
||||||
|
Eye
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const overview = ref({})
|
||||||
|
const attendanceStats = ref({})
|
||||||
|
const funStats = ref({})
|
||||||
|
const userStats = ref([])
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
try {
|
||||||
|
// Fetch overview stats
|
||||||
|
const overviewResponse = await axios.get('/api/stats/overview')
|
||||||
|
overview.value = overviewResponse.data
|
||||||
|
|
||||||
|
// Fetch attendance stats
|
||||||
|
const attendanceResponse = await axios.get('/api/stats/attendance')
|
||||||
|
attendanceStats.value = attendanceResponse.data
|
||||||
|
|
||||||
|
// Fetch fun stats
|
||||||
|
const funResponse = await axios.get('/api/stats/fun')
|
||||||
|
funStats.value = funResponse.data
|
||||||
|
|
||||||
|
// Fetch user stats for all users
|
||||||
|
const usersResponse = await axios.get('/api/users')
|
||||||
|
const userStatsPromises = usersResponse.data.map(async (user) => {
|
||||||
|
try {
|
||||||
|
const userStatsResponse = await axios.get(`/api/stats/user/${user.id}`)
|
||||||
|
return userStatsResponse.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching stats for user ${user.id}:`, error)
|
||||||
|
return {
|
||||||
|
user: user,
|
||||||
|
content_stats: { posts_count: 0, vlogs_count: 0, albums_count: 0, events_created: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const userStatsResults = await Promise.all(userStatsPromises)
|
||||||
|
userStats.value = userStatsResults.sort((a, b) => b.user.attendance_rate - a.user.attendance_rate)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors du chargement des statistiques')
|
||||||
|
console.error('Error fetching stats:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchStats()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
223
frontend/src/views/UserProfile.vue
Normal file
223
frontend/src/views/UserProfile.vue
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="text-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||||
|
<p class="mt-4 text-gray-600">Chargement du profil...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile not found -->
|
||||||
|
<div v-else-if="!profileUser" class="text-center py-12">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-4">Profil non trouvé</h1>
|
||||||
|
<p class="text-gray-600 mb-6">L'utilisateur que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||||
|
<router-link to="/" class="btn-primary">
|
||||||
|
Retour à l'accueil
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile content -->
|
||||||
|
<div v-else>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<router-link to="/" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||||
|
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||||
|
Retour à l'accueil
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Info -->
|
||||||
|
<div class="card p-8 text-center">
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="relative inline-block">
|
||||||
|
<img
|
||||||
|
v-if="profileUser.avatar_url"
|
||||||
|
:src="getMediaUrl(profileUser.avatar_url)"
|
||||||
|
:alt="profileUser.full_name"
|
||||||
|
class="w-32 h-32 rounded-full object-cover mx-auto border-4 border-white shadow-lg"
|
||||||
|
>
|
||||||
|
<div v-else class="w-32 h-32 rounded-full bg-primary-100 flex items-center justify-center mx-auto border-4 border-white shadow-lg">
|
||||||
|
<User class="w-16 h-16 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Info -->
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-2">{{ profileUser.full_name }}</h1>
|
||||||
|
<p class="text-xl text-gray-600 mb-4">@{{ profileUser.username }}</p>
|
||||||
|
|
||||||
|
<div v-if="profileUser.bio" class="text-gray-700 mb-6 max-w-2xl mx-auto">
|
||||||
|
{{ profileUser.bio }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 mt-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-primary-600">{{ userStats.posts_count || 0 }}</div>
|
||||||
|
<div class="text-gray-600">Publications</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-success-600">{{ userStats.vlogs_count || 0 }}</div>
|
||||||
|
<div class="text-gray-600">Vlogs</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-blue-600">{{ userStats.albums_count || 0 }}</div>
|
||||||
|
<div class="text-gray-600">Albums</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-purple-600">{{ userStats.events_created || 0 }}</div>
|
||||||
|
<div class="text-gray-600">Événements</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attendance Rate -->
|
||||||
|
<div v-if="profileUser.attendance_rate !== undefined" class="mt-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-lg font-semibold text-gray-900 mb-2">Taux de présence</div>
|
||||||
|
<div class="text-3xl font-bold text-success-600">{{ profileUser.attendance_rate.toFixed(1) }}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<div class="card p-6 mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Activité récente</h2>
|
||||||
|
|
||||||
|
<div v-if="recentActivity.length === 0" class="text-center py-8 text-gray-500">
|
||||||
|
Aucune activité récente
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="activity in recentActivity"
|
||||||
|
:key="activity.id"
|
||||||
|
class="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||||
|
@click="navigateToActivity(activity)"
|
||||||
|
>
|
||||||
|
<div class="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
|
||||||
|
<component :is="getActivityIcon(activity.type)" class="w-5 h-5 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm text-gray-900">{{ activity.description }}</p>
|
||||||
|
<p class="text-xs text-gray-500">{{ formatRelativeDate(activity.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight class="w-4 h-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Member Since -->
|
||||||
|
<div class="card p-6 text-center">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">Membre depuis</h3>
|
||||||
|
<p class="text-2xl text-primary-600">{{ formatDate(profileUser.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
import { format, formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import { User, ArrowLeft, ArrowRight, MessageSquare, Video, Image, Calendar, Activity } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const profileUser = ref(null)
|
||||||
|
const userStats = ref({})
|
||||||
|
const recentActivity = ref([])
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
if (!date) return ''
|
||||||
|
return format(new Date(date), 'MMMM yyyy', { locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeDate(date) {
|
||||||
|
if (!date) return ''
|
||||||
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityIcon(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'post':
|
||||||
|
return MessageSquare
|
||||||
|
case 'vlog':
|
||||||
|
return Video
|
||||||
|
case 'album':
|
||||||
|
return Image
|
||||||
|
case 'event':
|
||||||
|
return Calendar
|
||||||
|
default:
|
||||||
|
return Activity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToActivity(activity) {
|
||||||
|
if (activity.link) {
|
||||||
|
router.push(activity.link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProfile(userId) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/users/${userId}`)
|
||||||
|
profileUser.value = response.data
|
||||||
|
await fetchUserStats(userId)
|
||||||
|
await fetchRecentActivity(userId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching profile:', error)
|
||||||
|
profileUser.value = null
|
||||||
|
toast.error('Erreur lors du chargement du profil')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserStats(userId) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/stats/user/${userId}`)
|
||||||
|
userStats.value = response.data.content_stats
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user stats:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRecentActivity(userId) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/stats/activity/user/${userId}`)
|
||||||
|
recentActivity.value = response.data.activity
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recent activity:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const userId = route.params.id
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
toast.error('ID utilisateur manquant')
|
||||||
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empêcher de voir son propre profil ici
|
||||||
|
if (parseInt(userId) === authStore.user?.id) {
|
||||||
|
router.push('/profile')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchProfile(userId)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
370
frontend/src/views/VlogDetail.vue
Normal file
370
frontend/src/views/VlogDetail.vue
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="text-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||||
|
<p class="mt-4 text-gray-600">Chargement du vlog...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vlog not found -->
|
||||||
|
<div v-else-if="!vlog" class="text-center py-12">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-4">Vlog non trouvé</h1>
|
||||||
|
<p class="text-gray-600 mb-6">Le vlog que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||||
|
<router-link to="/vlogs" class="btn-primary">
|
||||||
|
Retour aux vlogs
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vlog details -->
|
||||||
|
<div v-else>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<router-link to="/vlogs" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||||
|
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||||
|
Retour aux vlogs
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<div v-if="canEdit" class="flex space-x-2">
|
||||||
|
<button
|
||||||
|
@click="showEditModal = true"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
<Edit class="w-4 h-4 mr-2" />
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="deleteVlog"
|
||||||
|
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4 mr-2" />
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="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 items-center">
|
||||||
|
<img
|
||||||
|
v-if="vlog.author_avatar"
|
||||||
|
:src="vlog.author_avatar"
|
||||||
|
:alt="vlog.author_name"
|
||||||
|
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">
|
||||||
|
<User class="w-4 h-4 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<span>Par {{ vlog.author_name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Calendar class="w-4 h-4 mr-2" />
|
||||||
|
<span>{{ formatDate(vlog.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Eye class="w-4 h-4 mr-2" />
|
||||||
|
<span>{{ vlog.views_count }} vue{{ vlog.views_count > 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="vlog.duration" class="flex items-center">
|
||||||
|
<Clock class="w-4 h-4 mr-2" />
|
||||||
|
<span>{{ formatDuration(vlog.duration) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video Player -->
|
||||||
|
<div class="card p-6 mb-8">
|
||||||
|
<VideoPlayer
|
||||||
|
:src="vlog.video_url"
|
||||||
|
:poster="vlog.thumbnail_url"
|
||||||
|
:title="vlog.title"
|
||||||
|
:description="vlog.description"
|
||||||
|
:duration="vlog.duration"
|
||||||
|
:views-count="vlog.views_count"
|
||||||
|
:likes-count="vlog.likes_count"
|
||||||
|
:comments-count="vlog.comments?.length || 0"
|
||||||
|
:is-liked="vlog.is_liked"
|
||||||
|
@like="toggleLike"
|
||||||
|
@toggle-comments="showComments = !showComments"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div v-if="vlog.description" class="card p-6 mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Description</h2>
|
||||||
|
<p class="text-gray-700 whitespace-pre-wrap">{{ vlog.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments Section -->
|
||||||
|
<div class="card p-6 mb-8">
|
||||||
|
<VlogComments
|
||||||
|
:vlog-id="vlog.id"
|
||||||
|
:comments="vlog.comments || []"
|
||||||
|
:comment-users="users"
|
||||||
|
@comment-added="onCommentAdded"
|
||||||
|
@comment-deleted="onCommentDeleted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Related Vlogs -->
|
||||||
|
<div class="card p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Autres vlogs de {{ vlog.author_name }}</h2>
|
||||||
|
|
||||||
|
<div v-if="relatedVlogs.length === 0" class="text-center py-8 text-gray-500">
|
||||||
|
Aucun autre vlog de cet auteur
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<router-link
|
||||||
|
v-for="relatedVlog in relatedVlogs"
|
||||||
|
:key="relatedVlog.id"
|
||||||
|
:to="`/vlogs/${relatedVlog.id}`"
|
||||||
|
class="block hover:shadow-md transition-shadow rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="aspect-video bg-gray-100 relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="relatedVlog.thumbnail_url"
|
||||||
|
:src="getMediaUrl(relatedVlog.thumbnail_url)"
|
||||||
|
:alt="relatedVlog.title"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<Film class="w-12 h-12 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Play Button Overlay -->
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
||||||
|
<Play class="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duration Badge -->
|
||||||
|
<div v-if="relatedVlog.duration" class="absolute bottom-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||||
|
{{ formatDuration(relatedVlog.duration) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3">
|
||||||
|
<h3 class="font-medium text-gray-900 line-clamp-2">{{ relatedVlog.title }}</h3>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">{{ formatRelativeDate(relatedVlog.created_at) }}</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{ relatedVlog.views_count }} vue{{ relatedVlog.views_count > 1 ? 's' : '' }}</p>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Vlog Modal -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showEditModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Modifier le vlog</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="updateVlog" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="label">Titre</label>
|
||||||
|
<input
|
||||||
|
v-model="editForm.title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Description</label>
|
||||||
|
<textarea
|
||||||
|
v-model="editForm.description"
|
||||||
|
rows="3"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showEditModal = false"
|
||||||
|
class="flex-1 btn-secondary"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="updating"
|
||||||
|
class="flex-1 btn-primary"
|
||||||
|
>
|
||||||
|
{{ updating ? 'Mise à jour...' : 'Mettre à jour' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
import { format, formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
Eye,
|
||||||
|
Clock,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Film,
|
||||||
|
Play
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import VideoPlayer from '@/components/VideoPlayer.vue'
|
||||||
|
import VlogComments from '@/components/VlogComments.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const vlog = ref(null)
|
||||||
|
const relatedVlogs = ref([])
|
||||||
|
const users = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const updating = ref(false)
|
||||||
|
const showEditModal = ref(false)
|
||||||
|
const showComments = ref(true)
|
||||||
|
|
||||||
|
const editForm = ref({
|
||||||
|
title: '',
|
||||||
|
description: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const canEdit = computed(() =>
|
||||||
|
vlog.value && (vlog.value.author_id === authStore.user?.id || authStore.user?.is_admin)
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return format(new Date(date), 'dd MMMM yyyy', { locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeDate(date) {
|
||||||
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (!seconds) return '--:--'
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const remainingSeconds = seconds % 60
|
||||||
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLike() {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`/api/vlogs/${vlog.value.id}/like`)
|
||||||
|
// Refresh vlog data to get updated like count
|
||||||
|
await fetchVlog()
|
||||||
|
toast.success(response.data.message)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour du like')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCommentAdded(comment) {
|
||||||
|
// Add the new comment to the vlog
|
||||||
|
if (!vlog.value.comments) {
|
||||||
|
vlog.value.comments = []
|
||||||
|
}
|
||||||
|
vlog.value.comments.unshift(comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCommentDeleted(commentId) {
|
||||||
|
// Remove the deleted comment from the vlog
|
||||||
|
if (vlog.value.comments) {
|
||||||
|
const index = vlog.value.comments.findIndex(c => c.id === commentId)
|
||||||
|
if (index > -1) {
|
||||||
|
vlog.value.comments.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsers() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/users')
|
||||||
|
users.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchVlog() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/vlogs/${route.params.id}`)
|
||||||
|
vlog.value = response.data
|
||||||
|
|
||||||
|
// Initialize edit form
|
||||||
|
editForm.value = {
|
||||||
|
title: vlog.value.title,
|
||||||
|
description: vlog.value.description || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch related vlogs from same author
|
||||||
|
const relatedResponse = await axios.get(`/api/vlogs?limit=6&offset=0`)
|
||||||
|
relatedVlogs.value = relatedResponse.data
|
||||||
|
.filter(v => v.id !== vlog.value.id && v.author_id === vlog.value.author_id)
|
||||||
|
.slice(0, 3)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors du chargement du vlog')
|
||||||
|
console.error('Error fetching vlog:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateVlog() {
|
||||||
|
updating.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.put(`/api/vlogs/${vlog.value.id}`, editForm.value)
|
||||||
|
vlog.value = response.data
|
||||||
|
showEditModal.value = false
|
||||||
|
toast.success('Vlog mis à jour')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour')
|
||||||
|
}
|
||||||
|
updating.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVlog() {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir supprimer ce vlog ?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/vlogs/${vlog.value.id}`)
|
||||||
|
toast.success('Vlog supprimé')
|
||||||
|
router.push('/vlogs')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la suppression')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchVlog()
|
||||||
|
fetchUsers()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
473
frontend/src/views/Vlogs.vue
Normal file
473
frontend/src/views/Vlogs.vue
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="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>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
|
Nouveau vlog
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vlogs Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div
|
||||||
|
v-for="vlog in vlogs"
|
||||||
|
:key="vlog.id"
|
||||||
|
class="card hover:shadow-lg transition-shadow cursor-pointer"
|
||||||
|
@click="openVlog(vlog)"
|
||||||
|
>
|
||||||
|
<!-- Thumbnail -->
|
||||||
|
<div class="aspect-video bg-gray-100 relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="vlog.thumbnail_url"
|
||||||
|
:src="getMediaUrl(vlog.thumbnail_url)"
|
||||||
|
:alt="vlog.title"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<Film class="w-16 h-16 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Play Button Overlay -->
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
||||||
|
<Play class="w-12 h-12 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duration Badge -->
|
||||||
|
<div v-if="vlog.duration" class="absolute bottom-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||||
|
{{ formatDuration(vlog.duration) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-semibold text-gray-900 mb-2 line-clamp-2">{{ vlog.title }}</h3>
|
||||||
|
|
||||||
|
<!-- Description with mentions -->
|
||||||
|
<div v-if="vlog.description" class="mb-3">
|
||||||
|
<Mentions :content="vlog.description" :mentions="getMentionsFromContent(vlog.description)" class="text-gray-600 text-sm line-clamp-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2 text-sm text-gray-600 mb-3">
|
||||||
|
<img
|
||||||
|
v-if="vlog.author_avatar"
|
||||||
|
:src="vlog.author_avatar"
|
||||||
|
:alt="vlog.author_name"
|
||||||
|
class="w-5 h-5 rounded-full object-cover"
|
||||||
|
>
|
||||||
|
<div v-else class="w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center">
|
||||||
|
<User class="w-3 h-3 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
:to="`/profile/${vlog.author_id}`"
|
||||||
|
class="text-gray-900 hover:text-primary-600 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ vlog.author_name }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-sm text-gray-500">
|
||||||
|
<span>{{ formatRelativeDate(vlog.created_at) }}</span>
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<Eye class="w-4 h-4" />
|
||||||
|
<span>{{ vlog.views_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load more -->
|
||||||
|
<div v-if="hasMoreVlogs" class="text-center mt-8">
|
||||||
|
<button
|
||||||
|
@click="loadMoreVlogs"
|
||||||
|
:disabled="loading"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="vlogs.length === 0 && !loading" class="text-center py-12">
|
||||||
|
<Film class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun vlog</h3>
|
||||||
|
<p class="text-gray-600">Soyez le premier à partager un vlog !</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Vlog Modal -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showCreateModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Créer un nouveau vlog</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="createVlog" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="label">Titre</label>
|
||||||
|
<input
|
||||||
|
v-model="newVlog.title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
placeholder="Titre de votre vlog..."
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Description</label>
|
||||||
|
<MentionInput
|
||||||
|
v-model="newVlog.description"
|
||||||
|
:users="users"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="Décrivez votre vlog... (utilisez @username pour mentionner)"
|
||||||
|
@mentions-changed="handleVlogMentionsChanged"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Vidéo</label>
|
||||||
|
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||||
|
<input
|
||||||
|
ref="videoInput"
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleVideoChange"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div v-if="!newVlog.video" class="space-y-2">
|
||||||
|
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
|
||||||
|
<p class="text-gray-600">Cliquez pour sélectionner une vidéo</p>
|
||||||
|
<p class="text-sm text-gray-500">MP4, WebM, MOV (max {{ uploadLimits.max_video_size_mb }}MB)</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$refs.videoInput.click()"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Sélectionner une vidéo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<video
|
||||||
|
:src="newVlog.video"
|
||||||
|
class="w-full max-h-48 object-cover rounded"
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="removeVideo"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
Changer de vidéo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Miniature (optionnel)</label>
|
||||||
|
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||||
|
<input
|
||||||
|
ref="thumbnailInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleThumbnailChange"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div v-if="!newVlog.thumbnail" class="space-y-2">
|
||||||
|
<Image class="w-12 h-12 text-gray-400 mx-auto" />
|
||||||
|
<p class="text-gray-600">Ajoutez une miniature personnalisée</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$refs.thumbnailInput.click()"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Sélectionner une image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<img
|
||||||
|
:src="newVlog.thumbnail"
|
||||||
|
class="w-full max-h-48 object-cover rounded mx-auto"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="removeThumbnail"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
Changer la miniature
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showCreateModal = false"
|
||||||
|
class="flex-1 btn-secondary"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="creating || !newVlog.video"
|
||||||
|
class="flex-1 btn-primary"
|
||||||
|
>
|
||||||
|
{{ creating ? 'Création...' : 'Créer le vlog' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import axios from '@/utils/axios'
|
||||||
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Film,
|
||||||
|
Play,
|
||||||
|
User,
|
||||||
|
Eye,
|
||||||
|
Upload,
|
||||||
|
Image
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import Mentions from '@/components/Mentions.vue'
|
||||||
|
import MentionInput from '@/components/MentionInput.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const vlogs = ref([])
|
||||||
|
const users = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const hasMoreVlogs = ref(true)
|
||||||
|
const offset = ref(0)
|
||||||
|
const uploadLimits = ref({
|
||||||
|
max_video_size_mb: 100
|
||||||
|
})
|
||||||
|
|
||||||
|
const newVlog = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
video: null,
|
||||||
|
thumbnail: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const vlogMentions = ref([])
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const remainingSeconds = seconds % 60
|
||||||
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeDate(date) {
|
||||||
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openVlog(vlog) {
|
||||||
|
router.push(`/vlogs/${vlog.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVideoChange(event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
if (!file.type.startsWith('video/')) {
|
||||||
|
toast.error('Veuillez sélectionner une vidéo')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > uploadLimits.max_video_size_mb * 1024 * 1024) {
|
||||||
|
toast.error(`La vidéo est trop volumineuse (max ${uploadLimits.max_video_size_mb}MB)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create preview URL
|
||||||
|
newVlog.value.video = URL.createObjectURL(file)
|
||||||
|
newVlog.value.videoFile = file
|
||||||
|
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeVideo() {
|
||||||
|
if (newVlog.value.video && newVlog.value.video.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(newVlog.value.video)
|
||||||
|
}
|
||||||
|
newVlog.value.video = null
|
||||||
|
newVlog.value.videoFile = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleThumbnailChange(event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error('Veuillez sélectionner une image')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > uploadLimits.max_image_size_mb * 1024 * 1024) {
|
||||||
|
toast.error(`L'image est trop volumineuse (max ${uploadLimits.max_image_size_mb}MB)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create preview URL
|
||||||
|
newVlog.value.thumbnail = URL.createObjectURL(file)
|
||||||
|
newVlog.value.thumbnailFile = file
|
||||||
|
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeThumbnail() {
|
||||||
|
if (newVlog.value.thumbnail && newVlog.value.thumbnail.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(newVlog.value.thumbnail)
|
||||||
|
}
|
||||||
|
newVlog.value.thumbnail = null
|
||||||
|
newVlog.value.thumbnailFile = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVlogMentionsChanged(mentions) {
|
||||||
|
vlogMentions.value = mentions
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsers() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/users')
|
||||||
|
users.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUploadLimits() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/settings/upload-limits')
|
||||||
|
uploadLimits.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching upload limits:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMentionsFromContent(content) {
|
||||||
|
if (!content) return []
|
||||||
|
|
||||||
|
const mentions = []
|
||||||
|
const mentionRegex = /@(\w+)/g
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = mentionRegex.exec(content)) !== null) {
|
||||||
|
const username = match[1]
|
||||||
|
const user = users.value.find(u => u.username === username)
|
||||||
|
if (user) {
|
||||||
|
mentions.push({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
full_name: user.full_name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mentions
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createVlog() {
|
||||||
|
if (!newVlog.value.title || !newVlog.value.videoFile) return
|
||||||
|
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('title', newVlog.value.title)
|
||||||
|
if (newVlog.value.description) {
|
||||||
|
formData.append('description', newVlog.value.description)
|
||||||
|
}
|
||||||
|
formData.append('video', newVlog.value.videoFile)
|
||||||
|
if (newVlog.value.thumbnailFile) {
|
||||||
|
formData.append('thumbnail', newVlog.value.thumbnailFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post('/api/vlogs/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
|
||||||
|
vlogs.value.unshift(response.data)
|
||||||
|
showCreateModal.value = false
|
||||||
|
resetForm()
|
||||||
|
toast.success('Vlog créé avec succès')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la création du vlog')
|
||||||
|
}
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
removeVideo()
|
||||||
|
removeThumbnail()
|
||||||
|
newVlog.value = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
video: null,
|
||||||
|
thumbnail: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchVlogs() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/vlogs?limit=12&offset=${offset.value}`)
|
||||||
|
if (offset.value === 0) {
|
||||||
|
vlogs.value = response.data
|
||||||
|
} else {
|
||||||
|
vlogs.value.push(...response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMoreVlogs.value = response.data.length === 12
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors du chargement des vlogs')
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreVlogs() {
|
||||||
|
offset.value += 12
|
||||||
|
await fetchVlogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchVlogs()
|
||||||
|
fetchUsers()
|
||||||
|
fetchUploadLimits()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
78
frontend/tailwind.config.js
Normal file
78
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Discord-style color palette
|
||||||
|
primary: {
|
||||||
|
50: '#f5f3ff', // Violet très pâle
|
||||||
|
100: '#ede9fe', // Violet pâle
|
||||||
|
200: '#ddd6fe', // Violet clair
|
||||||
|
300: '#c4b5fd', // Violet moyen
|
||||||
|
400: '#a78bfa', // Violet
|
||||||
|
500: '#8b5cf6', // Violet principal
|
||||||
|
600: '#7c3aed', // Violet foncé
|
||||||
|
700: '#6d28d9', // Violet plus foncé
|
||||||
|
800: '#5b21b6', // Violet très foncé
|
||||||
|
900: '#4c1d95', // Violet le plus foncé
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
50: '#f8fafc', // Gris très pâle
|
||||||
|
100: '#f1f5f9', // Gris pâle
|
||||||
|
200: '#e2e8f0', // Gris clair
|
||||||
|
300: '#cbd5e1', // Gris moyen
|
||||||
|
400: '#94a3b8', // Gris
|
||||||
|
500: '#64748b', // Gris principal
|
||||||
|
600: '#475569', // Gris foncé
|
||||||
|
700: '#334155', // Gris plus foncé
|
||||||
|
800: '#1e293b', // Gris très foncé
|
||||||
|
900: '#0f172a', // Gris le plus foncé
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
50: '#fef2f2', // Rouge très pâle
|
||||||
|
100: '#fee2e2', // Rouge pâle
|
||||||
|
200: '#fecaca', // Rouge clair
|
||||||
|
300: '#fca5a5', // Rouge moyen
|
||||||
|
400: '#f87171', // Rouge
|
||||||
|
500: '#ef4444', // Rouge principal
|
||||||
|
600: '#dc2626', // Rouge foncé
|
||||||
|
700: '#b91c1c', // Rouge plus foncé
|
||||||
|
800: '#991b1b', // Rouge très foncé
|
||||||
|
900: '#7f1d1d', // Rouge le plus foncé
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
50: '#f0fdf4', // Vert très pâle
|
||||||
|
100: '#dcfce7', // Vert pâle
|
||||||
|
200: '#bbf7d0', // Vert clair
|
||||||
|
300: '#86efac', // Vert moyen
|
||||||
|
400: '#4ade80', // Vert
|
||||||
|
500: '#22c55e', // Vert principal
|
||||||
|
600: '#16a34a', // Vert foncé
|
||||||
|
700: '#15803d', // Vert plus foncé
|
||||||
|
800: '#166534', // Vert très foncé
|
||||||
|
900: '#14532d', // Vert le plus foncé
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
50: '#fffbeb', // Jaune très pâle
|
||||||
|
100: '#fef3c7', // Jaune pâle
|
||||||
|
200: '#fde68a', // Jaune clair
|
||||||
|
300: '#fcd34d', // Jaune moyen
|
||||||
|
400: '#fbbf24', // Jaune
|
||||||
|
500: '#f59e0b', // Jaune principal
|
||||||
|
600: '#d97706', // Jaune foncé
|
||||||
|
700: '#b45309', // Jaune plus foncé
|
||||||
|
800: '#92400e', // Jaune très foncé
|
||||||
|
900: '#78350f', // Jaune le plus foncé
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
26
frontend/vite.config.js
Normal file
26
frontend/vite.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const { defineConfig } = require('vite')
|
||||||
|
const vue = require('@vitejs/plugin-vue')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
101
migrate.sh
Executable file
101
migrate.sh
Executable file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# LeDiscord - Script de migration de base de données
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🗄️ Script de migration de base de données LeDiscord"
|
||||||
|
|
||||||
|
# Vérifier que Docker est en cours d'exécution
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
echo "❌ Docker n'est pas en cours d'exécution"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Vérifier que le conteneur backend est en cours d'exécution
|
||||||
|
if ! docker ps | grep -q "lediscord_backend"; then
|
||||||
|
echo "❌ Le conteneur backend n'est pas en cours d'exécution"
|
||||||
|
echo " Lancez d'abord 'make start' ou 'docker-compose up -d'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Conteneur backend en cours d'exécution"
|
||||||
|
|
||||||
|
# Menu des options
|
||||||
|
echo ""
|
||||||
|
echo "Choisissez une option :"
|
||||||
|
echo "1) Créer une migration"
|
||||||
|
echo "2) Appliquer les migrations"
|
||||||
|
echo "3) Annuler la dernière migration"
|
||||||
|
echo "4) Voir l'état des migrations"
|
||||||
|
echo "5) Réinitialiser la base de données (⚠️ DANGEREUX)"
|
||||||
|
echo "6) Sauvegarder la base de données"
|
||||||
|
echo "7) Restaurer la base de données"
|
||||||
|
echo "0) Quitter"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Votre choix (0-7) : " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
echo "📝 Création d'une migration..."
|
||||||
|
read -p "Nom de la migration : " migration_name
|
||||||
|
docker exec lediscord_backend alembic revision --autogenerate -m "$migration_name"
|
||||||
|
echo "✅ Migration créée"
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
echo "🔄 Application des migrations..."
|
||||||
|
docker exec lediscord_backend alembic upgrade head
|
||||||
|
echo "✅ Migrations appliquées"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
echo "↩️ Annulation de la dernière migration..."
|
||||||
|
docker exec lediscord_backend alembic downgrade -1
|
||||||
|
echo "✅ Dernière migration annulée"
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
echo "📊 État des migrations..."
|
||||||
|
docker exec lediscord_backend alembic current
|
||||||
|
echo ""
|
||||||
|
echo "Historique des migrations :"
|
||||||
|
docker exec lediscord_backend alembic history
|
||||||
|
;;
|
||||||
|
5)
|
||||||
|
echo "⚠️ ATTENTION : Cette action va supprimer toutes les données !"
|
||||||
|
read -p "Êtes-vous sûr ? Tapez 'OUI' pour confirmer : " confirm
|
||||||
|
if [ "$confirm" = "OUI" ]; then
|
||||||
|
echo "🗑️ Suppression de la base de données..."
|
||||||
|
docker exec lediscord_backend alembic downgrade base
|
||||||
|
docker exec lediscord_backend alembic upgrade head
|
||||||
|
echo "✅ Base de données réinitialisée"
|
||||||
|
else
|
||||||
|
echo "❌ Opération annulée"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
6)
|
||||||
|
echo "💾 Sauvegarde de la base de données..."
|
||||||
|
timestamp=$(date +%Y%m%d_%H%M%S)
|
||||||
|
docker exec lediscord_postgres pg_dump -U lediscord_user lediscord > "backup_${timestamp}.sql"
|
||||||
|
echo "✅ Sauvegarde créée : backup_${timestamp}.sql"
|
||||||
|
;;
|
||||||
|
7)
|
||||||
|
echo "📥 Restauration de la base de données..."
|
||||||
|
read -p "Nom du fichier de sauvegarde : " backup_file
|
||||||
|
if [ -f "$backup_file" ]; then
|
||||||
|
docker exec -i lediscord_postgres psql -U lediscord_user lediscord < "$backup_file"
|
||||||
|
echo "✅ Base de données restaurée"
|
||||||
|
else
|
||||||
|
echo "❌ Fichier de sauvegarde non trouvé : $backup_file"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
0)
|
||||||
|
echo "👋 Au revoir !"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ Choix invalide"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
109
setup.sh
Executable file
109
setup.sh
Executable file
@@ -0,0 +1,109 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# LeDiscord - Script de setup automatique
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
set -e # Arrêter en cas d'erreur
|
||||||
|
|
||||||
|
echo "🚀 Démarrage du setup de LeDiscord..."
|
||||||
|
|
||||||
|
# Vérifier que Docker est installé
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "❌ Docker n'est pas installé. Veuillez l'installer d'abord."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
echo "❌ Docker Compose n'est pas installé. Veuillez l'installer d'abord."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Docker et Docker Compose sont installés"
|
||||||
|
|
||||||
|
# Vérifier que les ports sont disponibles
|
||||||
|
check_port() {
|
||||||
|
local port=$1
|
||||||
|
if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null ; then
|
||||||
|
echo "❌ Le port $port est déjà utilisé. Veuillez le libérer."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "🔍 Vérification des ports..."
|
||||||
|
check_port 8000
|
||||||
|
check_port 5173
|
||||||
|
check_port 5432
|
||||||
|
echo "✅ Tous les ports sont disponibles"
|
||||||
|
|
||||||
|
# Créer le fichier .env s'il n'existe pas
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "📝 Création du fichier .env..."
|
||||||
|
if [ -f env.example ]; then
|
||||||
|
cp env.example .env
|
||||||
|
echo "⚠️ Fichier .env créé à partir de env.example"
|
||||||
|
echo "⚠️ IMPORTANT: Modifiez le fichier .env avec vos valeurs avant de continuer"
|
||||||
|
echo "⚠️ Appuyez sur Entrée quand vous avez terminé..."
|
||||||
|
read
|
||||||
|
else
|
||||||
|
echo "❌ Fichier env.example non trouvé. Créez manuellement votre fichier .env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✅ Fichier .env existe déjà"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Créer le dossier uploads s'il n'existe pas
|
||||||
|
if [ ! -d uploads ]; then
|
||||||
|
echo "📁 Création du dossier uploads..."
|
||||||
|
mkdir -p uploads
|
||||||
|
touch uploads/.gitkeep
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Construire et démarrer les services
|
||||||
|
echo "🔨 Construction des images Docker..."
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
echo "🚀 Démarrage des services..."
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Attendre que les services soient prêts
|
||||||
|
echo "⏳ Attente que les services soient prêts..."
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
# Vérifier que les services fonctionnent
|
||||||
|
echo "🔍 Vérification des services..."
|
||||||
|
|
||||||
|
if curl -s http://localhost:8000/health > /dev/null; then
|
||||||
|
echo "✅ Backend accessible sur http://localhost:8000"
|
||||||
|
else
|
||||||
|
echo "❌ Backend non accessible"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if curl -s http://localhost:5173 > /dev/null; then
|
||||||
|
echo "✅ Frontend accessible sur http://localhost:5173"
|
||||||
|
else
|
||||||
|
echo "❌ Frontend non accessible"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 Setup terminé avec succès !"
|
||||||
|
echo ""
|
||||||
|
echo "📱 Accès à l'application :"
|
||||||
|
echo " Frontend: http://localhost:5173"
|
||||||
|
echo " API Docs: http://localhost:8000/docs"
|
||||||
|
echo ""
|
||||||
|
echo "🔑 Compte admin par défaut :"
|
||||||
|
echo " Email: admin@lediscord.com"
|
||||||
|
echo " Mot de passe: Celui défini dans votre fichier .env"
|
||||||
|
echo ""
|
||||||
|
echo "📚 Commandes utiles :"
|
||||||
|
echo " make start - Démarrer l'application"
|
||||||
|
echo " make stop - Arrêter l'application"
|
||||||
|
echo " make logs - Voir les logs"
|
||||||
|
echo " make help - Afficher l'aide"
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ N'oubliez pas de :"
|
||||||
|
echo " 1. Changer le mot de passe admin par défaut"
|
||||||
|
echo " 2. Configurer une clé JWT_SECRET_KEY sécurisée"
|
||||||
|
echo " 3. Configurer l'email si nécessaire"
|
||||||
61
start.sh
Executable file
61
start.sh
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "🚀 Démarrage de LeDiscord..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Vérifier si Docker est installé
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "❌ Docker n'est pas installé. Veuillez installer Docker et Docker Compose."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Vérifier si Docker Compose est installé
|
||||||
|
if ! command -v docker compose &> /dev/null; then
|
||||||
|
echo "❌ Docker Compose n'est pas installé. Veuillez installer Docker Compose."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Créer le fichier .env s'il n'existe pas
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "📝 Création du fichier .env avec les valeurs par défaut..."
|
||||||
|
cat > .env << EOL
|
||||||
|
# Database
|
||||||
|
DB_PASSWORD=lediscord_password_change_me
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY=$(openssl rand -hex 32)
|
||||||
|
|
||||||
|
# Email (optionnel - décommentez et configurez si nécessaire)
|
||||||
|
# SMTP_HOST=smtp.gmail.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=your-email@gmail.com
|
||||||
|
# SMTP_PASSWORD=your-app-password
|
||||||
|
|
||||||
|
# Upload path
|
||||||
|
UPLOAD_PATH=./uploads
|
||||||
|
|
||||||
|
# Admin
|
||||||
|
ADMIN_EMAIL=admin@lediscord.com
|
||||||
|
ADMIN_PASSWORD=admin123
|
||||||
|
EOL
|
||||||
|
echo "✅ Fichier .env créé. Pensez à modifier les mots de passe !"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Créer le dossier uploads s'il n'existe pas
|
||||||
|
mkdir -p uploads
|
||||||
|
|
||||||
|
# Construire et démarrer les conteneurs
|
||||||
|
echo "🐳 Construction et démarrage des conteneurs Docker..."
|
||||||
|
docker compose up --build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ LeDiscord est maintenant accessible sur :"
|
||||||
|
echo " - Frontend : http://localhost:5173"
|
||||||
|
echo " - API Docs : http://localhost:8000/docs"
|
||||||
|
echo ""
|
||||||
|
echo "📧 Compte admin par défaut :"
|
||||||
|
echo " - Email : admin@lediscord.com"
|
||||||
|
echo " - Mot de passe : admin123"
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ N'oubliez pas de changer les mots de passe par défaut !"
|
||||||
Reference in New Issue
Block a user