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
# FastAPI
# IMPORTANT: Zero-Config - Aucun fichier .env par défaut
# Seuls les fichiers d'environnement spécifiques sont autorisés
.env
.env.local
.env.development
.env.production
*.db
*.sqlite
@@ -95,13 +98,9 @@ ehthumbs.db
Desktop.ini
# Production
.env.production
.env.staging
*.pem
*.key
*.crt
# Temporary files
*.tmp
*.temp
.cache/
# IMPORTANT: Zero-Config - Aucune configuration par défaut
# Tous les fichiers .env d'environnement doivent être explicitement configurés
# Seuls les fichiers .env.local, .env.development, .env.production sont autorisés
# 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
@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
./stop.sh
local: ## Démarrer en mode local
@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.sh
./start.sh
stop-local: ## Arrêter en mode local
@echo "🛑 Arrêt de $(PROJECT_NAME) en mode LOCAL..."
@docker compose -f docker-compose.local.yml down
@echo "$(PROJECT_NAME) local arrêté"
logs: ## Afficher les logs
docker compose logs -f
restart-local: ## Redémarrer en mode local
@echo "🔄 Redémarrage de $(PROJECT_NAME) en mode LOCAL..."
@$(MAKE) stop-local
@$(MAKE) local
logs-backend: ## Afficher les logs du backend
docker compose logs -f backend
logs-local: ## Voir les logs en mode local
@echo "📝 Logs de $(PROJECT_NAME) en mode LOCAL..."
@docker compose -f docker-compose.local.yml logs -f
logs-frontend: ## Afficher les logs du frontend
docker compose logs -f frontend
status-local: ## Voir le statut en mode local
@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
docker compose logs -f postgres
build-local: ## Reconstruire en mode local
@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
docker compose down -v
rm -rf backend/__pycache__
rm -rf backend/**/__pycache__
dev: ## Démarrer en mode développement
@echo "🚀 Démarrage de $(PROJECT_NAME) en mode DEVELOPMENT..."
@docker compose -f docker-compose.dev.yml up --build -d
@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)
cd backend && pip install -r requirements.txt
cd frontend && npm install
stop-dev: ## Arrêter en mode développement
@echo "🛑 Arrêt de $(PROJECT_NAME) en mode DEVELOPMENT..."
@docker compose -f docker-compose.dev.yml down
@echo "$(PROJECT_NAME) development arrêté"
dev-backend: ## Lancer le backend en mode développement
cd backend && uvicorn app:app --reload --host 0.0.0.0 --port 8000
restart-dev: ## Redémarrer en mode développement
@echo "🔄 Redémarrage de $(PROJECT_NAME) en mode DEVELOPMENT..."
@$(MAKE) stop-dev
@$(MAKE) dev
dev-frontend: ## Lancer le frontend en mode développement
cd frontend && npm run dev
logs-dev: ## Voir les logs en mode développement
@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
docker compose exec backend /bin/bash
status-dev: ## Voir le statut en mode développement
@echo "📊 Statut de $(PROJECT_NAME) en mode DEVELOPMENT..."
@docker compose -f docker-compose.dev.yml ps
shell-db: ## Ouvrir psql dans le conteneur PostgreSQL
docker compose exec postgres psql -U lediscord_user -d lediscord
build-dev: ## Reconstruire en mode développement
@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
docker compose ps
prod: ## Démarrer en mode production
@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 sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from typing import List
from datetime import datetime
from config.database import get_db
@@ -64,7 +64,7 @@ async def get_events(
upcoming: bool = None
):
"""Get all events, optionally filtered by upcoming status."""
query = db.query(Event)
query = db.query(Event).options(joinedload(Event.creator))
if upcoming is True:
# Only upcoming events
@@ -83,7 +83,7 @@ async def get_upcoming_events(
current_user: User = Depends(get_current_active_user)
):
"""Get only upcoming events."""
events = db.query(Event).filter(
events = db.query(Event).options(joinedload(Event.creator)).filter(
Event.date >= datetime.utcnow()
).order_by(Event.date).all()
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)
):
"""Get only past events."""
events = db.query(Event).filter(
events = db.query(Event).options(joinedload(Event.creator)).filter(
Event.date < datetime.utcnow()
).order_by(Event.date.desc()).all()
return [format_event_response(event, db) for event in events]
@@ -204,6 +204,9 @@ def format_event_response(event: Event, db: Session) -> dict:
participations = []
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:
user = db.query(User).filter(User.id == p.user_id).first()
participations.append({
@@ -235,7 +238,8 @@ def format_event_response(event: Event, db: Session) -> dict:
"cover_image": event.cover_image,
"created_at": event.created_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,
"present_count": present_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),
current_user: User = Depends(get_admin_user)
):
"""Get current upload limits configuration."""
"""Get current upload limits configuration (admin only)."""
settings = db.query(SystemSettings).filter(
SystemSettings.category == "uploads"
).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(",")
)
@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)
async def create_setting(
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
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_origins=settings.CORS_ORIGINS_LIST,
allow_credentials=True,
allow_methods=["*"],
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
import os
from pathlib import Path
class Settings:
# Database
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://lediscord_user:lediscord_password@postgres:5432/lediscord")
"""Configuration principale avec variables d'environnement obligatoires"""
# JWT
JWT_SECRET_KEY: str = "your-super-secret-jwt-key-change-me"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRATION_MINUTES: int = 10080 # 7 days
# Environnement - OBLIGATOIRE
ENVIRONMENT: str = os.getenv("ENVIRONMENT")
if not ENVIRONMENT:
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_PATH: str = "/app/uploads"
MAX_UPLOAD_SIZE: int = 100 * 1024 * 1024 # 100MB
ALLOWED_IMAGE_TYPES: List[str] = ["image/jpeg", "image/png", "image/gif", "image/webp"]
ALLOWED_VIDEO_TYPES: List[str] = ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"]
UPLOAD_PATH: str = os.getenv("UPLOAD_PATH", "./uploads")
MAX_UPLOAD_SIZE: int = int(os.getenv("MAX_UPLOAD_SIZE", "104857600"))
ALLOWED_IMAGE_TYPES: List[str] = os.getenv("ALLOWED_IMAGE_TYPES", "image/jpeg,image/png,image/gif,image/webp").split(",")
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_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"]
# CORS - OBLIGATOIRE
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
SMTP_HOST: str = "smtp.gmail.com"
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SMTP_FROM: str = "noreply@lediscord.com"
SMTP_HOST: str = os.getenv("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER: str = os.getenv("SMTP_USER", "")
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM: str = os.getenv("SMTP_FROM", "noreply@lediscord.com")
# Admin
ADMIN_EMAIL: str = "admin@lediscord.com"
ADMIN_PASSWORD: str = "admin123"
# Admin - OBLIGATOIRE
ADMIN_EMAIL: str = os.getenv("ADMIN_EMAIL")
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_NAME: str = "LeDiscord"
APP_URL: str = "http://localhost:5173"
APP_NAME: str = os.getenv("APP_NAME", "LeDiscord")
APP_URL: str = os.getenv("APP_URL", "http://localhost:5173")
# 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
settings = Settings()
# 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
creator_id: int
creator_name: str
creator_avatar: Optional[str] = None
cover_image: Optional[str]
created_at: datetime
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