working version

This commit is contained in:
root
2025-08-27 18:34:38 +02:00
parent b7a84a53aa
commit dfaae262c7
153 changed files with 19389 additions and 788 deletions

0
.dockerignore Normal file → Executable file
View File

0
.github/workflows/ci.yml vendored Normal file → Executable file
View File

15
.gitignore vendored Normal file → Executable file
View File

@@ -24,8 +24,11 @@ wheels/
*.egg *.egg
# FastAPI # FastAPI
# IMPORTANT: Zero-Config - Aucun fichier .env par défaut
# Seuls les fichiers d'environnement spécifiques sont autorisés
.env .env
.env.local .env.local
.env.development
.env.production .env.production
*.db *.db
*.sqlite *.sqlite
@@ -95,13 +98,9 @@ ehthumbs.db
Desktop.ini Desktop.ini
# Production # Production
.env.production
.env.staging
*.pem *.pem
*.key
*.crt
# Temporary files # IMPORTANT: Zero-Config - Aucune configuration par défaut
*.tmp # Tous les fichiers .env d'environnement doivent être explicitement configurés
*.temp # Seuls les fichiers .env.local, .env.development, .env.production sont autorisés
.cache/ # Aucun fichier .env générique ou .env.example

View File

@@ -1,158 +0,0 @@
# 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 !

252
Makefile Normal file → Executable file
View File

@@ -1,56 +1,232 @@
.PHONY: help start stop restart logs clean build install .PHONY: help local dev prod start stop restart logs status build clean test verify
# Configuration
PROJECT_NAME := LeDiscord
help: ## Afficher cette aide 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}' @echo "🚀 $(PROJECT_NAME) - Makefile Zero-Config"
@echo "=========================================="
@echo ""
@echo "📋 Environnements disponibles :"
@echo " local - Développement local (localhost)"
@echo " dev - Environnement de développement (dev.lediscord.com)"
@echo " prod - Production (lediscord.com)"
@echo ""
@echo "🔧 Commandes disponibles :"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
@echo ""
@echo "💡 Exemples :"
@echo " make local # Démarrer en mode local"
@echo " make dev # Démarrer en mode développement"
@echo " make prod # Démarrer en mode production"
@echo " make stop-local # Arrêter en mode local"
@echo " make logs-dev # Voir les logs en mode développement"
start: ## Démarrer l'application # ===========================================
./start.sh # LOCAL (développement sur votre machine)
# ===========================================
stop: ## Arrêter l'application local: ## Démarrer en mode local
./stop.sh @echo "🚀 Démarrage de $(PROJECT_NAME) en mode LOCAL..."
@docker compose -f docker-compose.local.yml up --build -d
@echo "$(PROJECT_NAME) local démarré !"
@echo " Frontend: http://localhost:5173"
@echo " Backend: http://localhost:8000"
@echo " API Docs: http://localhost:8000/docs"
restart: ## Redémarrer l'application stop-local: ## Arrêter en mode local
./stop.sh @echo "🛑 Arrêt de $(PROJECT_NAME) en mode LOCAL..."
./start.sh @docker compose -f docker-compose.local.yml down
@echo "$(PROJECT_NAME) local arrêté"
logs: ## Afficher les logs restart-local: ## Redémarrer en mode local
docker compose logs -f @echo "🔄 Redémarrage de $(PROJECT_NAME) en mode LOCAL..."
@$(MAKE) stop-local
@$(MAKE) local
logs-backend: ## Afficher les logs du backend logs-local: ## Voir les logs en mode local
docker compose logs -f backend @echo "📝 Logs de $(PROJECT_NAME) en mode LOCAL..."
@docker compose -f docker-compose.local.yml logs -f
logs-frontend: ## Afficher les logs du frontend status-local: ## Voir le statut en mode local
docker compose logs -f frontend @echo "📊 Statut de $(PROJECT_NAME) en mode LOCAL..."
@docker compose -f docker-compose.local.yml ps
logs-db: ## Afficher les logs de la base de données build-local: ## Reconstruire en mode local
docker compose logs -f postgres @echo "🔨 Reconstruction de $(PROJECT_NAME) en mode LOCAL..."
@docker compose -f docker-compose.local.yml build --no-cache
@echo "✅ Reconstruction terminée"
build: ## Reconstruire les images Docker # ===========================================
docker compose build # DEVELOPMENT (dev.lediscord.com)
# ===========================================
clean: ## Nettoyer les conteneurs et volumes dev: ## Démarrer en mode développement
docker compose down -v @echo "🚀 Démarrage de $(PROJECT_NAME) en mode DEVELOPMENT..."
rm -rf backend/__pycache__ @docker compose -f docker-compose.dev.yml up --build -d
rm -rf backend/**/__pycache__ @echo "$(PROJECT_NAME) development démarré !"
@echo " Frontend: http://localhost:8082"
@echo " Backend: http://localhost:8002"
@echo " API Docs: http://localhost:8002/docs"
install: ## Installer les dépendances localement (dev) stop-dev: ## Arrêter en mode développement
cd backend && pip install -r requirements.txt @echo "🛑 Arrêt de $(PROJECT_NAME) en mode DEVELOPMENT..."
cd frontend && npm install @docker compose -f docker-compose.dev.yml down
@echo "$(PROJECT_NAME) development arrêté"
dev-backend: ## Lancer le backend en mode développement restart-dev: ## Redémarrer en mode développement
cd backend && uvicorn app:app --reload --host 0.0.0.0 --port 8000 @echo "🔄 Redémarrage de $(PROJECT_NAME) en mode DEVELOPMENT..."
@$(MAKE) stop-dev
@$(MAKE) dev
dev-frontend: ## Lancer le frontend en mode développement logs-dev: ## Voir les logs en mode développement
cd frontend && npm run dev @echo "📝 Logs de $(PROJECT_NAME) en mode DEVELOPMENT..."
@docker compose -f docker-compose.dev.yml logs -f
shell-backend: ## Ouvrir un shell dans le conteneur backend status-dev: ## Voir le statut en mode développement
docker compose exec backend /bin/bash @echo "📊 Statut de $(PROJECT_NAME) en mode DEVELOPMENT..."
@docker compose -f docker-compose.dev.yml ps
shell-db: ## Ouvrir psql dans le conteneur PostgreSQL build-dev: ## Reconstruire en mode développement
docker compose exec postgres psql -U lediscord_user -d lediscord @echo "🔨 Reconstruction de $(PROJECT_NAME) en mode DEVELOPMENT..."
@docker compose -f docker-compose.dev.yml build --no-cache
@echo "✅ Reconstruction terminée"
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 # PRODUCTION (lediscord.com)
# ===========================================
status: ## Afficher le statut des conteneurs prod: ## Démarrer en mode production
docker compose ps @echo "🚀 Démarrage de $(PROJECT_NAME) en mode PRODUCTION..."
@docker compose -f docker-compose.prod.yml up --build -d
@echo "$(PROJECT_NAME) production démarré !"
@echo " Frontend: http://localhost:80"
@echo " Backend: http://localhost:8001"
@echo " API Docs: http://localhost:8001/docs"
stop-prod: ## Arrêter en mode production
@echo "🛑 Arrêt de $(PROJECT_NAME) en mode PRODUCTION..."
@docker compose -f docker-compose.prod.yml down
@echo "$(PROJECT_NAME) production arrêté"
restart-prod: ## Redémarrer en mode production
@echo "🔄 Redémarrage de $(PROJECT_NAME) en mode PRODUCTION..."
@$(MAKE) stop-prod
@$(MAKE) prod
logs-prod: ## Voir les logs en mode production
@echo "📝 Logs de $(PROJECT_NAME) en mode PRODUCTION..."
@docker compose -f docker-compose.prod.yml logs -f
status-prod: ## Voir le statut en mode production
@echo "📊 Statut de $(PROJECT_NAME) en mode PRODUCTION..."
@docker compose -f docker-compose.prod.yml ps
build-prod: ## Reconstruire en mode production
@echo "🔨 Reconstruction de $(PROJECT_NAME) en mode PRODUCTION..."
@docker compose -f docker-compose.prod.yml build --no-cache
@echo "✅ Reconstruction terminée"
# ===========================================
# COMMANDES GLOBALES
# ===========================================
start: local ## Démarrer en mode local (alias)
stop: stop-local ## Arrêter en mode local (alias)
restart: restart-local ## Redémarrer en mode local (alias)
logs: logs-local ## Voir les logs en mode local (alias)
all: ## Démarrer tous les environnements
@echo "🚀 Démarrage de tous les environnements $(PROJECT_NAME)..."
@$(MAKE) local
@$(MAKE) dev
@$(MAKE) prod
@echo "✅ Tous les environnements sont démarrés !"
stop-all: ## Arrêter tous les environnements
@echo "🛑 Arrêt de tous les environnements $(PROJECT_NAME)..."
@$(MAKE) stop-local
@$(MAKE) stop-dev
@$(MAKE) stop-prod
@echo "✅ Tous les environnements sont arrêtés !"
status: ## Voir le statut de tous les environnements
@echo "📊 Statut de tous les environnements $(PROJECT_NAME)..."
@echo ""
@echo "🔵 LOCAL:"
@$(MAKE) status-local
@echo ""
@echo "🟡 DEVELOPMENT:"
@$(MAKE) status-dev
@echo ""
@echo "🟢 PRODUCTION:"
@$(MAKE) status-prod
clean: ## Nettoyer tous les environnements (ATTENTION: supprime les volumes)
@echo "🧹 Nettoyage complet de $(PROJECT_NAME)..."
@echo "⚠️ ATTENTION: Cette action supprimera tous les volumes et données !"
@read -p "Êtes-vous sûr ? (y/N): " -n 1 -r; \
if [[ $$REPLY =~ ^[Yy]$$ ]]; then \
echo ""; \
echo "🧹 Nettoyage en cours..."; \
$(MAKE) stop-all; \
docker compose -f docker-compose.local.yml down -v --remove-orphans; \
docker compose -f docker-compose.dev.yml down -v --remove-orphans; \
docker compose -f docker-compose.prod.yml down -v --remove-orphans; \
docker system prune -f; \
echo "✅ Nettoyage terminé"; \
else \
echo ""; \
echo "❌ Nettoyage annulé"; \
fi
# ===========================================
# UTILITAIRES
# ===========================================
test: ## Tester la configuration des environnements
@echo "🧪 Test de la configuration $(PROJECT_NAME)..."
@if [ -f "test-environments.sh" ]; then \
./test-environments.sh; \
else \
echo "❌ Script de test non trouvé"; \
fi
verify: ## Vérifier le mode Zero-Config
@echo "🔍 Vérification Zero-Config $(PROJECT_NAME)..."
@if [ -f "verify-zero-config.sh" ]; then \
./verify-zero-config.sh; \
else \
echo "❌ Script de vérification non trouvé"; \
fi
check: ## Vérifier la configuration des fichiers d'environnement
@echo "🔍 Vérification des fichiers d'environnement..."
@echo ""
@echo "📁 Backend:"
@for env in local development production; do \
if [ -f "backend/.env.$$env" ]; then \
echo "✅ backend/.env.$$env"; \
else \
echo "❌ backend/.env.$$env manquant"; \
fi; \
done
@echo ""
@echo "📁 Frontend:"
@for env in local development production; do \
if [ -f "frontend/.env.$$env" ]; then \
echo "✅ frontend/.env.$$env"; \
else \
echo "❌ frontend/.env.$$env manquant"; \
fi; \
done
@echo ""
@echo "📁 Docker Compose:"
@for env in local dev prod; do \
if [ -f "docker-compose.$$env.yml" ]; then \
echo "✅ docker-compose.$$env.yml"; \
else \
echo "❌ docker-compose.$$env.yml manquant"; \
fi; \
done

0
README.md Normal file → Executable file
View File

View File

@@ -1,29 +0,0 @@
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"]

61
backend/Dockerfile.dev Executable file
View File

@@ -0,0 +1,61 @@
FROM python:3.11-slim
LABEL maintainer="LeDiscord Team"
LABEL version="1.0"
LABEL description="LeDiscord Backend - Environnement Développement"
WORKDIR /app
# Env dev
ENV ENVIRONMENT=development \
PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# Dépendances système (minifiées)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
libmagic1 \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender1 \
libgomp1 \
curl \
&& rm -rf /var/lib/apt/lists/*
# Requirements (cache friendly)
COPY requirements.txt .
# Uvicorn[standard] apporte watchfiles pour un --reload rapide/stable en dev
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt \
&& pip install --no-cache-dir "uvicorn[standard]"
# Dossiers utiles
RUN mkdir -p /app/uploads /app/logs
# Code source
COPY . .
# Env dev
COPY .env.development .env
# Permissions
RUN chmod -R 755 /app
EXPOSE 8000
# Hot-reload + respect des en-têtes proxy (utile si tu testes derrière Traefik en dev)
# Astuce: on exclut uploads/logs du reload pour éviter les restarts inutiles
CMD ["uvicorn", "app:app", \
"--reload", \
"--reload-exclude", "uploads/*", \
"--reload-exclude", "logs/*", \
"--host", "0.0.0.0", \
"--port", "8000", \
"--log-level", "debug", \
"--proxy-headers", \
"--forwarded-allow-ips=*"]

75
backend/Dockerfile.prod Executable file
View File

@@ -0,0 +1,75 @@
# Multi-stage build pour la production
FROM python:3.11-slim AS builder
LABEL maintainer="LeDiscord Team"
LABEL version="1.0"
LABEL description="LeDiscord Backend - Environnement Production"
WORKDIR /app
# Env
ENV ENVIRONMENT=production \
PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# Dépendances de build
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Requirements
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# ---- Stage runtime ----
FROM python:3.11-slim AS production
WORKDIR /app
# Env runtime
ENV ENVIRONMENT=production \
PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# Dépendances runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
libmagic1 \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender1 \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
# Paquets Python depuis le builder
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Dossiers appli
RUN mkdir -p /app/uploads /app/logs
# Code source
COPY . .
# Env prod (attention à ce que tu y mets)
COPY .env.production .env
# Utilisateur non-root
RUN groupadd -r lediscord && useradd -r -g lediscord lediscord \
&& chown -R lediscord:lediscord /app
USER lediscord
EXPOSE 8000
# Healthcheck (optionnel mais pratique)
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import socket; s=socket.socket(); s.settimeout(2); s.connect(('127.0.0.1',8000)); s.close()" || exit 1
# Démarrage uvicorn — pas de guillemets imbriqués ici
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--log-level", "info", "--proxy-headers", "--forwarded-allow-ips=*"]

0
backend/__init__.py Normal file → Executable file
View File

0
backend/api/routers/__init__.py Normal file → Executable file
View File

0
backend/api/routers/admin.py Normal file → Executable file
View File

0
backend/api/routers/albums.py Normal file → Executable file
View File

0
backend/api/routers/auth.py Normal file → Executable file
View File

14
backend/api/routers/events.py Normal file → Executable file
View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, joinedload
from typing import List from typing import List
from datetime import datetime from datetime import datetime
from config.database import get_db from config.database import get_db
@@ -64,7 +64,7 @@ async def get_events(
upcoming: bool = None upcoming: bool = None
): ):
"""Get all events, optionally filtered by upcoming status.""" """Get all events, optionally filtered by upcoming status."""
query = db.query(Event) query = db.query(Event).options(joinedload(Event.creator))
if upcoming is True: if upcoming is True:
# Only upcoming events # Only upcoming events
@@ -83,7 +83,7 @@ async def get_upcoming_events(
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
"""Get only upcoming events.""" """Get only upcoming events."""
events = db.query(Event).filter( events = db.query(Event).options(joinedload(Event.creator)).filter(
Event.date >= datetime.utcnow() Event.date >= datetime.utcnow()
).order_by(Event.date).all() ).order_by(Event.date).all()
return [format_event_response(event, db) for event in events] return [format_event_response(event, db) for event in events]
@@ -94,7 +94,7 @@ async def get_past_events(
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
"""Get only past events.""" """Get only past events."""
events = db.query(Event).filter( events = db.query(Event).options(joinedload(Event.creator)).filter(
Event.date < datetime.utcnow() Event.date < datetime.utcnow()
).order_by(Event.date.desc()).all() ).order_by(Event.date.desc()).all()
return [format_event_response(event, db) for event in events] return [format_event_response(event, db) for event in events]
@@ -204,6 +204,9 @@ def format_event_response(event: Event, db: Session) -> dict:
participations = [] participations = []
present_count = absent_count = maybe_count = pending_count = 0 present_count = absent_count = maybe_count = pending_count = 0
# Get creator user directly
creator = db.query(User).filter(User.id == event.creator_id).first()
for p in event.participations: for p in event.participations:
user = db.query(User).filter(User.id == p.user_id).first() user = db.query(User).filter(User.id == p.user_id).first()
participations.append({ participations.append({
@@ -235,7 +238,8 @@ def format_event_response(event: Event, db: Session) -> dict:
"cover_image": event.cover_image, "cover_image": event.cover_image,
"created_at": event.created_at, "created_at": event.created_at,
"updated_at": event.updated_at, "updated_at": event.updated_at,
"creator_name": event.creator.full_name, "creator_name": creator.full_name if creator else "Unknown",
"creator_avatar": creator.avatar_url if creator else None,
"participations": participations, "participations": participations,
"present_count": present_count, "present_count": present_count,
"absent_count": absent_count, "absent_count": absent_count,

0
backend/api/routers/information.py Normal file → Executable file
View File

0
backend/api/routers/notifications.py Normal file → Executable file
View File

0
backend/api/routers/posts.py Normal file → Executable file
View File

24
backend/api/routers/settings.py Normal file → Executable file
View File

@@ -40,7 +40,7 @@ async def get_upload_limits(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user) current_user: User = Depends(get_admin_user)
): ):
"""Get current upload limits configuration.""" """Get current upload limits configuration (admin only)."""
settings = db.query(SystemSettings).filter( settings = db.query(SystemSettings).filter(
SystemSettings.category == "uploads" SystemSettings.category == "uploads"
).all() ).all()
@@ -61,6 +61,28 @@ async def get_upload_limits(
allowed_video_types=settings_dict.get("allowed_video_types", "video/mp4,video/mpeg,video/quicktime,video/webm").split(",") allowed_video_types=settings_dict.get("allowed_video_types", "video/mp4,video/mpeg,video/quicktime,video/webm").split(",")
) )
@router.get("/public/upload-limits", response_model=UploadLimitsResponse)
async def get_public_upload_limits(
db: Session = Depends(get_db)
):
"""Get current upload limits configuration (public endpoint - no auth required)."""
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}
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) @router.post("/", response_model=SystemSettingResponse)
async def create_setting( async def create_setting(
setting_data: SystemSettingCreate, setting_data: SystemSettingCreate,

0
backend/api/routers/stats.py Normal file → Executable file
View File

0
backend/api/routers/tickets.py Normal file → Executable file
View File

0
backend/api/routers/users.py Normal file → Executable file
View File

0
backend/api/routers/vlogs.py Normal file → Executable file
View File

2
backend/app.py Normal file → Executable file
View File

@@ -194,7 +194,7 @@ app = FastAPI(
# Configure CORS # Configure CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.CORS_ORIGINS, allow_origins=settings.CORS_ORIGINS_LIST,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

0
backend/config/database.py Normal file → Executable file
View File

127
backend/config/settings.py Normal file → Executable file
View File

@@ -1,37 +1,118 @@
from typing import List from typing import List
import os import os
from pathlib import Path
class Settings: class Settings:
# Database """Configuration principale avec variables d'environnement obligatoires"""
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://lediscord_user:lediscord_password@postgres:5432/lediscord")
# JWT # Environnement - OBLIGATOIRE
JWT_SECRET_KEY: str = "your-super-secret-jwt-key-change-me" ENVIRONMENT: str = os.getenv("ENVIRONMENT")
JWT_ALGORITHM: str = "HS256" if not ENVIRONMENT:
JWT_EXPIRATION_MINUTES: int = 10080 # 7 days raise ValueError("ENVIRONMENT variable is required. Use .env.local, .env.development, or .env.production")
# Debug et reload
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
RELOAD: bool = os.getenv("RELOAD", "false").lower() == "true"
# Database - OBLIGATOIRE
DATABASE_URL: str = os.getenv("DATABASE_URL")
if not DATABASE_URL:
raise ValueError("DATABASE_URL variable is required")
# JWT - OBLIGATOIRE
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY")
if not JWT_SECRET_KEY:
raise ValueError("JWT_SECRET_KEY variable is required")
JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
JWT_EXPIRATION_MINUTES: int = int(os.getenv("JWT_EXPIRATION_MINUTES", "10080"))
# Upload # Upload
UPLOAD_PATH: str = "/app/uploads" UPLOAD_PATH: str = os.getenv("UPLOAD_PATH", "./uploads")
MAX_UPLOAD_SIZE: int = 100 * 1024 * 1024 # 100MB MAX_UPLOAD_SIZE: int = int(os.getenv("MAX_UPLOAD_SIZE", "104857600"))
ALLOWED_IMAGE_TYPES: List[str] = ["image/jpeg", "image/png", "image/gif", "image/webp"] ALLOWED_IMAGE_TYPES: List[str] = os.getenv("ALLOWED_IMAGE_TYPES", "image/jpeg,image/png,image/gif,image/webp").split(",")
ALLOWED_VIDEO_TYPES: List[str] = ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"] ALLOWED_VIDEO_TYPES: List[str] = os.getenv("ALLOWED_VIDEO_TYPES", "video/mp4,video/mpeg,video/quicktime,video/webm").split(",")
# CORS - Fixed list, no environment parsing # CORS - OBLIGATOIRE
CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"] CORS_ORIGINS: str = os.getenv("CORS_ORIGINS")
if not CORS_ORIGINS:
raise ValueError("CORS_ORIGINS variable is required")
CORS_ORIGINS_LIST: List[str] = CORS_ORIGINS.split(",")
# Email # Email
SMTP_HOST: str = "smtp.gmail.com" SMTP_HOST: str = os.getenv("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT: int = 587 SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER: str = "" SMTP_USER: str = os.getenv("SMTP_USER", "")
SMTP_PASSWORD: str = "" SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM: str = "noreply@lediscord.com" SMTP_FROM: str = os.getenv("SMTP_FROM", "noreply@lediscord.com")
# Admin # Admin - OBLIGATOIRE
ADMIN_EMAIL: str = "admin@lediscord.com" ADMIN_EMAIL: str = os.getenv("ADMIN_EMAIL")
ADMIN_PASSWORD: str = "admin123" if not ADMIN_EMAIL:
raise ValueError("ADMIN_EMAIL variable is required")
ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD")
if not ADMIN_PASSWORD:
raise ValueError("ADMIN_PASSWORD variable is required")
# App # App
APP_NAME: str = "LeDiscord" APP_NAME: str = os.getenv("APP_NAME", "LeDiscord")
APP_URL: str = "http://localhost:5173" APP_URL: str = os.getenv("APP_URL", "http://localhost:5173")
settings = Settings() # Logging
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO" if ENVIRONMENT == "production" else "DEBUG")
# Performance
WORKERS: int = int(os.getenv("WORKERS", "1" if ENVIRONMENT in ["local", "development"] else "4"))
# Sécurité (production uniquement)
SECURE_COOKIES: bool = os.getenv("SECURE_COOKIES", "false").lower() == "true"
SECURE_HEADERS: bool = os.getenv("SECURE_HEADERS", "false").lower() == "true"
def __str__(self) -> str:
"""Représentation lisible de la configuration"""
return f"""
🔧 Configuration LeDiscord - Environnement: {self.ENVIRONMENT.upper()}
📊 Debug: {self.DEBUG}
🔄 Reload: {self.RELOAD}
🌐 CORS Origins: {', '.join(self.CORS_ORIGINS_LIST)}
🗄️ Database: {self.DATABASE_URL.split('@')[1] if '@' in self.DATABASE_URL else 'Unknown'}
📁 Upload Path: {self.UPLOAD_PATH}
📝 Log Level: {self.LOG_LEVEL}
🚀 Workers: {self.WORKERS}
"""
def validate(self) -> bool:
"""Valide la configuration"""
required_vars = [
"ENVIRONMENT",
"DATABASE_URL",
"JWT_SECRET_KEY",
"CORS_ORIGINS",
"ADMIN_EMAIL",
"ADMIN_PASSWORD"
]
for var in required_vars:
if not getattr(self, var):
print(f"❌ Configuration invalide: {var} est manquant")
return False
print("✅ Configuration validée avec succès")
return True
# Instance globale
try:
settings = Settings()
except ValueError as e:
print(f"❌ Erreur de configuration: {e}")
print("💡 Assurez-vous d'utiliser un fichier d'environnement spécifique:")
print(" - .env.local pour le développement local")
print(" - .env.development pour l'environnement de développement")
print(" - .env.production pour la production")
raise
# Validation automatique au démarrage
if __name__ == "__main__":
print(settings)
settings.validate()

0
backend/models/__init__.py Normal file → Executable file
View File

0
backend/models/album.py Normal file → Executable file
View File

0
backend/models/event.py Normal file → Executable file
View File

0
backend/models/information.py Normal file → Executable file
View File

0
backend/models/notification.py Normal file → Executable file
View File

0
backend/models/post.py Normal file → Executable file
View File

0
backend/models/settings.py Normal file → Executable file
View File

0
backend/models/ticket.py Normal file → Executable file
View File

0
backend/models/user.py Normal file → Executable file
View File

0
backend/models/vlog.py Normal file → Executable file
View File

0
backend/requirements.txt Normal file → Executable file
View File

0
backend/schemas/__init__.py Normal file → Executable file
View File

0
backend/schemas/album.py Normal file → Executable file
View File

1
backend/schemas/event.py Normal file → Executable file
View File

@@ -40,6 +40,7 @@ class EventResponse(EventBase):
id: int id: int
creator_id: int creator_id: int
creator_name: str creator_name: str
creator_avatar: Optional[str] = None
cover_image: Optional[str] cover_image: Optional[str]
created_at: datetime created_at: datetime
participations: List[ParticipationResponse] = [] participations: List[ParticipationResponse] = []

0
backend/schemas/information.py Normal file → Executable file
View File

0
backend/schemas/notification.py Normal file → Executable file
View File

0
backend/schemas/post.py Normal file → Executable file
View File

0
backend/schemas/settings.py Normal file → Executable file
View File

0
backend/schemas/ticket.py Normal file → Executable file
View File

0
backend/schemas/user.py Normal file → Executable file
View File

0
backend/schemas/vlog.py Normal file → Executable file
View File

84
backend/test_config.py Executable file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
Script de test pour vérifier la configuration des environnements
Usage: python test_config.py [local|development|production]
"""
import os
import sys
from pathlib import Path
def test_environment(env_name: str):
"""Teste la configuration d'un environnement spécifique"""
print(f"🔍 Test de la configuration {env_name.upper()}")
print("=" * 50)
# Définir l'environnement
os.environ["ENVIRONMENT"] = env_name
try:
# Importer la configuration
from config.settings import settings
print(f"✅ Configuration chargée avec succès")
print(f" Environnement détecté: {settings.ENVIRONMENT}")
print(f" Debug: {settings.DEBUG}")
print(f" Reload: {settings.RELOAD}")
print(f" Log Level: {settings.LOG_LEVEL}")
print(f" Workers: {settings.WORKERS}")
print(f" CORS Origins: {', '.join(settings.CORS_ORIGINS)}")
print(f" Upload Path: {settings.UPLOAD_PATH}")
print(f" Database: {settings.DATABASE_URL.split('@')[1] if '@' in settings.DATABASE_URL else 'Unknown'}")
# Validation
if settings.validate():
print(f"✅ Configuration {env_name} validée avec succès")
else:
print(f"❌ Configuration {env_name} invalide")
except Exception as e:
print(f"❌ Erreur lors du chargement de la configuration {env_name}: {e}")
return False
print()
return True
def main():
"""Fonction principale"""
print("🚀 Test de configuration LeDiscord Backend")
print("=" * 60)
# Vérifier que nous sommes dans le bon répertoire
if not Path("config/settings.py").exists():
print("❌ Erreur: Ce script doit être exécuté depuis le répertoire backend/")
sys.exit(1)
# Test des environnements
environments = ["local", "development", "production"]
if len(sys.argv) > 1:
env_arg = sys.argv[1].lower()
if env_arg in environments:
environments = [env_arg]
else:
print(f"❌ Environnement invalide: {env_arg}")
print(f" Environnements valides: {', '.join(environments)}")
sys.exit(1)
success_count = 0
for env in environments:
if test_environment(env):
success_count += 1
print("=" * 60)
print(f"📊 Résultats: {success_count}/{len(environments)} configurations valides")
if success_count == len(environments):
print("🎉 Toutes les configurations sont valides !")
return 0
else:
print("⚠️ Certaines configurations ont des problèmes")
return 1
if __name__ == "__main__":
sys.exit(main())

0
backend/utils/email.py Normal file → Executable file
View File

0
backend/utils/init_db.py Normal file → Executable file
View File

0
backend/utils/notification_service.py Normal file → Executable file
View File

0
backend/utils/security.py Normal file → Executable file
View File

0
backend/utils/settings_service.py Normal file → Executable file
View File

0
backend/utils/video_utils.py Normal file → Executable file
View File

30
backup/_data/Dockerfile.dev Executable file
View File

@@ -0,0 +1,30 @@
FROM node:18-alpine
# Métadonnées
LABEL maintainer="LeDiscord Team"
LABEL version="1.0"
LABEL description="LeDiscord Frontend - Environnement Development"
WORKDIR /app
# Variables d'environnement pour le développement
ENV NODE_ENV=development
ENV VITE_ENVIRONMENT=development
# Copy package files first for better caching
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy application files
COPY . .
# Copy development environment file
COPY .env.development .env
# Expose port
EXPOSE 5173
# Run the application in development mode
CMD ["npm", "run", "dev"]

50
backup/_data/Dockerfile.prod Executable file
View File

@@ -0,0 +1,50 @@
# Multi-stage build pour la production
FROM node:18-alpine AS builder
# Métadonnées
LABEL maintainer="LeDiscord Team"
LABEL version="1.0"
LABEL description="LeDiscord Frontend - Build Production"
WORKDIR /app
# Variables d'environnement pour la production
ENV NODE_ENV=production
ENV VITE_ENVIRONMENT=production
# Copy package files first for better caching
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy application files
COPY . .
# Copy production environment file
COPY .env.production .env
# Debug: Vérifier le contenu du fichier .env
RUN echo "=== Contenu du fichier .env ===" && cat .env
RUN echo "=== Variables d'environnement ===" && env | grep VITE
# Charger les variables d'environnement depuis le fichier .env (en filtrant les commentaires)
RUN export $(cat .env | grep -v '^#' | grep -v '^$' | xargs) && echo "=== Variables après export ===" && env | grep VITE
# Build the application avec les variables d'environnement chargées
RUN export $(cat .env | grep -v '^#' | grep -v '^$' | xargs) && npm run build
# Production stage
FROM nginx:alpine
# Copy built application
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx-frontend.conf /etc/nginx/nginx.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

16
backup/_data/index.html Executable 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>

View File

@@ -0,0 +1,64 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Basic settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Static files with long cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Vue Router - SPA fallback
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Health check
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
}

3396
backup/_data/package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

29
backup/_data/package.json Executable file
View File

@@ -0,0 +1,29 @@
{
"name": "lediscord-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^10.6.1",
"axios": "^1.6.2",
"date-fns": "^2.30.0",
"lucide-vue-next": "^0.294.0",
"pinia": "^2.1.7",
"video.js": "^8.6.1",
"vue": "^3.3.8",
"vue-router": "^4.2.5",
"vue-toastification": "^2.0.0-rc.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"terser": "^5.43.1",
"vite": "^5.0.0"
}
}

6
backup/_data/postcss.config.js Executable file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

22
backup/_data/src/App.vue Executable file
View File

@@ -0,0 +1,22 @@
<template>
<div id="app">
<component :is="layout">
<router-view />
<EnvironmentDebug />
</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'
import EnvironmentDebug from '@/components/EnvironmentDebug.vue'
const route = useRoute()
const layout = computed(() => {
return route.meta.layout === 'auth' ? AuthLayout : DefaultLayout
})
</script>

View File

@@ -0,0 +1,62 @@
<template>
<div v-if="showDebug" class="fixed bottom-4 right-4 bg-black bg-opacity-75 text-white p-3 rounded-lg text-xs font-mono z-50 max-w-xs">
<div class="flex items-center space-x-2 mb-2">
<span class="w-2 h-2 rounded-full" :class="environmentColor"></span>
<span class="font-bold">{{ environment.toUpperCase() }}</span>
</div>
<div class="space-y-1 text-gray-300">
<div>API: {{ apiUrl }}</div>
<div>App: {{ appUrl }}</div>
<div>Build: {{ buildTime }}</div>
<div>Router: {{ routerStatus }}</div>
<div class="text-yellow-400">VITE_API_URL: {{ viteApiUrl }}</div>
<div class="text-yellow-400">NODE_ENV: {{ nodeEnv }}</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
const routerStatus = ref('Initializing...')
// Utiliser directement les variables d'environnement Vite
const environment = computed(() => import.meta.env.VITE_ENVIRONMENT || 'local')
const apiUrl = computed(() => import.meta.env.VITE_API_URL || 'Non défini')
const appUrl = computed(() => import.meta.env.VITE_APP_URL || 'Non défini')
const viteApiUrl = computed(() => import.meta.env.VITE_API_URL || 'Non défini')
const nodeEnv = computed(() => import.meta.env.NODE_ENV || 'Non défini')
const buildTime = computed(() => new Date().toLocaleTimeString())
const environmentColor = computed(() => {
switch (environment.value) {
case 'local': return 'bg-green-500'
case 'development': return 'bg-yellow-500'
case 'production': return 'bg-red-500'
default: return 'bg-gray-500'
}
})
const showDebug = computed(() => {
// Toujours afficher en développement, conditionnellement en production
return environment.value !== 'production' || import.meta.env.DEV
})
onMounted(() => {
// Attendre un peu que le router soit initialisé
setTimeout(() => {
routerStatus.value = 'Ready'
}, 300)
// Debug des variables d'environnement
console.log('🔍 EnvironmentDebug - Variables d\'environnement:')
console.log(' VITE_ENVIRONMENT:', import.meta.env.VITE_ENVIRONMENT)
console.log(' VITE_API_URL:', import.meta.env.VITE_API_URL)
console.log(' VITE_APP_URL:', import.meta.env.VITE_APP_URL)
console.log(' NODE_ENV:', import.meta.env.NODE_ENV)
})
</script>
<style scoped>
/* Styles spécifiques au composant */
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="text-center">
<img
v-if="variant === 'pulse'"
src="/logo_lediscord.png"
alt="LeDiscord Logo"
:class="[
'mx-auto animate-pulse',
size === 'small' ? 'h-8 w-auto' :
size === 'medium' ? 'h-12 w-auto' :
'h-16 w-auto'
]"
>
<img
v-else-if="variant === 'spinner'"
src="/logo_lediscord.png"
alt="LeDiscord Logo"
:class="[
'mx-auto animate-spin',
size === 'small' ? 'h-5 w-auto' :
size === 'medium' ? 'h-6 w-auto' :
'h-8 w-auto'
]"
>
<p v-if="showText" class="text-sm text-gray-600 mt-2">{{ text || 'Chargement...' }}</p>
</div>
</template>
<script setup>
defineProps({
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large'].includes(value)
},
variant: {
type: String,
default: 'pulse',
validator: (value) => ['pulse', 'spinner'].includes(value)
},
text: {
type: String,
default: ''
},
showText: {
type: Boolean,
default: true
}
})
</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"
>
<LoadingLogo v-if="submitting" variant="spinner" size="small" :showText="false" />
{{ submitting ? 'Envoi...' : 'Envoyer le ticket' }}
</button>
</div>
</form>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router'
import axios from '@/utils/axios'
import { useAuthStore } from '@/stores/auth'
import { Plus, X, Send } from 'lucide-vue-next'
import LoadingLogo from '@/components/LoadingLogo.vue'
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,191 @@
<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'
import axios 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 axios.post(`/api/vlogs/${props.vlogId}/comment`, { content: newComment.value.trim() })
if (response.data) {
emit('comment-added', response.data.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 axios.delete(`/api/vlogs/${props.vlogId}/comment/${commentId}`)
if (response.data) {
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,25 @@
<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-4">
<h1 class="text-4xl font-bold bg-gradient-primary bg-clip-text text-transparent mb-0">LeDiscord</h1>
<p class="text-secondary-600">Notre espace privé</p>
</div>
<div class="bg-white rounded-2xl shadow-xl p-8">
<slot />
</div>
<div class="text-center mt-4">
<img
src="/logo_lediscord.png"
alt="LeDiscord Logo"
class="mx-auto h-48 w-auto mb-0 drop-shadow-lg"
>
</div>
</div>
</div>
</template>
<script setup>
</script>

View File

@@ -0,0 +1,289 @@
<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">
<img
src="/logo_lediscord.png"
alt="LeDiscord Logo"
class="h-8 w-auto mr-2"
>
<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>

48
backup/_data/src/main.js Executable file
View File

@@ -0,0 +1,48 @@
import { createApp, nextTick } 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'
// Créer l'application
const app = createApp(App)
// Créer et configurer Pinia
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
}
// Installer les plugins dans l'ordre correct
// IMPORTANT: Pinia doit être installé AVANT le router
app.use(pinia)
app.use(Toast, toastOptions)
// Maintenant installer le router
app.use(router)
// Attendre que le router soit prêt avant de monter l'app
router.isReady().then(() => {
app.mount('#app')
console.log('🚀 Application montée avec succès')
}).catch((error) => {
console.error('❌ Erreur lors du montage de l\'application:', error)
// Fallback: monter l'app même en cas d'erreur
app.mount('#app')
})

134
backup/_data/src/router/index.js Executable file
View File

@@ -0,0 +1,134 @@
import { createRouter, createWebHistory } from 'vue-router'
// 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 simplifié - la logique d'authentification sera gérée dans les composants
router.beforeEach((to, from, next) => {
// Pour l'instant, on laisse passer toutes les routes
// La logique d'authentification sera gérée dans les composants individuels
next()
})
export default router

197
backup/_data/src/stores/auth.js Executable 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
backup/_data/src/style.css Executable 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;
}
}

166
backup/_data/src/utils/axios.js Executable file
View File

@@ -0,0 +1,166 @@
import axios from 'axios'
import { useToast } from 'vue-toastification'
import router from '@/router'
// Configuration de l'URL de base selon l'environnement
const getBaseURL = () => {
// Récupérer l'environnement depuis les variables Vite
const environment = import.meta.env.VITE_ENVIRONMENT || 'local'
// Log de debug pour l'environnement
console.log(`🌍 Frontend - Environnement détecté: ${environment}`)
console.log(`🔗 API URL: ${import.meta.env.VITE_API_URL}`)
console.log(`🔧 VITE_ENVIRONMENT: ${import.meta.env.VITE_ENVIRONMENT}`)
console.log(`🔧 NODE_ENV: ${import.meta.env.NODE_ENV}`)
// Utiliser directement la variable d'environnement VITE_API_URL
// qui est déjà configurée correctement pour chaque environnement
const apiUrl = import.meta.env.VITE_API_URL
if (!apiUrl) {
console.warn('⚠️ VITE_API_URL non définie, utilisation de la valeur par défaut')
// Valeurs par défaut selon l'environnement
switch (environment) {
case 'production':
return 'https://api.lediscord.com'
case 'development':
return 'https://api-dev.lediscord.com' // API externe HTTPS en développement
case 'local':
default:
return 'http://localhost:8000'
}
}
console.log(`🎯 URL finale utilisée: ${apiUrl}`)
return apiUrl
}
// Configuration de l'instance axios
const instance = axios.create({
baseURL: getBaseURL(),
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// Log de la configuration
console.log(`🚀 Axios configuré avec l'URL de base: ${getBaseURL()}`)
// Request interceptor
instance.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// Log des requêtes en développement
if (import.meta.env.DEV) {
console.log(`📤 Requête ${config.method?.toUpperCase()} vers: ${config.url}`)
}
return config
},
error => {
console.error('❌ Erreur dans l\'intercepteur de requête:', error)
return Promise.reject(error)
}
)
// Response interceptor
instance.interceptors.response.use(
response => {
// Log des réponses en développement
if (import.meta.env.DEV) {
console.log(`📥 Réponse ${response.status} de: ${response.config.url}`)
}
return response
},
error => {
const toast = useToast()
// Log détaillé des erreurs
console.error('❌ Erreur API:', {
status: error.response?.status,
statusText: error.response?.statusText,
url: error.config?.url,
method: error.config?.method,
data: error.response?.data
})
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')
} else if (error.code === 'ECONNABORTED') {
toast.error('Délai d\'attente dépassé, veuillez réessayer')
} else if (!error.response) {
toast.error('Erreur de connexion, vérifiez votre connexion internet')
}
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 = getBaseURL()
// 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}`
}
// Fonction utilitaire pour obtenir l'environnement actuel
export function getCurrentEnvironment() {
return import.meta.env.VITE_ENVIRONMENT || 'local'
}
// Fonction utilitaire pour vérifier si on est en production
export function isProduction() {
return getCurrentEnvironment() === 'production'
}
// Fonction utilitaire pour vérifier si on est en développement
export function isDevelopment() {
return getCurrentEnvironment() === 'development'
}
// Fonction utilitaire pour vérifier si on est en local
export function isLocal() {
return getCurrentEnvironment() === 'local'
}
// Fonction utilitaire pour obtenir l'URL de l'API
export function getApiUrl() {
return import.meta.env.VITE_API_URL || getBaseURL()
}
// Fonction utilitaire pour obtenir l'URL de l'application
export function getAppUrl() {
return import.meta.env.VITE_APP_URL || window.location.origin
}

2127
backup/_data/src/views/Admin.vue Executable 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">
<LoadingLogo size="large" text="Chargement de l'album..." />
</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'
import LoadingLogo from '@/components/LoadingLogo.vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const toast = useToast()
const album = ref(null)
const loading = ref(true)
const updating = ref(false)
const uploading = ref(false)
const showEditModal = ref(false)
const showUploadModal = ref(false)
const viewMode = ref('grid')
const selectedMedia = ref(null)
const editForm = ref({
title: '',
description: ''
})
const newMedia = ref([])
const canEdit = computed(() =>
album.value && (album.value.creator_id === authStore.user?.id || authStore.user?.is_admin)
)
const totalSize = computed(() =>
album.value?.media?.reduce((sum, media) => sum + (media.file_size || 0), 0) || 0
)
function formatDate(date) {
return format(new Date(date), 'dd MMMM yyyy', { locale: fr })
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
async function fetchAlbum() {
try {
const response = await axios.get(`/api/albums/${route.params.id}`)
album.value = response.data
// Initialize edit form
editForm.value = {
title: album.value.title,
description: album.value.description || ''
}
} catch (error) {
toast.error('Erreur lors du chargement de l\'album')
console.error('Error fetching album:', error)
} finally {
loading.value = false
}
}
async function updateAlbum() {
updating.value = true
try {
const response = await axios.put(`/api/albums/${album.value.id}`, editForm.value)
album.value = response.data
showEditModal.value = false
toast.success('Album mis à jour')
} catch (error) {
toast.error('Erreur lors de la mise à jour')
}
updating.value = false
}
async function deleteAlbum() {
if (!confirm('Êtes-vous sûr de vouloir supprimer cet album ?')) return
try {
await axios.delete(`/api/albums/${album.value.id}`)
toast.success('Album supprimé')
router.push('/albums')
} catch (error) {
toast.error('Erreur lors de la suppression')
}
}
async function handleMediaChange(event) {
const files = Array.from(event.target.files)
for (const file of files) {
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
toast.error(`${file.name} n'est pas un fichier image ou vidéo valide`)
continue
}
if (file.size > 100 * 1024 * 1024) {
toast.error(`${file.name} est trop volumineux (max 100MB)`)
continue
}
const media = {
file: file,
name: file.name,
type: file.type.startsWith('image/') ? 'image' : 'video',
preview: URL.createObjectURL(file)
}
newMedia.value.push(media)
}
event.target.value = ''
}
function removeMedia(index) {
const media = newMedia.value[index]
if (media.preview && media.preview.startsWith('blob:')) {
URL.revokeObjectURL(media.preview)
}
newMedia.value.splice(index, 1)
}
async function uploadMedia() {
if (newMedia.value.length === 0) return
uploading.value = true
try {
const formData = new FormData()
newMedia.value.forEach(media => {
formData.append('files', media.file)
})
await axios.post(`/api/albums/${album.value.id}/media`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
// Refresh album data
await fetchAlbum()
showUploadModal.value = false
newMedia.value.forEach(media => {
if (media.preview && media.preview.startsWith('blob:')) {
URL.revokeObjectURL(media.preview)
}
})
newMedia.value = []
toast.success('Médias ajoutés avec succès')
} catch (error) {
toast.error('Erreur lors de l\'upload')
}
uploading.value = false
}
async function deleteMedia(mediaId) {
if (!confirm('Êtes-vous sûr de vouloir supprimer ce média ?')) return
try {
await axios.delete(`/api/albums/${album.value.id}/media/${mediaId}`)
await fetchAlbum()
toast.success('Média supprimé')
} catch (error) {
toast.error('Erreur lors de la suppression')
}
}
async function toggleMediaLike(media) {
try {
const response = await axios.post(`/api/albums/${album.value.id}/media/${media.id}/like`)
media.is_liked = response.data.is_liked
media.likes_count = response.data.likes_count
toast.success('Like mis à jour')
} catch (error) {
toast.error('Erreur lors de la mise à jour du like')
}
}
async function toggleMediaLikeFromViewer(media) {
try {
const response = await axios.post(`/api/albums/${album.value.id}/media/${media.id}/like`)
media.is_liked = response.data.is_liked
media.likes_count = response.data.likes_count
toast.success('Like mis à jour')
} catch (error) {
toast.error('Erreur lors de la mise à jour du like')
}
}
function openMediaViewer(media) {
selectedMedia.value = media
// Add keyboard event listeners
document.addEventListener('keydown', handleKeyboardNavigation)
}
function closeMediaViewer() {
selectedMedia.value = null
// Remove keyboard event listeners
document.removeEventListener('keydown', handleKeyboardNavigation)
}
function handleKeyboardNavigation(event) {
if (!selectedMedia.value) return
switch (event.key) {
case 'Escape':
closeMediaViewer()
break
case 'ArrowLeft':
if (album.value.media.length > 1) {
previousMedia()
}
break
case 'ArrowRight':
if (album.value.media.length > 1) {
nextMedia()
}
break
}
}
function previousMedia() {
const currentIndex = album.value.media.findIndex(media => media.id === selectedMedia.value.id)
if (currentIndex > 0) {
selectedMedia.value = album.value.media[currentIndex - 1]
}
}
function nextMedia() {
const currentIndex = album.value.media.findIndex(media => media.id === selectedMedia.value.id)
if (currentIndex < album.value.media.length - 1) {
selectedMedia.value = album.value.media[currentIndex + 1]
}
}
function getCurrentMediaIndex() {
if (!selectedMedia.value || !album.value) return 0
return album.value.media.findIndex(media => media.id === selectedMedia.value.id)
}
function getOptimalMediaSize() {
if (!selectedMedia.value) return {}
// Utilisation MAXIMALE de l'espace disponible
if (selectedMedia.value.media_type === 'image') {
// Images : 100% de l'écran avec juste une petite marge
return {
'max-width': '98vw',
'max-height': '96vh',
'width': 'auto',
'height': 'auto',
'object-fit': 'contain'
}
}
// Vidéos : presque plein écran
return {
'max-width': '96vw',
'max-height': '94vh',
'width': 'auto',
'height': 'auto'
}
}
onMounted(() => {
fetchAlbum()
})
onUnmounted(() => {
// Clean up event listeners
document.removeEventListener('keydown', handleKeyboardNavigation)
})
</script>

786
backup/_data/src/views/Albums.vue Executable file
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,518 @@
<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">
<LoadingLogo size="large" text="Chargement de l'événement..." />
</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">
<img
v-if="!event.cover_image && event.creator_avatar"
:src="getMediaUrl(event.creator_avatar)"
:alt="event.creator_name"
class="w-16 h-16 rounded-full object-cover"
>
<div v-else-if="!event.cover_image" class="w-16 h-16 rounded-full bg-primary-100 flex items-center justify-center">
<User class="w-8 h-8 text-primary-600" />
</div>
<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'
import LoadingLogo from '@/components/LoadingLogo.vue'
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>

519
backup/_data/src/views/Events.vue Executable file
View File

@@ -0,0 +1,519 @@
<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">
<img
v-if="event.creator_avatar"
:src="getMediaUrl(event.creator_avatar)"
:alt="event.creator_name"
class="w-16 h-16 rounded-full object-cover"
>
<div v-else class="w-16 h-16 rounded-full bg-primary-100 flex items-center justify-center">
<User class="w-8 h-8 text-primary-600" />
</div>
</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}`)
console.log('Events response:', response.data)
if (response.data && response.data.length > 0) {
console.log('First event:', response.data[0])
console.log('Creator avatar:', response.data[0].creator_avatar)
}
if (offset.value === 0) {
events.value = response.data
} else {
events.value.push(...response.data)
}
hasMoreEvents.value = response.data.length === 12
} catch (error) {
console.error('Error fetching events:', 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
backup/_data/src/views/Home.vue Executable 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">
<LoadingLogo size="large" text="Chargement des informations..." />
</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'
import LoadingLogo from '@/components/LoadingLogo.vue'
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">
<LoadingLogo size="large" text="Chargement de vos tickets..." />
</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'
import LoadingLogo from '@/components/LoadingLogo.vue'
const toast = useToast()
// State
const tickets = ref([])
const loading = ref(true)
const selectedStatus = ref(null)
const showTicketModal = ref(false)
const editingTicket = ref(null)
const savingTicket = ref(false)
const showScreenshotModal = ref(false)
const selectedScreenshot = ref('')
const screenshotInput = ref(null)
const ticketForm = ref({
title: '',
description: '',
ticket_type: 'other',
priority: 'medium'
})
// Computed
const availableStatuses = computed(() => {
const statuses = [...new Set(tickets.value.map(ticket => ticket.status))]
return statuses.sort()
})
const filteredTickets = computed(() => {
if (!selectedStatus.value) {
return tickets.value
}
return tickets.value.filter(ticket => ticket.status === selectedStatus.value)
})
const ticketStats = computed(() => {
const stats = {
open: tickets.value.filter(t => t.status === 'open').length,
in_progress: tickets.value.filter(t => t.status === 'in_progress').length,
resolved: tickets.value.filter(t => t.status === 'resolved').length,
total: tickets.value.length
}
return stats
})
// Methods
function getTypeLabel(type) {
const labels = {
'bug': 'Bug',
'feature_request': 'Fonctionnalité',
'improvement': 'Amélioration',
'support': 'Support',
'other': 'Autre'
}
return labels[type] || type
}
function getStatusLabel(status) {
const labels = {
'open': 'Ouvert',
'in_progress': 'En cours',
'resolved': 'Résolu',
'closed': 'Fermé'
}
return labels[status] || status
}
function getPriorityLabel(priority) {
const labels = {
'low': 'Faible',
'medium': 'Moyenne',
'high': 'Élevée',
'urgent': 'Urgente'
}
return labels[priority] || priority
}
function getTypeBadgeClass(type) {
const classes = {
'bug': 'bg-red-100 text-red-800',
'feature_request': 'bg-blue-100 text-blue-800',
'improvement': 'bg-green-100 text-green-800',
'support': 'bg-yellow-100 text-yellow-800',
'other': 'bg-gray-100 text-gray-800'
}
return classes[type] || 'bg-gray-100 text-gray-800'
}
function getStatusBadgeClass(status) {
const classes = {
'open': 'bg-blue-100 text-blue-800',
'in_progress': 'bg-yellow-100 text-yellow-800',
'resolved': 'bg-green-100 text-green-800',
'closed': 'bg-gray-100 text-gray-800'
}
return classes[status] || 'bg-gray-100 text-gray-800'
}
function getPriorityBadgeClass(priority) {
const classes = {
'low': 'bg-green-100 text-green-800',
'medium': 'bg-yellow-100 text-yellow-800',
'high': 'bg-orange-100 text-orange-800',
'urgent': 'bg-red-100 text-red-800'
}
return classes[priority] || 'bg-gray-100 text-gray-800'
}
function formatDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
}
function resetTicketForm() {
ticketForm.value = {
title: '',
description: '',
ticket_type: 'other',
priority: 'medium'
}
editingTicket.value = null
if (screenshotInput.value) {
screenshotInput.value.value = ''
}
}
function editTicket(ticket) {
editingTicket.value = ticket
ticketForm.value = {
title: ticket.title,
description: ticket.description,
ticket_type: ticket.ticket_type,
priority: ticket.priority
}
showTicketModal.value = true
}
function handleScreenshotChange(event) {
const file = event.target.files[0]
if (file && file.size > 5 * 1024 * 1024) {
toast.error('Le fichier est trop volumineux (max 5MB)')
event.target.value = ''
}
}
function viewScreenshot(screenshotPath) {
selectedScreenshot.value = getMediaUrl(screenshotPath)
showScreenshotModal.value = true
}
async function saveTicket() {
savingTicket.value = true
try {
const formData = new FormData()
formData.append('title', ticketForm.value.title)
formData.append('description', ticketForm.value.description)
formData.append('ticket_type', ticketForm.value.ticket_type)
formData.append('priority', ticketForm.value.priority)
if (screenshotInput.value && screenshotInput.value.files[0]) {
formData.append('screenshot', screenshotInput.value.files[0])
}
if (editingTicket.value) {
// Update existing ticket
await axios.put(`/api/tickets/${editingTicket.value.id}`, ticketForm.value)
toast.success('Ticket mis à jour')
} else {
// Create new ticket
await axios.post('/api/tickets/', formData)
toast.success('Ticket créé avec succès')
}
await fetchTickets()
showTicketModal.value = false
resetTicketForm()
} catch (error) {
toast.error('Erreur lors de la sauvegarde')
console.error('Error saving ticket:', error)
} finally {
savingTicket.value = false
}
}
async function deleteTicket(ticketId) {
if (!confirm('Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.')) return
try {
await axios.delete(`/api/tickets/${ticketId}`)
await fetchTickets()
toast.success('Ticket supprimé')
} catch (error) {
toast.error('Erreur lors de la suppression')
console.error('Error deleting ticket:', error)
}
}
async function fetchTickets() {
try {
loading.value = true
const response = await axios.get('/api/tickets/')
tickets.value = response.data
} catch (error) {
console.error('Error fetching tickets:', error)
toast.error('Erreur lors du chargement des tickets')
} finally {
loading.value = false
}
}
// Lifecycle
onMounted(() => {
fetchTickets()
})
</script>

521
backup/_data/src/views/Posts.vue Executable file
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>

307
backup/_data/src/views/Stats.vue Executable file
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">
<LoadingLogo size="large" text="Chargement des statistiques..." />
</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'
import LoadingLogo from '@/components/LoadingLogo.vue'
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">
<LoadingLogo size="large" text="Chargement du profil..." />
</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, 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 { User, ArrowLeft, ArrowRight, MessageSquare, Video, Image, Calendar, Activity } from 'lucide-vue-next'
import LoadingLogo from '@/components/LoadingLogo.vue'
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">
<LoadingLogo size="large" text="Chargement du vlog..." />
</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="getMediaUrl(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'
import LoadingLogo from '@/components/LoadingLogo.vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const toast = useToast()
const vlog = ref(null)
const relatedVlogs = ref([])
const users = ref([])
const loading = ref(true)
const updating = ref(false)
const showEditModal = ref(false)
const showComments = ref(true)
const editForm = ref({
title: '',
description: ''
})
const canEdit = computed(() =>
vlog.value && (vlog.value.author_id === authStore.user?.id || authStore.user?.is_admin)
)
function formatDate(date) {
return format(new Date(date), 'dd MMMM yyyy', { locale: fr })
}
function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
}
function formatDuration(seconds) {
if (!seconds) return '--:--'
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
async function toggleLike() {
try {
const response = await axios.post(`/api/vlogs/${vlog.value.id}/like`)
// Refresh vlog data to get updated like count
await fetchVlog()
toast.success(response.data.message)
} catch (error) {
toast.error('Erreur lors de la mise à jour du like')
}
}
function onCommentAdded(comment) {
// Add the new comment to the vlog
if (!vlog.value.comments) {
vlog.value.comments = []
}
vlog.value.comments.unshift(comment)
}
function onCommentDeleted(commentId) {
// Remove the deleted comment from the vlog
if (vlog.value.comments) {
const index = vlog.value.comments.findIndex(c => c.id === commentId)
if (index > -1) {
vlog.value.comments.splice(index, 1)
}
}
}
async function fetchUsers() {
try {
const response = await axios.get('/api/users')
users.value = response.data
} catch (error) {
console.error('Error fetching users:', error)
}
}
async function fetchVlog() {
try {
const response = await axios.get(`/api/vlogs/${route.params.id}`)
vlog.value = response.data
// Initialize edit form
editForm.value = {
title: vlog.value.title,
description: vlog.value.description || ''
}
// Fetch related vlogs from same author
const relatedResponse = await axios.get(`/api/vlogs?limit=6&offset=0`)
relatedVlogs.value = relatedResponse.data
.filter(v => v.id !== vlog.value.id && v.author_id === vlog.value.author_id)
.slice(0, 3)
} catch (error) {
toast.error('Erreur lors du chargement du vlog')
console.error('Error fetching vlog:', error)
} finally {
loading.value = false
}
}
async function updateVlog() {
updating.value = true
try {
const response = await axios.put(`/api/vlogs/${vlog.value.id}`, editForm.value)
vlog.value = response.data
showEditModal.value = false
toast.success('Vlog mis à jour')
} catch (error) {
toast.error('Erreur lors de la mise à jour')
}
updating.value = false
}
async function deleteVlog() {
if (!confirm('Êtes-vous sûr de vouloir supprimer ce vlog ?')) return
try {
await axios.delete(`/api/vlogs/${vlog.value.id}`)
toast.success('Vlog supprimé')
router.push('/vlogs')
} catch (error) {
toast.error('Erreur lors de la suppression')
}
}
onMounted(() => {
fetchVlog()
fetchUsers()
})
</script>

473
backup/_data/src/views/Vlogs.vue Executable file
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="getMediaUrl(vlog.author_avatar)"
:alt="vlog.author_name"
class="w-5 h-5 rounded-full object-cover"
>
<div v-else class="w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center">
<User class="w-3 h-3 text-gray-500" />
</div>
<router-link
:to="`/profile/${vlog.author_id}`"
class="text-gray-900 hover:text-primary-600 transition-colors cursor-pointer"
>
{{ vlog.author_name }}
</router-link>
</div>
<div class="flex items-center justify-between text-sm text-gray-500">
<span>{{ formatRelativeDate(vlog.created_at) }}</span>
<div class="flex items-center space-x-1">
<Eye class="w-4 h-4" />
<span>{{ vlog.views_count }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Load more -->
<div v-if="hasMoreVlogs" class="text-center mt-8">
<button
@click="loadMoreVlogs"
:disabled="loading"
class="btn-secondary"
>
{{ loading ? 'Chargement...' : 'Charger plus' }}
</button>
</div>
<!-- Empty state -->
<div v-if="vlogs.length === 0 && !loading" class="text-center py-12">
<Film class="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun vlog</h3>
<p class="text-gray-600">Soyez le premier à partager un vlog !</p>
</div>
<!-- Create Vlog Modal -->
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showCreateModal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
>
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
<h2 class="text-xl font-semibold mb-4">Créer un nouveau vlog</h2>
<form @submit.prevent="createVlog" class="space-y-4">
<div>
<label class="label">Titre</label>
<input
v-model="newVlog.title"
type="text"
required
class="input"
placeholder="Titre de votre vlog..."
>
</div>
<div>
<label class="label">Description</label>
<MentionInput
v-model="newVlog.description"
:users="users"
:rows="3"
placeholder="Décrivez votre vlog... (utilisez @username pour mentionner)"
@mentions-changed="handleVlogMentionsChanged"
/>
</div>
<div>
<label class="label">Vidéo</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<input
ref="videoInput"
type="file"
accept="video/*"
class="hidden"
@change="handleVideoChange"
>
<div v-if="!newVlog.video" class="space-y-2">
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
<p class="text-gray-600">Cliquez pour sélectionner une vidéo</p>
<p class="text-sm text-gray-500">MP4, WebM, MOV (max {{ uploadLimits.max_video_size_mb }}MB)</p>
<button
type="button"
@click="$refs.videoInput.click()"
class="btn-secondary"
>
Sélectionner une vidéo
</button>
</div>
<div v-else class="space-y-2">
<video
:src="newVlog.video"
class="w-full max-h-48 object-cover rounded"
controls
/>
<button
type="button"
@click="removeVideo"
class="btn-secondary text-sm"
>
Changer de vidéo
</button>
</div>
</div>
</div>
<div>
<label class="label">Miniature (optionnel)</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<input
ref="thumbnailInput"
type="file"
accept="image/*"
class="hidden"
@change="handleThumbnailChange"
>
<div v-if="!newVlog.thumbnail" class="space-y-2">
<Image class="w-12 h-12 text-gray-400 mx-auto" />
<p class="text-gray-600">Ajoutez une miniature personnalisée</p>
<button
type="button"
@click="$refs.thumbnailInput.click()"
class="btn-secondary"
>
Sélectionner une image
</button>
</div>
<div v-else class="space-y-2">
<img
:src="newVlog.thumbnail"
class="w-full max-h-48 object-cover rounded mx-auto"
/>
<button
type="button"
@click="removeThumbnail"
class="btn-secondary text-sm"
>
Changer la miniature
</button>
</div>
</div>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
@click="showCreateModal = false"
class="flex-1 btn-secondary"
>
Annuler
</button>
<button
type="submit"
:disabled="creating || !newVlog.video"
class="flex-1 btn-primary"
>
{{ creating ? 'Création...' : 'Créer le vlog' }}
</button>
</div>
</form>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import {
Plus,
Film,
Play,
User,
Eye,
Upload,
Image
} from 'lucide-vue-next'
import Mentions from '@/components/Mentions.vue'
import MentionInput from '@/components/MentionInput.vue'
const router = useRouter()
const toast = useToast()
const vlogs = ref([])
const users = ref([])
const loading = ref(false)
const creating = ref(false)
const showCreateModal = ref(false)
const hasMoreVlogs = ref(true)
const offset = ref(0)
const uploadLimits = ref({
max_video_size_mb: 100
})
const newVlog = ref({
title: '',
description: '',
video: null,
thumbnail: null
})
const vlogMentions = ref([])
function formatDuration(seconds) {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
function formatRelativeDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
}
function openVlog(vlog) {
router.push(`/vlogs/${vlog.id}`)
}
async function handleVideoChange(event) {
const file = event.target.files[0]
if (!file) return
if (!file.type.startsWith('video/')) {
toast.error('Veuillez sélectionner une vidéo')
return
}
if (file.size > uploadLimits.max_video_size_mb * 1024 * 1024) {
toast.error(`La vidéo est trop volumineuse (max ${uploadLimits.max_video_size_mb}MB)`)
return
}
// Create preview URL
newVlog.value.video = URL.createObjectURL(file)
newVlog.value.videoFile = file
event.target.value = ''
}
function removeVideo() {
if (newVlog.value.video && newVlog.value.video.startsWith('blob:')) {
URL.revokeObjectURL(newVlog.value.video)
}
newVlog.value.video = null
newVlog.value.videoFile = null
}
async function handleThumbnailChange(event) {
const file = event.target.files[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast.error('Veuillez sélectionner une image')
return
}
if (file.size > uploadLimits.max_image_size_mb * 1024 * 1024) {
toast.error(`L'image est trop volumineuse (max ${uploadLimits.max_image_size_mb}MB)`)
return
}
// Create preview URL
newVlog.value.thumbnail = URL.createObjectURL(file)
newVlog.value.thumbnailFile = file
event.target.value = ''
}
function removeThumbnail() {
if (newVlog.value.thumbnail && newVlog.value.thumbnail.startsWith('blob:')) {
URL.revokeObjectURL(newVlog.value.thumbnail)
}
newVlog.value.thumbnail = null
newVlog.value.thumbnailFile = null
}
function handleVlogMentionsChanged(mentions) {
vlogMentions.value = mentions
}
async function fetchUsers() {
try {
const response = await axios.get('/api/users')
users.value = response.data
} catch (error) {
console.error('Error fetching users:', error)
}
}
async function fetchUploadLimits() {
try {
const response = await axios.get('/api/settings/upload-limits')
uploadLimits.value = response.data
} catch (error) {
console.error('Error fetching upload limits:', error)
}
}
function getMentionsFromContent(content) {
if (!content) return []
const mentions = []
const mentionRegex = /@(\w+)/g
let match
while ((match = mentionRegex.exec(content)) !== null) {
const username = match[1]
const user = users.value.find(u => u.username === username)
if (user) {
mentions.push({
id: user.id,
username: user.username,
full_name: user.full_name
})
}
}
return mentions
}
async function createVlog() {
if (!newVlog.value.title || !newVlog.value.videoFile) return
creating.value = true
try {
const formData = new FormData()
formData.append('title', newVlog.value.title)
if (newVlog.value.description) {
formData.append('description', newVlog.value.description)
}
formData.append('video', newVlog.value.videoFile)
if (newVlog.value.thumbnailFile) {
formData.append('thumbnail', newVlog.value.thumbnailFile)
}
const response = await axios.post('/api/vlogs/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
vlogs.value.unshift(response.data)
showCreateModal.value = false
resetForm()
toast.success('Vlog créé avec succès')
} catch (error) {
toast.error('Erreur lors de la création du vlog')
}
creating.value = false
}
function resetForm() {
removeVideo()
removeThumbnail()
newVlog.value = {
title: '',
description: '',
video: null,
thumbnail: null
}
}
async function fetchVlogs() {
loading.value = true
try {
const response = await axios.get(`/api/vlogs?limit=12&offset=${offset.value}`)
if (offset.value === 0) {
vlogs.value = response.data
} else {
vlogs.value.push(...response.data)
}
hasMoreVlogs.value = response.data.length === 12
} catch (error) {
toast.error('Erreur lors du chargement des vlogs')
}
loading.value = false
}
async function loadMoreVlogs() {
offset.value += 12
await fetchVlogs()
}
onMounted(() => {
fetchVlogs()
fetchUsers()
fetchUploadLimits()
})
</script>

78
backup/_data/tailwind.config.js Executable file
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: [],
}

222
backup/_data/test-env.js Executable file
View File

@@ -0,0 +1,222 @@
#!/usr/bin/env node
/**
* Script de test des environnements Frontend LeDiscord
* Vérifie la cohérence des configurations et évite les problèmes de mixed content
*/
const fs = require('fs')
const path = require('path')
// Couleurs pour la console
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m'
}
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`)
}
function testEnvironment(envName) {
log(`\n🔍 Test de l'environnement: ${envName.toUpperCase()}`, 'cyan')
const envFile = path.join(__dirname, `env.${envName}`)
if (!fs.existsSync(envFile)) {
log(`❌ Fichier ${envFile} non trouvé`, 'red')
return false
}
log(`✅ Fichier ${envFile} trouvé`, 'green')
// Lire le fichier d'environnement
const envContent = fs.readFileSync(envFile, 'utf8')
const envVars = {}
// Parser les variables d'environnement
envContent.split('\n').forEach(line => {
line = line.trim()
if (line && !line.startsWith('#')) {
const [key, ...valueParts] = line.split('=')
if (key && valueParts.length > 0) {
envVars[key] = valueParts.join('=')
}
}
})
// Vérifier les variables requises
const requiredVars = ['VITE_API_URL', 'VITE_APP_URL', 'VITE_UPLOAD_URL', 'VITE_ENVIRONMENT']
let allValid = true
requiredVars.forEach(varName => {
if (!envVars[varName]) {
log(`❌ Variable manquante: ${varName}`, 'red')
allValid = false
} else {
log(`${varName}: ${envVars[varName]}`, 'green')
}
})
// Vérifier la cohérence des protocoles
if (envVars.VITE_API_URL && envVars.VITE_APP_URL) {
const apiProtocol = envVars.VITE_API_URL.split('://')[0]
const appProtocol = envVars.VITE_APP_URL.split('://')[0]
if (apiProtocol !== appProtocol) {
log(`⚠️ Protocoles différents: API=${apiProtocol}, APP=${appProtocol}`, 'yellow')
log(` Cela peut causer des problèmes de mixed content!`, 'yellow')
allValid = false
} else {
log(`✅ Protocoles cohérents: ${apiProtocol}`, 'green')
}
}
// Vérifier la cohérence des domaines
if (envVars.VITE_API_URL && envVars.VITE_APP_URL) {
const apiDomain = envVars.VITE_API_URL.split('://')[1]?.split('/')[0]
const appDomain = envVars.VITE_APP_URL.split('://')[1]?.split('/')[0]
if (apiDomain && appDomain) {
if (envName === 'local') {
// En local, les domaines peuvent être différents (localhost:8000 vs localhost:5173)
log(`✅ Domaines locaux: API=${apiDomain}, APP=${appDomain}`, 'green')
} else if (apiDomain.includes('dev.') && appDomain.includes('dev.')) {
log(`✅ Domaines de développement cohérents: ${apiDomain}`, 'green')
} else if (!apiDomain.includes('dev.') && !appDomain.includes('dev.')) {
log(`✅ Domaines de production cohérents: ${apiDomain}`, 'green')
} else {
log(`⚠️ Domaines incohérents: API=${apiDomain}, APP=${appDomain}`, 'yellow')
allValid = false
}
}
}
// Vérifier l'environnement spécifié
if (envVars.VITE_ENVIRONMENT && envVars.VITE_ENVIRONMENT !== envName) {
log(`⚠️ Environnement spécifié (${envVars.VITE_ENVIRONMENT}) ne correspond pas au nom du fichier (${envName})`, 'yellow')
allValid = false
}
return allValid
}
function testDockerfiles() {
log(`\n🐳 Test des Dockerfiles`, 'cyan')
const dockerfiles = ['Dockerfile.local', 'Dockerfile.dev', 'Dockerfile.prod']
let allValid = true
dockerfiles.forEach(dockerfile => {
const dockerfilePath = path.join(__dirname, dockerfile)
if (fs.existsSync(dockerfilePath)) {
log(`${dockerfile} trouvé`, 'green')
// Vérifier que le Dockerfile copie le bon fichier d'environnement
const content = fs.readFileSync(dockerfilePath, 'utf8')
const envName = dockerfile.replace('Dockerfile.', '')
if (content.includes(`env.${envName}`)) {
log(`${dockerfile} copie le bon fichier env.${envName}`, 'green')
} else {
log(`${dockerfile} ne copie pas env.${envName}`, 'red')
allValid = false
}
} else {
log(`${dockerfile} manquant`, 'red')
allValid = false
}
})
return allValid
}
function testViteConfig() {
log(`\n⚙️ Test de la configuration Vite`, 'cyan')
const viteConfigPath = path.join(__dirname, 'vite.config.js')
if (!fs.existsSync(viteConfigPath)) {
log(`❌ vite.config.js non trouvé`, 'red')
return false
}
log(`✅ vite.config.js trouvé`, 'green')
// Vérifier que la configuration gère les environnements
const content = fs.readFileSync(viteConfigPath, 'utf8')
if (content.includes('getEnvironmentConfig')) {
log(`✅ Configuration par environnement détectée`, 'green')
} else {
log(`⚠️ Configuration par environnement non détectée`, 'yellow')
}
if (content.includes('proxy')) {
log(`✅ Configuration proxy détectée`, 'green')
} else {
log(`⚠️ Configuration proxy non détectée`, 'yellow')
}
return true
}
function main() {
const args = process.argv.slice(2)
log(`🚀 Test des environnements Frontend LeDiscord`, 'bright')
log(`📁 Répertoire: ${__dirname}`, 'blue')
let allTestsPassed = true
if (args.length > 0) {
// Test d'un environnement spécifique
const envName = args[0]
if (['local', 'development', 'production'].includes(envName)) {
allTestsPassed = testEnvironment(envName) && allTestsPassed
} else {
log(`❌ Environnement invalide: ${envName}`, 'red')
log(` Environnements valides: local, development, production`, 'yellow')
process.exit(1)
}
} else {
// Test de tous les environnements
log(`\n🌍 Test de tous les environnements`, 'cyan')
const environments = ['local', 'development', 'production']
environments.forEach(env => {
allTestsPassed = testEnvironment(env) && allTestsPassed
})
}
// Tests généraux
allTestsPassed = testDockerfiles() && allTestsPassed
allTestsPassed = testViteConfig() && allTestsPassed
// Résumé
log(`\n📊 Résumé des tests`, 'cyan')
if (allTestsPassed) {
log(`✅ Tous les tests sont passés avec succès!`, 'green')
log(`🎉 Votre configuration frontend est prête pour la production!`, 'green')
} else {
log(`❌ Certains tests ont échoué`, 'red')
log(`🔧 Veuillez corriger les problèmes avant de continuer`, 'yellow')
process.exit(1)
}
log(`\n💡 Conseils pour éviter les problèmes de mixed content:`, 'cyan')
log(` - Assurez-vous que toutes les URLs d'un même environnement utilisent le même protocole`, 'blue')
log(` - En local: utilisez HTTP (http://localhost:*)`, 'blue')
log(` - En développement/production: utilisez HTTPS (https://*.lediscord.com)`, 'blue')
log(` - Vérifiez que VITE_API_URL et VITE_APP_URL sont cohérents`, 'blue')
}
if (require.main === module) {
main()
}

131
backup/_data/vite.config.js Executable file
View File

@@ -0,0 +1,131 @@
const { defineConfig } = require('vite')
const vue = require('@vitejs/plugin-vue')
const path = require('path')
// Configuration par environnement
const getEnvironmentConfig = (mode) => {
const configs = {
local: {
server: {
host: '0.0.0.0',
port: 5173,
allowedHosts: ['localhost', '127.0.0.1'],
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false
},
'/uploads': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false
}
}
},
define: {
__ENVIRONMENT__: '"local"'
}
},
development: {
server: {
host: '0.0.0.0',
port: 5173,
allowedHosts: ['dev.lediscord.com', 'localhost'],
// Pas de proxy en développement car l'API est externe (https://api-dev.lediscord.com)
// Le proxy n'est nécessaire que pour l'environnement local
},
define: {
__ENVIRONMENT__: '"development"'
}
},
production: {
server: {
host: '0.0.0.0',
port: 5173,
allowedHosts: ['lediscord.com', 'www.lediscord.com'],
proxy: {
'/api': {
target: 'https://api.lediscord.com',
changeOrigin: true,
secure: true
},
'/uploads': {
target: 'https://api.lediscord.com',
changeOrigin: true,
secure: true
}
}
},
define: {
__ENVIRONMENT__: '"production"'
}
}
}
return configs[mode] || configs.local
}
module.exports = defineConfig(({ command, mode }) => {
// Détecter l'environnement
const env = process.env.NODE_ENV || mode || 'local'
const envConfig = getEnvironmentConfig(env)
console.log(`🚀 Configuration Vite pour l'environnement: ${env.toUpperCase()}`)
console.log(`🔧 Variables d'environnement:`, {
NODE_ENV: process.env.NODE_ENV,
VITE_ENVIRONMENT: process.env.VITE_ENVIRONMENT,
VITE_API_URL: process.env.VITE_API_URL,
VITE_APP_URL: process.env.VITE_APP_URL
})
return {
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
// Configuration du serveur selon l'environnement
server: envConfig.server,
// Configuration pour la production
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
utils: ['axios', 'date-fns']
}
}
},
// Optimisations de production
minify: env === 'production' ? 'terser' : false,
sourcemap: env !== 'production',
// Variables d'environnement
define: envConfig.define
},
// Configuration des variables d'environnement
define: {
...envConfig.define,
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
// Forcer les variables d'environnement
'import.meta.env.VITE_ENVIRONMENT': JSON.stringify(process.env.VITE_ENVIRONMENT || env),
'import.meta.env.VITE_API_URL': JSON.stringify(process.env.VITE_API_URL),
'import.meta.env.VITE_APP_URL': JSON.stringify(process.env.VITE_APP_URL)
},
// Optimisations selon l'environnement
optimizeDeps: {
include: ['vue', 'vue-router', 'pinia', 'axios']
},
// Configuration des assets
assetsInclude: ['**/*.png', '**/*.jpg', '**/*.jpeg', '**/*.gif', '**/*.svg', '**/*.mp4', '**/*.webm'],
// Configuration du mode
mode: env === 'production' ? 'production' : 'development'
}
})

66
docker-compose.dev.yml Executable file
View File

@@ -0,0 +1,66 @@
services:
postgres:
image: postgres:15-alpine
container_name: lediscord_db_dev
environment:
POSTGRES_DB: lediscord
POSTGRES_USER: lediscord_user
POSTGRES_PASSWORD: ${DB_PASSWORD:-lediscord_password}
volumes:
- postgres_data_dev:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- lediscord_network_dev
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lediscord_user -d lediscord"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev
container_name: lediscord_backend_dev
env_file:
- ./backend/.env.development
environment:
# Variables spécifiques au docker-compose (peuvent surcharger .env)
DB_PASSWORD: ${DB_PASSWORD:-lediscord_password}
volumes:
- ${UPLOAD_PATH:-./uploads}:/app/uploads
- ./backend:/app
ports:
- "8002:8000"
depends_on:
postgres:
condition: service_healthy
networks:
- lediscord_network_dev
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
container_name: lediscord_frontend_dev
env_file:
- ./frontend/.env.development
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "8082:5173"
networks:
- lediscord_network_dev
command: npm run dev #-- --host
restart: unless-stopped
networks:
lediscord_network_dev:
driver: bridge
volumes:
postgres_data_dev:

65
docker-compose.local.yml Executable file
View File

@@ -0,0 +1,65 @@
services:
postgres:
image: postgres:15-alpine
container_name: lediscord_db_local
environment:
POSTGRES_DB: lediscord
POSTGRES_USER: lediscord_user
POSTGRES_PASSWORD: ${DB_PASSWORD:-lediscord_password}
volumes:
- postgres_data_local:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- lediscord_network_local
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lediscord_user -d lediscord"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile.local
container_name: lediscord_backend_local
env_file:
- ./backend/.env.local
environment:
# Variables spécifiques au docker-compose (peuvent surcharger .env)
DB_PASSWORD: ${DB_PASSWORD:-lediscord_password}
volumes:
- ${UPLOAD_PATH:-./uploads}:/app/uploads
- ./backend:/app
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
networks:
- lediscord_network_local
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.local
container_name: lediscord_frontend_local
env_file:
- ./frontend/.env.local
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "5173:5173"
networks:
- lediscord_network_local
command: npm run dev -- --host
restart: unless-stopped
networks:
lediscord_network_local:
driver: bridge
volumes:
postgres_data_local:

Some files were not shown because too many files have changed in this diff Show More