initial commit - LeDiscord plateforme des copains

This commit is contained in:
EvanChal
2025-08-21 00:28:21 +02:00
commit b7a84a53aa
93 changed files with 16247 additions and 0 deletions

73
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
# Backend package initialization

View 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"]

View 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)}"
)

View 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()

View 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
}

View 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()

View 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
}

View 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}

View 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
}

View 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"}

View 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
}

View 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
}

View 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

View 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
View 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"}

View 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()

View 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()

View 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
View 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
View 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")

View 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)

View 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
View 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")

View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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

View 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

View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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()

View 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
View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

20
frontend/src/App.vue Normal file
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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')

View 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
View 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
View 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;
}
}

View 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

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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 !"

5
stop.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
echo "🛑 Arrêt de LeDiscord..."
docker compose down
echo "✅ LeDiscord est arrêté."