test
This commit is contained in:
parent
8823916d7a
commit
4fd1c14d1d
40
.env.example
Normal file
40
.env.example
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# MySQL (dans Docker Compose, le serveur MySQL s'appelle "db")
|
||||||
|
MYSQL_HOST=db
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_DATABASE=s105_AccroCBio
|
||||||
|
MYSQL_USER=u105_0kSRKdIEvn
|
||||||
|
MYSQL_PASSWORD=U0gpgwI+gN141Q9AjwBnmR!c
|
||||||
|
MYSQL_ROOT_PASSWORD=U0gpgwI+gN141Q9AjwBnmR!c
|
||||||
|
|
||||||
|
# Django
|
||||||
|
SECRET_KEY=pizirbfparivbaepvjejnze10247y30uuzrjfcj
|
||||||
|
DEBUG=True
|
||||||
|
|
||||||
|
# Ports directs d'écoute (dans Pterodactyl ET Docker Compose)
|
||||||
|
FRONTEND_PORT=1036
|
||||||
|
BACKEND_PORT=1037
|
||||||
|
|
||||||
|
# Admin : créé au premier démarrage seulement
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin123
|
||||||
|
ADMIN_FIRST_NAME=Valérie
|
||||||
|
ADMIN_LAST_NAME=Chaillou
|
||||||
|
|
||||||
|
# Noms de domaines publics autorisés (séparés par des virgules)
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1,backend,https://accrocbio.louismazin.ovh
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1,https://accrocbio.louismazin.ovh
|
||||||
|
CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1,https://accrocbio.louismazin.ovh
|
||||||
|
AUTO_PORT_CORS=True
|
||||||
|
AUTO_PORT_CSRF=True
|
||||||
|
|
||||||
|
SESSION_COOKIE_SECURE=False
|
||||||
|
CSRF_COOKIE_SECURE=False
|
||||||
|
|
||||||
|
# Notifications facultatives des formulaires (sinon messages dans /admin/).
|
||||||
|
CONTACT_NOTIFICATION_EMAIL=
|
||||||
|
DEFAULT_FROM_EMAIL=accrocbio@gmail.com
|
||||||
|
EMAIL_HOST=localhost
|
||||||
|
EMAIL_PORT=25
|
||||||
|
EMAIL_HOST_USER=
|
||||||
|
EMAIL_HOST_PASSWORD=
|
||||||
|
EMAIL_USE_TLS=False
|
||||||
82
README.md
82
README.md
@ -1,52 +1,64 @@
|
|||||||
# AccroCbio — site React / Django
|
# AccroCBio — site pédagogique sur le template commun
|
||||||
|
|
||||||
Refonte éditoriale du site coursbiologiechaillou.fr, basée sur l'architecture `Site`.
|
Site AccroCBio conservé visuellement, restructuré autour du même socle que `Template` : **Django 5 / DRF**, **React + Vite / shadcn/ui**, **MySQL 8**, **Oxlint**, services d'API distincts des composants React, authentification Django **par session avec CSRF**, administration React à `/admin/` (aucun `django-admin`).
|
||||||
|
|
||||||
## Stack conservée
|
Les pages pédagogiques d'origine restent présentes : accueil, présentation, services à la personne, crédit d'impôt, ressources et leurs sous-rubriques, avis, offre de rentrée, contact, mentions légales et confidentialité. Les ressources, témoignages, offres et messages reçus restent dans leurs modèles Django existants.
|
||||||
|
|
||||||
- **Frontend** : React 19, Vite, Tailwind CSS 4, composants shadcn existants, port **1028**.
|
## Installer / migrer depuis l'ancien AccroCBio
|
||||||
- **Backend** : Django + Django REST Framework, port **1029**.
|
|
||||||
- **Base** : MySQL 8 / MariaDB compatible, identifiants via `.env`.
|
|
||||||
- **Docker** : `docker-compose.yml`, `frontend/dockerfile` et `backend/dockerfile` **inchangés**.
|
|
||||||
- **Gestion du contenu** : Django Admin (`/admin/`) pour les PDF/liens, les avis, les offres et les messages.
|
|
||||||
|
|
||||||
## Mise en place
|
**Avant de remplacer les fichiers : sauvegardez impérativement la base MySQL et `backend/media/`.** Ce projet réutilise la base et les migrations existantes : il ne faut pas réinitialiser le volume MySQL. Le déploiement Compose reste volontairement sur **MySQL 8.0**, comme AccroCBio d'origine.
|
||||||
|
|
||||||
1. Utiliser **une nouvelle base de données**, distincte de celle du site de boulangerie. Ne pas pointer sur une base de production existante.
|
1. Appliquer les fichiers de l'archive de modifications à la racine de l'ancien AccroCBio, en conservant l'arborescence et les autres fichiers existants.
|
||||||
2. Copier `.env.example` vers `.env`, renseigner les secrets forts et le domaine réel (`ALLOWED_HOSTS`).
|
2. **Garder le `.env` réel** et les valeurs des paramètres `MYSQL_*`, `SECRET_KEY`, ports et domaines. Ne pas remplacer les mots de passe par les valeurs de `.env.example`.
|
||||||
3. `docker compose up -d --build` (ou le script `start.sh` dans un conteneur Pterodactyl configuré comme auparavant).
|
3. Vérifier `ALLOWED_HOSTS`, `CORS_ALLOWED_ORIGINS`, `CSRF_TRUSTED_ORIGINS` pour le domaine public ; en HTTPS, mettre `SESSION_COOKIE_SECURE=True`, `CSRF_COOKIE_SECURE=True` et `DEBUG=False`.
|
||||||
4. `docker compose exec backend python manage.py migrate`.
|
4. Ajouter `FRONTEND_PORT` et `BACKEND_PORT` dans le `.env` existant si absents (exemple : `FRONTEND_PORT=1036` et `BACKEND_PORT=1037`). Lancer `docker compose up -d --build`. Le backend exécute `migrate`, `ensure_admin`, `collectstatic` et démarre Django directement sur `BACKEND_PORT` ; Vite Preview écoute sur `FRONTEND_PORT`.
|
||||||
5. `docker compose exec backend python manage.py createsuperuser` : il permet l'accès à `/admin/`.
|
5. Se connecter à `/admin/` avec un compte **staff** déjà existant (`is_staff=True`) ou le compte admin initial configuré dans `.env` lors du tout premier démarrage. Les anciens jetons JWT de l'éditeur ne servent plus : une nouvelle connexion est requise.
|
||||||
6. En production, placer le frontend et `/api/` derrière nginx sous le même domaine HTTPS. Vite preview/Django runserver restent des outils de développement : pour une exposition publique durable, préférer un serveur statique et Gunicorn.
|
6. Vérifier en priorité le formulaire de contact, les ressources, les médias et la page d'édition.
|
||||||
|
|
||||||
Les migrations insèrent des références à quelques **PDF déjà publics** (liens externes uniquement). L'auteur des documents et son hébergement gardent leurs droits. Il faut ajouter dans l'administration les PDF manquants dont le lien n'a pas été vérifié. Aucune archive PDF tierce n'est embarquée.
|
**Conservation des données** : la migration `common.0002_import_legacy_content` copie les personnalisations de l'ancienne table `education.SiteContent` dans la nouvelle table CMS `common.SiteContent`, sans supprimer l'ancienne. Le modèle utilisateur personnalisé `users.User` et ses migrations sont conservés. Le dossier `./backend/media:/app/media` continue de stocker les anciennes images ; les nouveaux uploads utilisent `/api/cms/media/…`.
|
||||||
|
|
||||||
### Ressources administrables
|
**Ne pas** utiliser `docker compose down -v` lors de la mise à jour : cela effacerait les volumes de base de données.
|
||||||
|
|
||||||
- `GET /api/education/resources/?category=svt-premiere`
|
## Administration « je peux tout modifier »
|
||||||
- `GET /api/education/testimonials/`
|
|
||||||
- `GET /api/education/announcements/`
|
|
||||||
- `POST /api/education/contact/` (limite anonyme : 5 par heure par adresse IP, messages visibles depuis l'admin).
|
|
||||||
|
|
||||||
La notification des messages par mail est facultative et s'active via `CONTACT_NOTIFICATION_EMAIL`, `EMAIL_HOST`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, etc. Sans SMTP, le contact **est enregistré en base** mais aucun mail n'est envoyé.
|
À `/admin/`, deux mécanismes se complètent :
|
||||||
|
|
||||||
### Pages livrées
|
- **Modifier directement les pages** : mode « Modifier » activé sur le site, pour les textes, liens de boutons et images marqués par des contrôles d'édition.
|
||||||
|
- **Éditeur avancé → Pages** : ajouter, publier/masquer, nommer et organiser de nouvelles pages.
|
||||||
|
- **Éditeur avancé → Navigation** : libellés, visibilité et ordre des liens internes.
|
||||||
|
- **Éditeur avancé → Blocs** : ajouter, modifier, masquer, déplacer et supprimer des titres, paragraphes, boutons ou images, y compris sur les pages historiques.
|
||||||
|
- **Éditeur avancé → Apparence** : changer les couleurs principales du thème clair/sombre sans reconstruire l'application.
|
||||||
|
- **Éditeur avancé → Textes / liens** : modifier et réinitialiser les contenus enregistrés.
|
||||||
|
- **Ressources, Avis, Offres, Messages** : garder la gestion des données métier de l'ancien AccroCBio.
|
||||||
|
- **Utilisateurs** : voir les comptes, créer des comptes (superutilisateur) et désactiver/réactiver ceux qui ne sont pas protégés.
|
||||||
|
|
||||||
Accueil, Présentation, Services à la personne, Avance immédiate, Ressources (SVT Seconde/Première/Terminale, BPH, SBM, STL Première/Terminale), Contact, Avis, Offre de rentrée, Mentions légales, Confidentialité.
|
La gestion couvre les contenus éditables et les sections ajoutées via le CMS. La création de nouveaux types de composants, la logique des formulaires et certaines zones techniques codées en dur nécessitent encore une intervention dans le code.
|
||||||
|
|
||||||
### Points à personnaliser avant publication
|
## Architecture
|
||||||
|
|
||||||
- Renseigner l'hébergeur et valider les mentions légales / la politique de confidentialité.
|
```text
|
||||||
- Vérifier avec la propriétaire le droit d'utiliser le nom AccroCbio, les informations personnelles et les documents existants.
|
backend/
|
||||||
- Confirmer la validité de toute offre avant de l'activer dans Django Admin (offre de septembre 2026 désactivée par défaut).
|
common/ # Authentification par session, utilisateurs, CMS et migrations
|
||||||
- Définir une durée de rétention et un processus de purge pour les demandes de contact.
|
core/ # Django, URLs, origines, réglages
|
||||||
- Configurer HTTPS, `ALLOWED_HOSTS`, SMTP si nécessaire, sauvegardes DB et éventuel cache partagé pour le throttling si plusieurs workers.
|
education/ # API et modèles existants (cours, contact, ressources, avis, offres)
|
||||||
|
users/ # Modèle utilisateur et migrations existantes
|
||||||
|
media/ # Images et fichiers persistants sur l'hôte
|
||||||
|
frontend/
|
||||||
|
src/services/ # API et services métier (aucun fetch dans les pages)
|
||||||
|
src/context/ # État de la session et de l'édition
|
||||||
|
src/components/cms/ # Éditeur, pages, blocs et thèmes
|
||||||
|
src/pages/education/ # Pages pédagogiques originales
|
||||||
|
src/pages/editor/ # Administration React intégrée
|
||||||
|
```
|
||||||
|
|
||||||
## Tests
|
## Développement et paramètres
|
||||||
|
|
||||||
- Backend : `python manage.py test education` (requiert les dépendances Python et une DB de test).
|
Les ports dans `.env.example` sont `FRONTEND_PORT=1036` et `BACKEND_PORT=1037`, à adapter à votre installation. **Ils doivent être ajoutés dans le `.env` réel** ; on n'utilise plus de ports internes `80/8000`. `docker compose up -d --build` lance MySQL, Django et Vite Preview, **sans Nginx**. Vite relaie `/api`, `/static` et `/media` à Django sur `BACKEND_PORT` ; sous Compose, le nom réseau `backend` est utilisé, et sous Pterodactyl, `127.0.0.1`. Quand `.git` existe, `start.sh` exécute `git fetch` puis `git reset --hard origin/main`, conformément au script fourni : **pousser les modifications sur Gitea avant de redémarrer**, sous peine de les voir écrasées. Le `start.sh` sert uniquement à un hébergement **hors Compose**, typiquement Pterodactyl, avec les dépendances Python/Node et une base MySQL déjà accessibles. Il garde la séquence Git → vérifications Django → migrations → ensure_admin → collectstatic → npm → build → Vite Preview → arrêt propre. Il ne crée ni n'installe la base MySQL.
|
||||||
- Frontend : `npm ci && npm run build`.
|
|
||||||
|
|
||||||
## Sécurité
|
Pour une installation locale autonome : dans `backend/`, utiliser `pip install -r requirements.txt`, `python manage.py migrate`, `python manage.py ensure_admin`, `python manage.py runserver 0.0.0.0:$BACKEND_PORT` ; dans `frontend/`, utiliser `npm install` puis `npm run dev -- --port $FRONTEND_PORT`. Oxlint : `npm run lint`.
|
||||||
|
|
||||||
Les fichiers `.env`, `.git` et les anciens modules de gestion de la boulangerie sont exclus de l'archive de distribution. Le modèle utilisateur personnalisé `users.User` et ses migrations sont préservés pour compatibilité avec l'ancienne structure de la base et l'administration Django.
|
Le formulaire de contact conserve ses messages en base ; les notifications par e-mail sont optionnelles (`CONTACT_NOTIFICATION_EMAIL`, `DEFAULT_FROM_EMAIL`, `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `EMAIL_USE_TLS`).
|
||||||
|
|
||||||
|
**Sécurité et données** : l'éditeur est réservé aux comptes `is_staff`, protégé par les sessions Django et CSRF ; les pages publiques ne donnent pas accès aux routes d'écriture administratives. Les médias sont limités à PNG/JPEG/WebP jusqu'à 5 Mo. Pour un site publié en production, activer HTTPS, contrôler les hôtes autorisés et conserver une sauvegarde de la base et des médias.
|
||||||
|
|
||||||
|
**Important pour la mise à jour** : si l’ancien fichier `frontend/nginx.conf` existe encore, il peut être supprimé ; il n’est plus lu par le Dockerfile. Ne pas remplacer le fichier `.env` existant par `.env.example` : y ajouter simplement les deux variables de ports. En cas de changement des ports, recréer les deux services avec `docker compose up -d --build --force-recreate`. Pour un domaine public, ajouter le domaine dans `ALLOWED_HOSTS` (et les origines HTTPS dans `CSRF_TRUSTED_ORIGINS`).
|
||||||
|
|||||||
0
backend/common/__init__.py
Normal file
0
backend/common/__init__.py
Normal file
151
backend/common/admin_api.py
Normal file
151
backend/common/admin_api.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
"""API d'administration pour l'interface React (authentification Django par session)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.contrib.auth import authenticate, get_user_model, login, logout
|
||||||
|
from django.contrib.auth.password_validation import validate_password
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.middleware.csrf import get_token
|
||||||
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
|
from django.views.decorators.http import require_GET, require_POST
|
||||||
|
|
||||||
|
|
||||||
|
def _error(message, status=400, **extra):
|
||||||
|
return JsonResponse({"detail": message, **extra}, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_body(request):
|
||||||
|
try:
|
||||||
|
payload = json.loads(request.body or "{}")
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return None
|
||||||
|
return payload if isinstance(payload, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_only(request, superuser=False):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return _error("Connexion requise.", 401)
|
||||||
|
if not request.user.is_active or not request.user.is_staff:
|
||||||
|
return _error("Accès administrateur requis.", 403)
|
||||||
|
if superuser and not request.user.is_superuser:
|
||||||
|
return _error("Action réservée aux superutilisateurs.", 403)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _user_data(user):
|
||||||
|
return {
|
||||||
|
"id": user.pk,
|
||||||
|
"username": user.get_username(),
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"email": user.email,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"is_staff": user.is_staff,
|
||||||
|
"is_superuser": user.is_superuser,
|
||||||
|
"last_login": user.last_login.isoformat() if user.last_login else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
@ensure_csrf_cookie
|
||||||
|
def csrf(request):
|
||||||
|
# Prépare un cookie CSRF avant la première requête POST (connexion incluse).
|
||||||
|
get_token(request)
|
||||||
|
return JsonResponse({"detail": "CSRF prêt."})
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def me(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return _error("Connexion requise.", 401)
|
||||||
|
return JsonResponse({"user": _user_data(request.user)})
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def sign_in(request):
|
||||||
|
data = _json_body(request)
|
||||||
|
if data is None:
|
||||||
|
return _error("Données JSON invalides.")
|
||||||
|
username = str(data.get("username", "")).strip()
|
||||||
|
password = data.get("password", "")
|
||||||
|
if not isinstance(password, str):
|
||||||
|
return _error("Mot de passe invalide.")
|
||||||
|
user = authenticate(request, username=username, password=password)
|
||||||
|
if user is None:
|
||||||
|
return _error("Identifiants incorrects.", 401)
|
||||||
|
if not user.is_staff:
|
||||||
|
return _error("Accès administrateur requis.", 403)
|
||||||
|
login(request, user)
|
||||||
|
return JsonResponse({"user": _user_data(user)})
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def sign_out(request):
|
||||||
|
logout(request)
|
||||||
|
return JsonResponse({"detail": "Déconnexion effectuée."})
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def dashboard(request):
|
||||||
|
denied = _admin_only(request)
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
User = get_user_model()
|
||||||
|
users = User.objects.all()
|
||||||
|
return JsonResponse({
|
||||||
|
"stats": {
|
||||||
|
"total": users.count(),
|
||||||
|
"active": users.filter(is_active=True).count(),
|
||||||
|
"staff": users.filter(is_staff=True).count(),
|
||||||
|
},
|
||||||
|
"users": [_user_data(user) for user in users.order_by("-date_joined", "-pk")[:200]],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def create_user(request):
|
||||||
|
denied = _admin_only(request, superuser=True)
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
data = _json_body(request)
|
||||||
|
if data is None:
|
||||||
|
return _error("Données JSON invalides.")
|
||||||
|
username = str(data.get("username", "")).strip()
|
||||||
|
password = data.get("password")
|
||||||
|
email = str(data.get("email", "")).strip()
|
||||||
|
if not username or len(username) > 150:
|
||||||
|
return _error("Nom d'utilisateur invalide.")
|
||||||
|
if not isinstance(password, str) or not password:
|
||||||
|
return _error("Mot de passe requis.")
|
||||||
|
User = get_user_model()
|
||||||
|
if User.objects.filter(username__iexact=username).exists():
|
||||||
|
return _error("Ce nom d'utilisateur existe déjà.", 409)
|
||||||
|
temp_user = User(username=username, email=email)
|
||||||
|
try:
|
||||||
|
temp_user.full_clean(exclude=["password"], validate_unique=False)
|
||||||
|
validate_password(password, user=temp_user)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return _error(" ".join(exc.messages))
|
||||||
|
user = User.objects.create_user(username=username, password=password, email=email)
|
||||||
|
return JsonResponse({"user": _user_data(user)}, status=201)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def set_user_active(request, user_id):
|
||||||
|
denied = _admin_only(request, superuser=True)
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
data = _json_body(request)
|
||||||
|
if data is None or type(data.get("is_active")) is not bool:
|
||||||
|
return _error("La propriété is_active doit être un booléen.")
|
||||||
|
User = get_user_model()
|
||||||
|
try:
|
||||||
|
target = User.objects.get(pk=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return _error("Utilisateur introuvable.", 404)
|
||||||
|
if target.pk == request.user.pk or target.is_superuser:
|
||||||
|
return _error("Impossible de modifier ce compte.", 403)
|
||||||
|
target.is_active = data["is_active"]
|
||||||
|
target.save(update_fields=["is_active"])
|
||||||
|
return JsonResponse({"user": _user_data(target)})
|
||||||
6
backend/common/apps.py
Normal file
6
backend/common/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CommonConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "common"
|
||||||
179
backend/common/cms_api.py
Normal file
179
backend/common/cms_api.py
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
"""API du CMS, lecture publique et écriture réservée aux administrateurs.
|
||||||
|
|
||||||
|
Authentification par session Django / protection CSRF native DRF.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import FileResponse, Http404
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from rest_framework import permissions, serializers, status
|
||||||
|
from rest_framework.parsers import FormParser, MultiPartParser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from .models import SiteContent, SitePage, SiteBlock
|
||||||
|
|
||||||
|
KEY_PATTERN = re.compile(r'^[a-z][a-z0-9._-]{1,99}$')
|
||||||
|
MEDIA_PATTERN = re.compile(r'^[a-f0-9]{32}\.(png|jpg|webp)$')
|
||||||
|
IMAGE_TYPES = {'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp'}
|
||||||
|
FORBIDDEN_PAGE_SLUGS = {'admin', 'api', 'static', 'media', 'home'}
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(request):
|
||||||
|
return request.user.is_authenticated and request.user.is_active and request.user.is_staff
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_link(value):
|
||||||
|
return not value or (value.startswith('/') and not value.startswith('//') and not value.startswith('/\\')) or value.startswith('https://') or bool(re.fullmatch(r'tel:[+0-9 ()-]{3,40}', value)) or bool(re.fullmatch(r'mailto:[a-zA-Z0-9._+%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', value))
|
||||||
|
|
||||||
|
|
||||||
|
class ContentView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return Response({row.key: row.value for row in SiteContent.objects.all()})
|
||||||
|
|
||||||
|
|
||||||
|
class ContentEntryView(APIView):
|
||||||
|
permission_classes = [permissions.IsAdminUser]
|
||||||
|
|
||||||
|
def put(self, request, key):
|
||||||
|
if not KEY_PATTERN.fullmatch(key):
|
||||||
|
return Response({'detail': 'Clé de contenu invalide.'}, status=400)
|
||||||
|
value = request.data.get('value')
|
||||||
|
if not isinstance(value, str) or len(value) > 12000:
|
||||||
|
return Response({'detail': 'Texte invalide (maximum 12 000 caractères).'}, status=400)
|
||||||
|
if key.endswith(('.url', '.href', '.image', '.logo')) and not allowed_link(value):
|
||||||
|
return Response({'detail': 'Utilisez un chemin interne, une URL HTTPS, mailto: ou tel:.'}, status=400)
|
||||||
|
if key.startswith('theme.') and not re.fullmatch(r'#[a-fA-F0-9]{6}', value):
|
||||||
|
return Response({'detail': 'Couleur hexadécimale attendue (#RRGGBB).'}, status=400)
|
||||||
|
obj, _ = SiteContent.objects.update_or_create(key=key, defaults={'value': value})
|
||||||
|
return Response({'key': obj.key, 'value': obj.value})
|
||||||
|
|
||||||
|
def delete(self, request, key):
|
||||||
|
SiteContent.objects.filter(key=key).delete()
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
class PageSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = SitePage
|
||||||
|
fields = ('id', 'slug', 'title', 'description', 'nav_label', 'position', 'published', 'show_in_nav')
|
||||||
|
|
||||||
|
def validate_slug(self, value):
|
||||||
|
if value in FORBIDDEN_PAGE_SLUGS or value in getattr(settings, 'CMS_RESERVED_SLUGS', set()):
|
||||||
|
raise serializers.ValidationError('Ce chemin est réservé.')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class BlockSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = SiteBlock
|
||||||
|
fields = ('id', 'page_slug', 'kind', 'title', 'body', 'image', 'url', 'position', 'published')
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
for field in ('url', 'image'):
|
||||||
|
value = attrs.get(field, getattr(self.instance, field, ''))
|
||||||
|
if not allowed_link(value):
|
||||||
|
raise serializers.ValidationError({field: 'Adresse non autorisée : URL HTTPS ou chemin interne uniquement.'})
|
||||||
|
if len(attrs.get('body', '')) > 12000:
|
||||||
|
raise serializers.ValidationError({'body': 'Texte trop long.'})
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
model = None
|
||||||
|
serializer_class = None
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
items = self.model.objects.all()
|
||||||
|
if not is_admin(request):
|
||||||
|
items = items.filter(published=True)
|
||||||
|
slug = request.query_params.get('page_slug')
|
||||||
|
if slug and self.model is SiteBlock:
|
||||||
|
items = items.filter(page_slug=slug)
|
||||||
|
return Response(self.serializer_class(items, many=True).data)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
if not is_admin(request):
|
||||||
|
return Response({'detail': 'Accès administrateur requis.'}, status=403)
|
||||||
|
serializer = self.serializer_class(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=201)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemView(APIView):
|
||||||
|
permission_classes = [permissions.IsAdminUser]
|
||||||
|
model = None
|
||||||
|
serializer_class = None
|
||||||
|
|
||||||
|
def patch(self, request, pk):
|
||||||
|
instance = get_object_or_404(self.model, pk=pk)
|
||||||
|
serializer = self.serializer_class(instance, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def delete(self, request, pk):
|
||||||
|
get_object_or_404(self.model, pk=pk).delete()
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
class PagesView(CollectionView):
|
||||||
|
model = SitePage
|
||||||
|
serializer_class = PageSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class PageView(ItemView):
|
||||||
|
model = SitePage
|
||||||
|
serializer_class = PageSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class BlocksView(CollectionView):
|
||||||
|
model = SiteBlock
|
||||||
|
serializer_class = BlockSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class BlockView(ItemView):
|
||||||
|
model = SiteBlock
|
||||||
|
serializer_class = BlockSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class UploadView(APIView):
|
||||||
|
permission_classes = [permissions.IsAdminUser]
|
||||||
|
parser_classes = [FormParser, MultiPartParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
image = request.FILES.get('image')
|
||||||
|
if not image or image.size > 5 * 1024 * 1024:
|
||||||
|
return Response({'detail': 'Image absente ou supérieure à 5 Mo.'}, status=400)
|
||||||
|
kind = IMAGE_TYPES.get(image.content_type)
|
||||||
|
if not kind:
|
||||||
|
return Response({'detail': 'Formats : PNG, JPEG, WebP.'}, status=400)
|
||||||
|
header = image.read(16)
|
||||||
|
image.seek(0)
|
||||||
|
valid = {'png': header.startswith(b'\x89PNG\r\n\x1a\n'), 'jpg': header.startswith(b'\xff\xd8\xff'), 'webp': header.startswith(b'RIFF') and header[8:12] == b'WEBP'}
|
||||||
|
if not valid[kind]:
|
||||||
|
return Response({'detail': 'Fichier image invalide.'}, status=400)
|
||||||
|
filename = f'{uuid.uuid4().hex}.{kind}'
|
||||||
|
folder = Path(settings.MEDIA_ROOT) / 'cms'
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
with (folder / filename).open('wb') as dest:
|
||||||
|
for chunk in image.chunks():
|
||||||
|
dest.write(chunk)
|
||||||
|
return Response({'url': f'/api/cms/media/{filename}'}, status=201)
|
||||||
|
|
||||||
|
|
||||||
|
def serve_image(request, filename):
|
||||||
|
if not MEDIA_PATTERN.fullmatch(filename):
|
||||||
|
raise Http404()
|
||||||
|
file = Path(settings.MEDIA_ROOT) / 'cms' / filename
|
||||||
|
if not file.is_file():
|
||||||
|
raise Http404()
|
||||||
|
extension = file.suffix
|
||||||
|
return FileResponse(file.open('rb'), content_type={'.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp'}[extension])
|
||||||
14
backend/common/cms_urls.py
Normal file
14
backend/common/cms_urls.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from .cms_api import (ContentView, ContentEntryView, PagesView, PageView,
|
||||||
|
BlocksView, BlockView, UploadView, serve_image)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('content/', ContentView.as_view()),
|
||||||
|
path('content/<str:key>/', ContentEntryView.as_view()),
|
||||||
|
path('pages/', PagesView.as_view()),
|
||||||
|
path('pages/<int:pk>/', PageView.as_view()),
|
||||||
|
path('blocks/', BlocksView.as_view()),
|
||||||
|
path('blocks/<int:pk>/', BlockView.as_view()),
|
||||||
|
path('upload/', UploadView.as_view()),
|
||||||
|
path('media/<str:filename>', serve_image),
|
||||||
|
]
|
||||||
0
backend/common/management/__init__.py
Normal file
0
backend/common/management/__init__.py
Normal file
0
backend/common/management/commands/__init__.py
Normal file
0
backend/common/management/commands/__init__.py
Normal file
29
backend/common/management/commands/ensure_admin.py
Normal file
29
backend/common/management/commands/ensure_admin.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""Crée le superutilisateur lors du premier démarrage, sans le modifier ensuite."""
|
||||||
|
from decouple import config
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Crée le compte administrateur initial depuis le .env."
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
username = config("ADMIN_USERNAME", default="").strip()
|
||||||
|
password = config("ADMIN_PASSWORD", default="")
|
||||||
|
if not username or not password:
|
||||||
|
self.stdout.write(self.style.WARNING("ADMIN_USERNAME ou ADMIN_PASSWORD absent : création ignorée."))
|
||||||
|
return
|
||||||
|
|
||||||
|
user_model = get_user_model()
|
||||||
|
if user_model.objects.filter(username=username).exists():
|
||||||
|
self.stdout.write("Compte administrateur déjà présent : mot de passe inchangé.")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_model.objects.create_superuser(
|
||||||
|
username=username,
|
||||||
|
email="",
|
||||||
|
password=password,
|
||||||
|
first_name=config("ADMIN_FIRST_NAME", default=""),
|
||||||
|
last_name=config("ADMIN_LAST_NAME", default=""),
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS("Administrateur initial créé."))
|
||||||
34
backend/common/migrations/0001_initial.py
Normal file
34
backend/common/migrations/0001_initial.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
dependencies = []
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(name='SiteContent', fields=[
|
||||||
|
('key', models.CharField(primary_key=True, max_length=100, serialize=False)),
|
||||||
|
('value', models.TextField(blank=True)),
|
||||||
|
('modified_at', models.DateTimeField(auto_now=True)),
|
||||||
|
], options={'ordering': ['key']}),
|
||||||
|
migrations.CreateModel(name='SitePage', fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('slug', models.SlugField(max_length=80, unique=True)),
|
||||||
|
('title', models.CharField(max_length=200)),
|
||||||
|
('description', models.CharField(blank=True, max_length=500)),
|
||||||
|
('nav_label', models.CharField(blank=True, max_length=100)),
|
||||||
|
('position', models.PositiveIntegerField(default=0)),
|
||||||
|
('published', models.BooleanField(default=True)),
|
||||||
|
('show_in_nav', models.BooleanField(default=True)),
|
||||||
|
], options={'ordering': ['position', 'pk']}),
|
||||||
|
migrations.CreateModel(name='SiteBlock', fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('page_slug', models.SlugField(db_index=True, max_length=80)),
|
||||||
|
('kind', models.CharField(choices=[('heading','Titre'),('text','Texte'),('image','Image'),('button','Bouton')], default='text', max_length=16)),
|
||||||
|
('title', models.CharField(blank=True, max_length=250)),
|
||||||
|
('body', models.TextField(blank=True)),
|
||||||
|
('image', models.CharField(blank=True, max_length=1200)),
|
||||||
|
('url', models.CharField(blank=True, max_length=1200)),
|
||||||
|
('position', models.PositiveIntegerField(default=0)),
|
||||||
|
('published', models.BooleanField(default=True)),
|
||||||
|
], options={'ordering': ['position', 'pk']}),
|
||||||
|
]
|
||||||
13
backend/common/migrations/0002_import_legacy_content.py
Normal file
13
backend/common/migrations/0002_import_legacy_content.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def import_existing_content(apps, schema_editor):
|
||||||
|
Old = apps.get_model('education', 'SiteContent')
|
||||||
|
New = apps.get_model('common', 'SiteContent')
|
||||||
|
for old in Old.objects.using(schema_editor.connection.alias).all().iterator():
|
||||||
|
New.objects.using(schema_editor.connection.alias).get_or_create(key=old.key, defaults={'value': old.value})
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [('common', '0001_initial'), ('education', '0004_alter_announcement_id_alter_contactmessage_id_and_more')]
|
||||||
|
operations = [migrations.RunPython(import_existing_content, migrations.RunPython.noop)]
|
||||||
0
backend/common/migrations/__init__.py
Normal file
0
backend/common/migrations/__init__.py
Normal file
46
backend/common/models.py
Normal file
46
backend/common/models.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"""CMS réutilisable : contenus, pages libres et blocs réordonnables."""
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class SiteContent(models.Model):
|
||||||
|
key = models.CharField(max_length=100, primary_key=True)
|
||||||
|
value = models.TextField(blank=True)
|
||||||
|
modified_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['key']
|
||||||
|
|
||||||
|
|
||||||
|
class SitePage(models.Model):
|
||||||
|
slug = models.SlugField(max_length=80, unique=True)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
description = models.CharField(max_length=500, blank=True)
|
||||||
|
nav_label = models.CharField(max_length=100, blank=True)
|
||||||
|
position = models.PositiveIntegerField(default=0)
|
||||||
|
published = models.BooleanField(default=True)
|
||||||
|
show_in_nav = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['position', 'pk']
|
||||||
|
|
||||||
|
|
||||||
|
class SiteBlock(models.Model):
|
||||||
|
class Kind(models.TextChoices):
|
||||||
|
HEADING = 'heading', 'Titre'
|
||||||
|
TEXT = 'text', 'Texte'
|
||||||
|
IMAGE = 'image', 'Image'
|
||||||
|
BUTTON = 'button', 'Bouton'
|
||||||
|
|
||||||
|
# "home" = accueil ; "presentation", etc. = chemins existants ;
|
||||||
|
# les pages libres utilisent leur slug.
|
||||||
|
page_slug = models.SlugField(max_length=80, db_index=True)
|
||||||
|
kind = models.CharField(max_length=16, choices=Kind.choices, default=Kind.TEXT)
|
||||||
|
title = models.CharField(max_length=250, blank=True)
|
||||||
|
body = models.TextField(blank=True)
|
||||||
|
image = models.CharField(max_length=1200, blank=True)
|
||||||
|
url = models.CharField(max_length=1200, blank=True)
|
||||||
|
position = models.PositiveIntegerField(default=0)
|
||||||
|
published = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['position', 'pk']
|
||||||
65
backend/common/tests.py
Normal file
65
backend/common/tests.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"""Régressions des droits admin. Lancer avec : python manage.py test common."""
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import Resolver404, resolve, reverse
|
||||||
|
|
||||||
|
|
||||||
|
class AdminApiTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.superuser = User.objects.create_superuser(username="owner", password="TestPassword2026!")
|
||||||
|
self.staff = User.objects.create_user(username="staff", password="TestPassword2026!", is_staff=True)
|
||||||
|
self.member = User.objects.create_user(username="member", password="TestPassword2026!")
|
||||||
|
|
||||||
|
def test_dashboard_rejects_anonymous_and_regular_users(self):
|
||||||
|
self.assertEqual(self.client.get(reverse("admin-dashboard")).status_code, 401)
|
||||||
|
self.client.force_login(self.member)
|
||||||
|
self.assertEqual(self.client.get(reverse("admin-dashboard")).status_code, 403)
|
||||||
|
|
||||||
|
def test_staff_can_read_but_not_write(self):
|
||||||
|
self.client.force_login(self.staff)
|
||||||
|
response = self.client.get(reverse("admin-dashboard"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["stats"]["total"], 3)
|
||||||
|
self.assertEqual(self.client.post(reverse("admin-create-user"), data='{"username":"newuser", "password":"AnotherPassword2026!"}', content_type="application/json").status_code, 403)
|
||||||
|
|
||||||
|
def test_superuser_can_create_and_disable_standard_account(self):
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
response = self.client.post(reverse("admin-create-user"), data='{"username":"newuser", "password":"AnotherPassword2026!"}', content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
created = get_user_model().objects.get(username="newuser")
|
||||||
|
self.assertFalse(created.is_staff)
|
||||||
|
response = self.client.post(reverse("admin-user-active", kwargs={"user_id": created.pk}), data='{"is_active":false}', content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
created.refresh_from_db()
|
||||||
|
self.assertFalse(created.is_active)
|
||||||
|
|
||||||
|
def test_superuser_cannot_disable_self_or_other_superusers(self):
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
response = self.client.post(reverse("admin-user-active", kwargs={"user_id": self.superuser.pk}), data='{"is_active":false}', content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_csrf_required_for_login(self):
|
||||||
|
client = Client(enforce_csrf_checks=True)
|
||||||
|
client.get(reverse("auth-csrf"))
|
||||||
|
self.assertEqual(client.post(reverse("auth-login"), data='{"username":"owner", "password":"TestPassword2026!"}', content_type="application/json").status_code, 403)
|
||||||
|
token = client.cookies["csrftoken"].value
|
||||||
|
response = client.post(reverse("auth-login"), data='{"username":"owner", "password":"TestPassword2026!"}', content_type="application/json", HTTP_X_CSRFTOKEN=token)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["user"]["username"], "owner")
|
||||||
|
|
||||||
|
def test_ordinary_user_cannot_log_in_to_admin(self):
|
||||||
|
response = self.client.post(reverse("auth-login"), data='{"username":"member", "password":"TestPassword2026!"}', content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertNotIn("_auth_user_id", self.client.session)
|
||||||
|
|
||||||
|
|
||||||
|
class NoDjangoAdminTests(TestCase):
|
||||||
|
def test_native_django_admin_is_absent(self):
|
||||||
|
self.assertNotIn("django.contrib.admin", settings.INSTALLED_APPS)
|
||||||
|
with self.assertRaises(Resolver404):
|
||||||
|
resolve("/django-admin/")
|
||||||
|
|
||||||
|
def test_custom_admin_api_still_exists(self):
|
||||||
|
self.assertEqual(reverse("admin-dashboard"), "/api/admin/dashboard/")
|
||||||
44
backend/common/tests_cms.py
Normal file
44
backend/common/tests_cms.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"""Tests : CMS public, droits, liens et pages libres."""
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from .models import SiteContent, SiteBlock, SitePage
|
||||||
|
|
||||||
|
|
||||||
|
class CMSTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.admin = User.objects.create_superuser(username='editor', password='StrongPass2026!')
|
||||||
|
self.member = User.objects.create_user(username='member', password='StrongPass2026!')
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_public_content_read_only(self):
|
||||||
|
SiteContent.objects.create(key='home.title', value='Bonjour')
|
||||||
|
self.assertEqual(self.client.get('/api/cms/content/').data['home.title'], 'Bonjour')
|
||||||
|
self.assertEqual(self.client.put('/api/cms/content/home.title/', {'value': 'Non'}, format='json').status_code, 403)
|
||||||
|
self.client.force_authenticate(user=self.member)
|
||||||
|
self.assertEqual(self.client.put('/api/cms/content/home.title/', {'value': 'Non'}, format='json').status_code, 403)
|
||||||
|
|
||||||
|
def test_admin_can_edit_and_reset(self):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
self.assertEqual(self.client.put('/api/cms/content/home.title/', {'value': 'Texte'}, format='json').status_code, 200)
|
||||||
|
self.assertEqual(self.client.delete('/api/cms/content/home.title/').status_code, 204)
|
||||||
|
self.assertFalse(SiteContent.objects.filter(key='home.title').exists())
|
||||||
|
|
||||||
|
def test_unsafe_links_rejected(self):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
self.assertEqual(self.client.put('/api/cms/content/contact.button.href/', {'value': 'javascript:alert(1)'}, format='json').status_code, 400)
|
||||||
|
self.assertEqual(self.client.post('/api/cms/blocks/', {'page_slug': 'home', 'kind': 'button', 'url': 'javascript:alert(1)'}, format='json').status_code, 400)
|
||||||
|
|
||||||
|
def test_pages_and_blocks_publication(self):
|
||||||
|
page = SitePage.objects.create(slug='cours', title='Cours', published=False)
|
||||||
|
SiteBlock.objects.create(page_slug='cours', kind='text', body='Interne', published=False)
|
||||||
|
self.assertFalse(self.client.get('/api/cms/pages/').data)
|
||||||
|
self.assertFalse(self.client.get('/api/cms/blocks/').data)
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
self.assertEqual(self.client.get('/api/cms/pages/').data[0]['id'], page.pk)
|
||||||
|
self.assertEqual(len(self.client.get('/api/cms/blocks/').data), 1)
|
||||||
|
|
||||||
|
def test_media_upload_is_private(self):
|
||||||
|
self.assertEqual(self.client.post('/api/cms/upload/').status_code, 403)
|
||||||
15
backend/common/urls.py
Normal file
15
backend/common/urls.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .admin_api import create_user, csrf, dashboard, me, set_user_active, sign_in, sign_out
|
||||||
|
from .views import health
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("health/", health, name="api-health"),
|
||||||
|
path("auth/csrf/", csrf, name="auth-csrf"),
|
||||||
|
path("auth/me/", me, name="auth-me"),
|
||||||
|
path("auth/login/", sign_in, name="auth-login"),
|
||||||
|
path("auth/logout/", sign_out, name="auth-logout"),
|
||||||
|
path("admin/dashboard/", dashboard, name="admin-dashboard"),
|
||||||
|
path("admin/users/", create_user, name="admin-create-user"),
|
||||||
|
path("admin/users/<int:user_id>/active/", set_user_active, name="admin-user-active"),
|
||||||
|
]
|
||||||
9
backend/common/views.py
Normal file
9
backend/common/views.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def health(request):
|
||||||
|
return Response({"status": "ok", "message": "Django fonctionne !"})
|
||||||
31
backend/core/origins.py
Normal file
31
backend/core/origins.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""Helpers indépendants de Django pour composer les origines HTTP depuis .env."""
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
|
||||||
|
def csv_values(raw):
|
||||||
|
return list(dict.fromkeys(item.strip() for item in str(raw or "").split(",") if item.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def append_port(origin, port):
|
||||||
|
"""Ajoute un port aux origines sans port explicite, sans doubler un port existant."""
|
||||||
|
value = origin.strip().rstrip("/")
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
|
raise ValueError(f"Origine invalide : {origin!r}. Utiliser http:// ou https://")
|
||||||
|
if parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment:
|
||||||
|
raise ValueError(f"Une origine ne doit pas contenir de chemin ou d'identifiants : {origin!r}")
|
||||||
|
if not (1 <= int(port) <= 65535):
|
||||||
|
raise ValueError(f"Port hors plage : {port}")
|
||||||
|
# parsed.port valide aussi la syntaxe des ports explicites.
|
||||||
|
netloc = parsed.netloc if parsed.port is not None else f"{parsed.netloc}:{int(port)}"
|
||||||
|
return urlunsplit((parsed.scheme, netloc, "", "", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def effective_origins(raw, port, auto_port=True):
|
||||||
|
items = csv_values(raw)
|
||||||
|
if auto_port:
|
||||||
|
items = [append_port(origin, port) for origin in items]
|
||||||
|
else:
|
||||||
|
for origin in items:
|
||||||
|
append_port(origin, port) # Valide la syntaxe sans changer les ports.
|
||||||
|
return list(dict.fromkeys(items))
|
||||||
@ -1,162 +1,135 @@
|
|||||||
"""
|
"""Paramètres Django du template : origine + port composés automatiquement."""
|
||||||
Django settings for core project.
|
|
||||||
|
|
||||||
Generated by 'django-admin startproject' using Django 5.2.17.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/5.2/topics/settings/
|
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
|
||||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
|
||||||
"""
|
|
||||||
from datetime import timedelta
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from decouple import config
|
from decouple import config
|
||||||
import dj_database_url
|
from .origins import csv_values, effective_origins
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY = config("SECRET_KEY", default="")
|
||||||
|
if not SECRET_KEY:
|
||||||
|
raise RuntimeError("SECRET_KEY est vide : renseigne SECRET_KEY dans le fichier .env")
|
||||||
|
DEBUG = config("DEBUG", default=False, cast=bool)
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
FRONTEND_PORT = config("FRONTEND_PORT", default=1036, cast=int)
|
||||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
BACKEND_PORT = config("BACKEND_PORT", default=1037, cast=int)
|
||||||
|
ALLOWED_HOSTS = csv_values(config("ALLOWED_HOSTS", default="localhost,127.0.0.1,backend"))
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# http://localhost -> http://localhost:FRONTEND_PORT pour CORS
|
||||||
SECRET_KEY = config('SECRET_KEY')
|
CORS_ALLOWED_ORIGINS = effective_origins(
|
||||||
#it's
|
config("CORS_ALLOWED_ORIGINS", default="http://localhost"),
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
FRONTEND_PORT,
|
||||||
DEBUG = config('DEBUG', default=False, cast=bool)
|
auto_port=config("AUTO_PORT_CORS", default=True, cast=bool),
|
||||||
|
)
|
||||||
|
CORS_ALLOWED_ORIGINS = list(dict.fromkeys([*csv_values(config("CORS_ALLOWED_ORIGINS", default="http://localhost")), *CORS_ALLOWED_ORIGINS]))
|
||||||
|
|
||||||
def csv_env(name, default=''):
|
# http://localhost -> http://localhost:BACKEND_PORT pour CSRF
|
||||||
"""Lit une liste séparée par des virgules en ignorant les valeurs vides."""
|
_csrf_backend = effective_origins(
|
||||||
return [value.strip() for value in config(name, default=default).split(',') if value.strip()]
|
config("CSRF_TRUSTED_ORIGINS", default="http://localhost"),
|
||||||
|
BACKEND_PORT,
|
||||||
ALLOWED_HOSTS = csv_env('ALLOWED_HOSTS', 'localhost,127.0.0.1,backend')
|
auto_port=config("AUTO_PORT_CSRF", default=True, cast=bool),
|
||||||
|
)
|
||||||
# Application definition
|
# Ajoute également l'origine du frontend : nécessaire aux requêtes CSRF
|
||||||
|
# du tableau de bord React utilisant les sessions Django.
|
||||||
|
# Conserver aussi les domaines en HTTPS sans numéro de port (proxy inverse 443).
|
||||||
|
# Le composeur de ports est utile en local mais ne doit pas casser le domaine public.
|
||||||
|
CSRF_TRUSTED_ORIGINS = list(dict.fromkeys([
|
||||||
|
*csv_values(config("CSRF_TRUSTED_ORIGINS", default="http://localhost")),
|
||||||
|
*_csrf_backend,
|
||||||
|
*csv_values(config("CORS_ALLOWED_ORIGINS", default="http://localhost")),
|
||||||
|
*CORS_ALLOWED_ORIGINS,
|
||||||
|
]))
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
"django.contrib.auth",
|
||||||
'django.contrib.auth',
|
"django.contrib.contenttypes",
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.sessions",
|
||||||
'django.contrib.sessions',
|
"django.contrib.messages",
|
||||||
'django.contrib.messages',
|
"django.contrib.staticfiles",
|
||||||
'django.contrib.staticfiles',
|
"rest_framework",
|
||||||
# Third party apps
|
"corsheaders",
|
||||||
'rest_framework',
|
"common",
|
||||||
'rest_framework_simplejwt',
|
"users",
|
||||||
'rest_framework_simplejwt.token_blacklist',
|
"education",
|
||||||
'corsheaders',
|
|
||||||
# Local apps
|
|
||||||
'users',
|
|
||||||
'education',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'corsheaders.middleware.CorsMiddleware',
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
'django.middleware.security.SecurityMiddleware',
|
"django.middleware.security.SecurityMiddleware",
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
'django.middleware.common.CommonMiddleware',
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
"django.middleware.common.CommonMiddleware",
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'core.urls'
|
ROOT_URLCONF = "core.urls"
|
||||||
|
TEMPLATES = [{
|
||||||
TEMPLATES = [
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
{
|
"DIRS": [],
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"APP_DIRS": True,
|
||||||
'DIRS': [],
|
"OPTIONS": {
|
||||||
'APP_DIRS': True,
|
"context_processors": [
|
||||||
'OPTIONS': {
|
"django.template.context_processors.debug",
|
||||||
'context_processors': [
|
"django.template.context_processors.request",
|
||||||
'django.template.context_processors.request',
|
"django.contrib.auth.context_processors.auth",
|
||||||
'django.contrib.auth.context_processors.auth',
|
"django.contrib.messages.context_processors.messages",
|
||||||
'django.contrib.messages.context_processors.messages',
|
]
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]
|
}]
|
||||||
|
WSGI_APPLICATION = "core.wsgi.application"
|
||||||
WSGI_APPLICATION = 'core.wsgi.application'
|
ASGI_APPLICATION = "core.asgi.application"
|
||||||
|
|
||||||
|
|
||||||
# Database
|
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': dj_database_url.config(
|
"default": {
|
||||||
default=f"mysql://{config('MYSQL_USER', default='user')}:{config('MYSQL_PASSWORD', default='password')}@{config('MYSQL_HOST', default='host')}:{config('MYSQL_PORT', default='3306')}/{config('MYSQL_DATABASE', default='name')}"
|
"ENGINE": "django.db.backends.mysql",
|
||||||
)
|
"NAME": config("MYSQL_DATABASE"),
|
||||||
|
"USER": config("MYSQL_USER"),
|
||||||
|
"PASSWORD": config("MYSQL_PASSWORD"),
|
||||||
|
"HOST": config("MYSQL_HOST", default="db"),
|
||||||
|
"PORT": config("MYSQL_PORT", default=3306, cast=int),
|
||||||
|
"OPTIONS": {"charset": "utf8mb4"},
|
||||||
|
"CONN_MAX_AGE": 60,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Password validation
|
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||||
},
|
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||||
{
|
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
AUTH_USER_MODEL = 'users.User'
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework.authentication.SessionAuthentication"],
|
||||||
# Internationalization
|
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
|
||||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
}
|
||||||
|
|
||||||
LANGUAGE_CODE = 'fr-fr'
|
|
||||||
|
|
||||||
TIME_ZONE = 'Europe/Paris'
|
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "fr-fr"
|
||||||
|
TIME_ZONE = "Europe/Paris"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = "/static/"
|
||||||
# Static files (CSS, JavaScript, Images)
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
STORAGES = {
|
||||||
|
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
|
||||||
STATIC_URL = 'static/'
|
|
||||||
|
|
||||||
# CORS configuration
|
|
||||||
CORS_ALLOWED_ORIGINS = csv_env('CORS_ALLOWED_ORIGINS')
|
|
||||||
CORS_ALLOW_ALL_ORIGINS = config('CORS_ALLOW_ALL_ORIGINS', default=False, cast=bool)
|
|
||||||
CSRF_TRUSTED_ORIGINS = csv_env('CSRF_TRUSTED_ORIGINS')
|
|
||||||
|
|
||||||
# REST Framework & JWT settings
|
|
||||||
REST_FRAMEWORK = {
|
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
|
||||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
|
||||||
),
|
|
||||||
'DEFAULT_PERMISSION_CLASSES': [
|
|
||||||
'rest_framework.permissions.AllowAny',
|
|
||||||
],
|
|
||||||
'DEFAULT_THROTTLE_RATES': {'contact': '5/hour', 'editor_login': '15/hour'},
|
|
||||||
}
|
}
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
|
SESSION_COOKIE_SAMESITE = "Lax"
|
||||||
|
CSRF_COOKIE_SAMESITE = "Lax"
|
||||||
|
SESSION_COOKIE_SECURE = config("SESSION_COOKIE_SECURE", default=False, cast=bool)
|
||||||
|
CSRF_COOKIE_SECURE = config("CSRF_COOKIE_SECURE", default=False, cast=bool)
|
||||||
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
|
|
||||||
SIMPLE_JWT = {
|
# Les images du CMS survivent aux redéploiements (volume Docker).
|
||||||
'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=36500),
|
MEDIA_URL = "/media/"
|
||||||
'ROTATE_REFRESH_TOKENS': True,
|
|
||||||
'BLACKLIST_AFTER_ROTATION': True,
|
|
||||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Default primary key field type
|
# Conserve le modèle utilisateur AccroCBio et ses migrations existantes.
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
AUTH_USER_MODEL = 'users.User'
|
||||||
|
|
||||||
|
|
||||||
# Notification facultative des messages du formulaire de contact.
|
|
||||||
CONTACT_NOTIFICATION_EMAIL = config('CONTACT_NOTIFICATION_EMAIL', default='')
|
CONTACT_NOTIFICATION_EMAIL = config('CONTACT_NOTIFICATION_EMAIL', default='')
|
||||||
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='no-reply@localhost')
|
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='no-reply@localhost')
|
||||||
EMAIL_HOST = config('EMAIL_HOST', default='localhost')
|
EMAIL_HOST = config('EMAIL_HOST', default='localhost')
|
||||||
@ -164,8 +137,7 @@ EMAIL_PORT = config('EMAIL_PORT', default=25, cast=int)
|
|||||||
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
|
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
|
||||||
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
|
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
|
||||||
EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=False, cast=bool)
|
EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=False, cast=bool)
|
||||||
|
REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = {'contact': '5/hour'}
|
||||||
|
|
||||||
# Le tableau d'administration reste sous /admin/ et utilise les comptes Django.
|
# Réserver les routes déjà utilisées par le site pédagogique.
|
||||||
|
CMS_RESERVED_SLUGS = {"presentation", "services-a-la-personne", "credit-impot", "ressources", "contact", "avis", "offre-de-rentree", "mentions-legales", "confidentialite"}
|
||||||
# Images téléversées depuis l’éditeur. Stockées sur le volume du backend.
|
|
||||||
MEDIA_ROOT = BASE_DIR / 'media'
|
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
from django.contrib import admin
|
from django.urls import path, include
|
||||||
from django.urls import include, path
|
|
||||||
|
|
||||||
admin.site.site_header = 'AccroCbio — Administration'
|
|
||||||
admin.site.site_title = 'AccroCbio'
|
|
||||||
admin.site.index_title = 'Gestion du site'
|
|
||||||
|
|
||||||
|
# /admin/ est une page React : aucun django-admin n'est exposé.
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('api/', include('common.urls')),
|
||||||
|
path('api/cms/', include('common.cms_urls')),
|
||||||
path('api/education/', include('education.urls')),
|
path('api/education/', include('education.urls')),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,78 +1,21 @@
|
|||||||
"""Edition du site public via une interface React protégée par JWT."""
|
"""Routes d’administration du contenu métier protégées par session Django."""
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import authenticate
|
|
||||||
from django.http import FileResponse, Http404
|
from django.http import FileResponse, Http404
|
||||||
from rest_framework import permissions, serializers, status, throttling
|
from rest_framework import permissions, serializers, status
|
||||||
from rest_framework.parsers import FormParser, MultiPartParser
|
from rest_framework.parsers import FormParser, MultiPartParser
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
|
||||||
|
|
||||||
from .models import Announcement, ContactMessage, Resource, SiteContent, Testimonial
|
from .models import Announcement, ContactMessage, Resource, Testimonial
|
||||||
|
|
||||||
KEY_PATTERN = re.compile(r'^[a-z][a-z0-9._-]{1,99}$')
|
KEY_PATTERN = re.compile(r'^[a-z][a-z0-9._-]{1,99}$')
|
||||||
IMAGE_EXTENSIONS = {'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp'}
|
IMAGE_EXTENSIONS = {'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp'}
|
||||||
|
|
||||||
|
|
||||||
class EditorLoginThrottle(throttling.AnonRateThrottle):
|
|
||||||
scope = 'editor_login'
|
|
||||||
|
|
||||||
|
|
||||||
class EditorLoginView(APIView):
|
|
||||||
authentication_classes = []
|
|
||||||
permission_classes = [permissions.AllowAny]
|
|
||||||
throttle_classes = [EditorLoginThrottle]
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
username = str(request.data.get('username', ''))[:150]
|
|
||||||
password = request.data.get('password', '')
|
|
||||||
user = authenticate(request, username=username, password=password)
|
|
||||||
if not user or not user.is_active or not user.is_staff:
|
|
||||||
return Response({'detail': 'Identifiants incorrects ou accès administrateur refusé.'}, status=401)
|
|
||||||
token = RefreshToken.for_user(user)
|
|
||||||
return Response({'access': str(token.access_token), 'username': user.username})
|
|
||||||
|
|
||||||
|
|
||||||
class EditorMeView(APIView):
|
|
||||||
permission_classes = [permissions.IsAdminUser]
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
return Response({'username': request.user.username, 'is_staff': True})
|
|
||||||
|
|
||||||
|
|
||||||
class SiteContentView(APIView):
|
|
||||||
authentication_classes = []
|
|
||||||
permission_classes = [permissions.AllowAny]
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
return Response({record.key: record.value for record in SiteContent.objects.all()})
|
|
||||||
|
|
||||||
|
|
||||||
class SiteContentEntryView(APIView):
|
|
||||||
permission_classes = [permissions.IsAdminUser]
|
|
||||||
|
|
||||||
def put(self, request, key):
|
|
||||||
if not KEY_PATTERN.fullmatch(key):
|
|
||||||
return Response({'detail': 'Clé de contenu invalide.'}, status=400)
|
|
||||||
value = request.data.get('value')
|
|
||||||
if not isinstance(value, str) or len(value) > 4000:
|
|
||||||
return Response({'detail': 'La valeur doit être un texte de 4000 caractères maximum.'}, status=400)
|
|
||||||
# Les liens ne peuvent pas contenir de protocole actif ou de URL relative suspecte.
|
|
||||||
if key.endswith('.url') or key.endswith('.href') or key.endswith('.image'):
|
|
||||||
if value and not ((value.startswith('/') and not value.startswith('//')) or value.startswith('https://') or (key.endswith('.href') and re.fullmatch(r'tel:[+0-9 ().-]{3,40}', value))):
|
|
||||||
return Response({'detail': 'Utilisez une route interne (/) ou une URL HTTPS.'}, status=400)
|
|
||||||
entry, _ = SiteContent.objects.update_or_create(key=key, defaults={'value': value})
|
|
||||||
return Response({'key': entry.key, 'value': entry.value})
|
|
||||||
|
|
||||||
def delete(self, request, key):
|
|
||||||
SiteContent.objects.filter(key=key).delete()
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
class EditorImageUploadView(APIView):
|
class EditorImageUploadView(APIView):
|
||||||
permission_classes = [permissions.IsAdminUser]
|
permission_classes = [permissions.IsAdminUser]
|
||||||
parser_classes = [MultiPartParser, FormParser]
|
parser_classes = [MultiPartParser, FormParser]
|
||||||
|
|||||||
@ -33,43 +33,40 @@ class PublicApiTests(TestCase):
|
|||||||
class SiteEditorTests(TestCase):
|
class SiteEditorTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from rest_framework.test import APIClient
|
|
||||||
self.client = APIClient()
|
self.client = APIClient()
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
self.admin_user = User.objects.create_superuser(username='editor', email='editor@example.com', password='a-safe-password!')
|
self.admin_user = User.objects.create_superuser(username='editor', email='editor@example.com', password='a-safe-password!')
|
||||||
self.normal_user = User.objects.create_user(username='visitor', password='a-safe-password!')
|
self.normal_user = User.objects.create_user(username='visitor', password='a-safe-password!')
|
||||||
|
|
||||||
def login_admin(self):
|
def login_admin(self):
|
||||||
response = self.client.post('/api/education/editor/login/', {'username': 'editor', 'password': 'a-safe-password!'})
|
response = self.client.post('/api/auth/login/', {'username': 'editor', 'password': 'a-safe-password!'}, format='json')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {response.data["access"]}')
|
|
||||||
|
|
||||||
def test_only_staff_can_log_in(self):
|
def test_only_staff_can_log_in(self):
|
||||||
response = self.client.post('/api/education/editor/login/', {'username': 'visitor', 'password': 'a-safe-password!'})
|
response = self.client.post('/api/auth/login/', {'username': 'visitor', 'password': 'a-safe-password!'}, format='json')
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
def test_public_can_read_but_not_edit_content(self):
|
def test_old_content_is_kept(self):
|
||||||
from .models import SiteContent
|
# Les anciennes personnalisations sont migrées par common.0002.
|
||||||
|
from common.models import SiteContent
|
||||||
SiteContent.objects.create(key='home.hero.title.first', value='Bonjour')
|
SiteContent.objects.create(key='home.hero.title.first', value='Bonjour')
|
||||||
response = self.client.get('/api/education/site-content/')
|
self.assertEqual(self.client.get('/api/cms/content/').data['home.hero.title.first'], 'Bonjour')
|
||||||
self.assertEqual(response.status_code, 200)
|
response = self.client.put('/api/cms/content/home.hero.title.first/', {'value': 'Interdit'}, format='json')
|
||||||
self.assertEqual(response.data['home.hero.title.first'], 'Bonjour')
|
|
||||||
response = self.client.put('/api/education/site-content/home.hero.title.first/', {'value': 'Interdit'}, format='json')
|
|
||||||
self.assertIn(response.status_code, (401, 403))
|
self.assertIn(response.status_code, (401, 403))
|
||||||
|
|
||||||
def test_admin_can_edit_and_restore_content(self):
|
def test_admin_can_edit_and_restore_content(self):
|
||||||
self.login_admin()
|
self.login_admin()
|
||||||
key = 'home.hero.title.first'
|
key = 'home.hero.title.first'
|
||||||
response = self.client.put(f'/api/education/site-content/{key}/', {'value': 'Nouveau titre'}, format='json')
|
response = self.client.put(f'/api/cms/content/{key}/', {'value': 'Nouveau titre'}, format='json')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(self.client.get('/api/education/site-content/').data[key], 'Nouveau titre')
|
self.assertEqual(self.client.get('/api/cms/content/').data[key], 'Nouveau titre')
|
||||||
response = self.client.delete(f'/api/education/site-content/{key}/')
|
response = self.client.delete(f'/api/cms/content/{key}/')
|
||||||
self.assertEqual(response.status_code, 204)
|
self.assertEqual(response.status_code, 204)
|
||||||
self.assertNotIn(key, self.client.get('/api/education/site-content/').data)
|
self.assertNotIn(key, self.client.get('/api/cms/content/').data)
|
||||||
|
|
||||||
def test_link_validator_rejects_scripts(self):
|
def test_link_validator_rejects_scripts(self):
|
||||||
self.login_admin()
|
self.login_admin()
|
||||||
response = self.client.put('/api/education/site-content/home.contact.href/', {'value': 'javascript:alert(1)'}, format='json')
|
response = self.client.put('/api/cms/content/home.contact.href/', {'value': 'javascript:alert(1)'}, format='json')
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
def test_resources_admin_crud(self):
|
def test_resources_admin_crud(self):
|
||||||
|
|||||||
@ -1,19 +1,14 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from .views import AnnouncementView, ContactView, ResourcesView, TestimonialsView
|
from .views import AnnouncementView, ContactView, ResourcesView, TestimonialsView
|
||||||
from .editor_views import (EditorLoginView, EditorMeView, SiteContentView,
|
from .editor_views import (serve_editor_image, EditorDataView, EditorDataDetailView)
|
||||||
SiteContentEntryView, EditorImageUploadView, serve_editor_image, EditorDataView, EditorDataDetailView)
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('resources/', ResourcesView.as_view(), name='resources'),
|
path('resources/', ResourcesView.as_view()),
|
||||||
path('testimonials/', TestimonialsView.as_view(), name='testimonials'),
|
path('testimonials/', TestimonialsView.as_view()),
|
||||||
path('announcements/', AnnouncementView.as_view(), name='announcements'),
|
path('announcements/', AnnouncementView.as_view()),
|
||||||
path('contact/', ContactView.as_view(), name='contact'),
|
path('contact/', ContactView.as_view()),
|
||||||
path('site-content/', SiteContentView.as_view(), name='site-content'),
|
# Conservé pour les images déjà publiées par l'ancien éditeur.
|
||||||
path('site-content/<str:key>/', SiteContentEntryView.as_view(), name='site-content-edit'),
|
path('editor-images/<str:filename>', serve_editor_image),
|
||||||
path('editor/login/', EditorLoginView.as_view(), name='editor-login'),
|
path('editor/data/<str:category>/', EditorDataView.as_view()),
|
||||||
path('editor/me/', EditorMeView.as_view(), name='editor-me'),
|
path('editor/data/<str:category>/<int:pk>/', EditorDataDetailView.as_view()),
|
||||||
path('editor/upload/', EditorImageUploadView.as_view(), name='editor-upload'),
|
|
||||||
path('editor-images/<str:filename>', serve_editor_image, name='editor-image'),
|
|
||||||
path('editor/data/<str:category>/', EditorDataView.as_view(), name='editor-data'),
|
|
||||||
path('editor/data/<str:category>/<int:pk>/', EditorDataDetailView.as_view(), name='editor-data-detail'),
|
|
||||||
]
|
]
|
||||||
|
|||||||
11
backend/entrypoint.sh
Normal file
11
backend/entrypoint.sh
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
: "${BACKEND_PORT:?BACKEND_PORT manquant dans .env}"
|
||||||
|
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
python manage.py ensure_admin
|
||||||
|
python manage.py collectstatic --noinput --verbosity 0
|
||||||
|
|
||||||
|
# Même port d'écoute dans le conteneur et sur l'hôte.
|
||||||
|
exec python manage.py runserver "0.0.0.0:${BACKEND_PORT}" --noreload
|
||||||
@ -1,7 +1,7 @@
|
|||||||
django
|
Django>=5.2,<6
|
||||||
djangorestframework
|
mysqlclient>=2.2,<3
|
||||||
djangorestframework-simplejwt
|
djangorestframework>=3.16,<4
|
||||||
django-cors-headers
|
django-cors-headers>=4.7,<5
|
||||||
mysqlclient
|
python-decouple>=3.8,<4
|
||||||
python-decouple
|
gunicorn>=23,<25
|
||||||
dj-database-url
|
whitenoise>=6.9,<7
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import NavBar from './components/NavBar';
|
|||||||
import AdminPage from './pages/editor/AdminPage';
|
import AdminPage from './pages/editor/AdminPage';
|
||||||
import { EditorProvider, useEditor } from '@/context/EditorContext';
|
import { EditorProvider, useEditor } from '@/context/EditorContext';
|
||||||
import { EditText } from '@/components/education/Editable';
|
import { EditText } from '@/components/education/Editable';
|
||||||
|
import { CmsTheme } from '@/components/cms/CmsTheme';
|
||||||
|
import { CmsBlocks, CmsDynamicPage } from '@/components/cms/CmsBlocks';
|
||||||
import { Edit3, LogOut, Settings2, Eye } from 'lucide-react';
|
import { Edit3, LogOut, Settings2, Eye } from 'lucide-react';
|
||||||
import HomePage from './pages/education/HomePage';
|
import HomePage from './pages/education/HomePage';
|
||||||
import PresentationPage from './pages/education/PresentationPage';
|
import PresentationPage from './pages/education/PresentationPage';
|
||||||
@ -50,14 +52,16 @@ function SiteFooter() {
|
|||||||
return <footer className="site-footer mt-auto">
|
return <footer className="site-footer mt-auto">
|
||||||
<div className="site-container flex flex-wrap justify-between gap-8 py-8">
|
<div className="site-container flex flex-wrap justify-between gap-8 py-8">
|
||||||
<div className="max-w-sm"><Link to="/" className="font-bold text-xl">Accro<span className="text-primary">C</span>bio</Link><p className="text-sm text-muted-foreground mt-3 leading-relaxed"><EditText id="global.footer.description" multiline>Cours particuliers de biologie, SVT et sciences médicales à domicile dans le nord des Deux-Sèvres.</EditText></p></div>
|
<div className="max-w-sm"><Link to="/" className="font-bold text-xl">Accro<span className="text-primary">C</span>bio</Link><p className="text-sm text-muted-foreground mt-3 leading-relaxed"><EditText id="global.footer.description" multiline>Cours particuliers de biologie, SVT et sciences médicales à domicile dans le nord des Deux-Sèvres.</EditText></p></div>
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-3 text-sm"><Link to="/presentation">À propos</Link><Link to="/ressources">Ressources</Link><Link to="/contact">Contact</Link><Link to="/mentions-legales">Mentions légales</Link><Link to="/confidentialite">Confidentialité</Link></div>
|
<div className="flex flex-wrap gap-x-6 gap-y-3 text-sm"><Link to="/presentation"><EditText id="global.footer.about">À propos</EditText></Link><Link to="/ressources"><EditText id="global.footer.resources">Ressources</EditText></Link><Link to="/contact"><EditText id="global.footer.contact">Contact</EditText></Link><Link to="/mentions-legales"><EditText id="global.footer.legal">Mentions légales</EditText></Link><Link to="/confidentialite"><EditText id="global.footer.privacy">Confidentialité</EditText></Link></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="site-container flex items-center justify-between gap-4 border-t border-border/70 py-4 text-xs text-muted-foreground"><span>© {new Date().getFullYear()} AccroCbio · Valérie Chaillou</span><a href="https://www.coursbiologiechaillou.fr/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1">Site de référence <ArrowUpRight className="size-3" /></a></div>
|
<div className="site-container flex items-center justify-between gap-4 border-t border-border/70 py-4 text-xs text-muted-foreground"><span>© {new Date().getFullYear()} AccroCbio · <EditText id="global.footer.teacher">Valérie Chaillou</EditText></span><a href="https://www.coursbiologiechaillou.fr/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1">Site de référence <ArrowUpRight className="size-3" /></a></div>
|
||||||
</footer>;
|
</footer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SiteApp() {
|
function SiteApp() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const contentSlug = pathname === '/' ? 'home' : pathname.startsWith('/ressources/') ? 'ressources' : pathname.split('/')[1];
|
||||||
|
const isBuiltIn = ['/', '/presentation', '/services-a-la-personne', '/credit-impot', '/ressources', '/contact', '/avis', '/offre-de-rentree', '/mentions-legales', '/confidentialite'].includes(pathname) || pathname.startsWith('/ressources/');
|
||||||
if (pathname === '/admin' || pathname.startsWith('/admin/')) return <AdminPage />;
|
if (pathname === '/admin' || pathname.startsWith('/admin/')) return <AdminPage />;
|
||||||
return <SidebarProvider defaultOpen={true}>
|
return <SidebarProvider defaultOpen={true}>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
@ -79,8 +83,10 @@ function SiteApp() {
|
|||||||
<Route path="/offre-de-rentree" element={<OfferPage />} />
|
<Route path="/offre-de-rentree" element={<OfferPage />} />
|
||||||
<Route path="/mentions-legales" element={<LegalPage type="legal" />} />
|
<Route path="/mentions-legales" element={<LegalPage type="legal" />} />
|
||||||
<Route path="/confidentialite" element={<LegalPage type="privacy" />} />
|
<Route path="/confidentialite" element={<LegalPage type="privacy" />} />
|
||||||
|
<Route path="/:slug" element={<CmsDynamicRoute />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
{isBuiltIn && <CmsBlocks slug={contentSlug}/>}
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
@ -88,4 +94,9 @@ function SiteApp() {
|
|||||||
</SidebarProvider>;
|
</SidebarProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() { return <EditorProvider><SiteApp /></EditorProvider>; }
|
function CmsDynamicRoute() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
return <CmsDynamicPage slug={pathname.slice(1)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() { return <EditorProvider><CmsTheme /><SiteApp /></EditorProvider>; }
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { BookOpenText, CircleHelp, FlaskConical, GraduationCap, Home, Mail, Mess
|
|||||||
import { Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar';
|
import { Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { EditImage, EditText } from '@/components/education/Editable';
|
import { EditImage, EditText } from '@/components/education/Editable';
|
||||||
|
import { useEditor } from '@/context/EditorContext';
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: '/', label: 'Accueil', icon: Home, end: true },
|
{ href: '/', label: 'Accueil', icon: Home, end: true },
|
||||||
@ -25,17 +26,21 @@ const extra = [
|
|||||||
|
|
||||||
export default function NavBar() {
|
export default function NavBar() {
|
||||||
const { isMobile, setOpenMobile } = useSidebar();
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
|
const editor = useEditor();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const close = () => { if (isMobile) setOpenMobile(false); };
|
const close = () => { if (isMobile) setOpenMobile(false); };
|
||||||
function group(items) {
|
function group(items) {
|
||||||
return items.map(({ href, label, icon: Icon, end }) => (
|
const key = (href) => href === '/' ? 'home' : href.slice(1);
|
||||||
|
return items.filter(({ href }) => editor.get(`nav.${key(href)}.visible`, 'true') !== 'false')
|
||||||
|
.sort((a, b) => Number(editor.get(`nav.${key(a.href)}.order`, '999')) - Number(editor.get(`nav.${key(b.href)}.order`, '999')))
|
||||||
|
.map(({ href, label, icon: Icon, end }) => (
|
||||||
<SidebarMenuItem key={href}>
|
<SidebarMenuItem key={href}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
tooltip={label}
|
tooltip={label}
|
||||||
isActive={end ? pathname === href : pathname === href || pathname.startsWith(`${href}/`)}
|
isActive={end ? pathname === href : pathname === href || pathname.startsWith(`${href}/`)}
|
||||||
className="h-10 px-2! gap-3 rounded-xl group-data-[collapsible=icon]:size-9! group-data-[collapsible=icon]:p-0!"
|
className="h-10 px-2! gap-3 rounded-xl group-data-[collapsible=icon]:size-9! group-data-[collapsible=icon]:p-0!"
|
||||||
render={<NavLink to={href} end={end} onClick={close} className="flex items-center w-full min-w-0 gap-3">
|
render={<NavLink to={href} end={end} onClick={close} className="flex items-center w-full min-w-0 gap-3">
|
||||||
<Icon className="size-4.5 shrink-0" /><span className="truncate text-[13px] font-medium group-data-[collapsible=icon]:hidden"><EditText id={`nav.${href.replaceAll('/', '.')}.label`} label="Nom du menu">{label}</EditText></span>
|
<Icon className="size-4.5 shrink-0" /><span className="truncate text-[13px] font-medium group-data-[collapsible=icon]:hidden"><EditText id={`nav.${key(href)}.label`} label="Nom du menu">{editor.get(`nav.${href.replaceAll('/', '.')}.label`, label)}</EditText></span>
|
||||||
</NavLink>}
|
</NavLink>}
|
||||||
/>
|
/>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
@ -46,16 +51,16 @@ export default function NavBar() {
|
|||||||
<SidebarHeader className="p-2 pt-5 pb-5 group-data-[collapsible=icon]:px-1.5">
|
<SidebarHeader className="p-2 pt-5 pb-5 group-data-[collapsible=icon]:px-1.5">
|
||||||
<Link to="/" className="flex items-center gap-3 px-2 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:justify-center" onClick={close} aria-label="AccroCbio, retour à l'accueil">
|
<Link to="/" className="flex items-center gap-3 px-2 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:justify-center" onClick={close} aria-label="AccroCbio, retour à l'accueil">
|
||||||
<EditImage id="global.logo" alt="Logo AccroCbio" className="logo-mark size-9 shrink-0"><FlaskConical className="size-5" /></EditImage>
|
<EditImage id="global.logo" alt="Logo AccroCbio" className="logo-mark size-9 shrink-0"><FlaskConical className="size-5" /></EditImage>
|
||||||
<span className="grid min-w-0 group-data-[collapsible=icon]:hidden"><span className="text-[19px] font-bold tracking-tight leading-none">Accro<span className="text-primary">C</span>bio</span><span className="text-[10px] mt-1.5 tracking-[0.16em] uppercase text-muted-foreground">Cours de biologie</span></span>
|
<span className="grid min-w-0 group-data-[collapsible=icon]:hidden"><span className="text-[19px] font-bold tracking-tight leading-none">Accro<span className="text-primary">C</span>bio</span><span className="text-[10px] mt-1.5 tracking-[0.16em] uppercase text-muted-foreground"><EditText id="global.subtitle">Cours de biologie</EditText></span></span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent className="px-2 pb-2">
|
<SidebarContent className="px-2 pb-2">
|
||||||
<SidebarGroup className="p-0 mb-4"><SidebarGroupLabel className="text-[10px] uppercase tracking-widest group-data-[collapsible=icon]:hidden">Découvrir</SidebarGroupLabel><SidebarGroupContent><SidebarMenu>{group(nav)}</SidebarMenu></SidebarGroupContent></SidebarGroup>
|
<SidebarGroup className="p-0 mb-4"><SidebarGroupLabel className="text-[10px] uppercase tracking-widest group-data-[collapsible=icon]:hidden"><EditText id="nav.section.discover">Découvrir</EditText></SidebarGroupLabel><SidebarGroupContent><SidebarMenu>{group(nav)}</SidebarMenu></SidebarGroupContent></SidebarGroup>
|
||||||
<SidebarGroup className="p-0 mb-4"><SidebarGroupLabel className="text-[10px] uppercase tracking-widest group-data-[collapsible=icon]:hidden">Bibliothèque</SidebarGroupLabel><SidebarGroupContent><SidebarMenu>{group(topics)}</SidebarMenu></SidebarGroupContent></SidebarGroup>
|
<SidebarGroup className="p-0 mb-4"><SidebarGroupLabel className="text-[10px] uppercase tracking-widest group-data-[collapsible=icon]:hidden"><EditText id="nav.section.library">Bibliothèque</EditText></SidebarGroupLabel><SidebarGroupContent><SidebarMenu>{group(topics)}</SidebarMenu></SidebarGroupContent></SidebarGroup>
|
||||||
<SidebarGroup className="p-0"><SidebarGroupLabel className="text-[10px] uppercase tracking-widest group-data-[collapsible=icon]:hidden">À propos</SidebarGroupLabel><SidebarGroupContent><SidebarMenu>{group(extra)}</SidebarMenu></SidebarGroupContent></SidebarGroup>
|
<SidebarGroup className="p-0"><SidebarGroupLabel className="text-[10px] uppercase tracking-widest group-data-[collapsible=icon]:hidden"><EditText id="nav.section.about">À propos</EditText></SidebarGroupLabel><SidebarGroupContent><SidebarMenu>{group(extra)}{group(editor.pages.filter((p) => p.show_in_nav && p.published).map((p) => ({ href: `/${p.slug}`, label: p.nav_label || p.title, icon: BookOpenText })))}</SidebarMenu></SidebarGroupContent></SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter className="p-2 pb-4">
|
<SidebarFooter className="p-2 pb-4">
|
||||||
<Button render={<Link to="/contact" onClick={close}><Mail className="size-4" /><span className="group-data-[collapsible=icon]:hidden">Me contacter</span><ChevronRight className="ml-auto size-3.5 group-data-[collapsible=icon]:hidden" /></Link>} className="h-10 w-full justify-start gap-2.5 group-data-[collapsible=icon]:size-9! group-data-[collapsible=icon]:px-2!" />
|
<Button render={<Link to="/contact" onClick={close}><Mail className="size-4" /><span className="group-data-[collapsible=icon]:hidden"><EditText id="nav.contact.button">Me contacter</EditText></span><ChevronRight className="ml-auto size-3.5 group-data-[collapsible=icon]:hidden" /></Link>} className="h-10 w-full justify-start gap-2.5 group-data-[collapsible=icon]:size-9! group-data-[collapsible=icon]:px-2!" />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
|
|||||||
22
frontend/src/components/cms/CmsBlocks.jsx
Normal file
22
frontend/src/components/cms/CmsBlocks.jsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { useEditor } from '@/context/EditorContext'
|
||||||
|
|
||||||
|
export function CmsBlocks({ slug = 'home' }) {
|
||||||
|
const { blocks, user } = useEditor()
|
||||||
|
const items = blocks.filter((item) => item.page_slug === slug && (item.published || user))
|
||||||
|
if (!items.length) return null
|
||||||
|
return <div className="mx-auto w-full max-w-6xl space-y-6 px-4 py-8 sm:px-6">
|
||||||
|
{items.map((item) => <section key={item.id} className={`rounded-2xl border border-border bg-card p-6 sm:p-9 ${!item.published ? 'opacity-50' : ''}`}>
|
||||||
|
{item.kind === 'heading' && <h2 className="text-3xl font-semibold tracking-tight">{item.title}</h2>}
|
||||||
|
{item.kind === 'text' && <><h2 className="mb-3 text-xl font-semibold">{item.title}</h2><p className="whitespace-pre-wrap leading-relaxed text-muted-foreground">{item.body}</p></>}
|
||||||
|
{item.kind === 'image' && <figure>{item.image && <img className="max-h-[480px] w-full rounded-xl object-contain" src={item.image} alt={item.title || 'Illustration'}/>} {item.title && <figcaption className="mt-2 text-center text-sm text-muted-foreground">{item.title}</figcaption>}</figure>}
|
||||||
|
{item.kind === 'button' && <a className="inline-flex items-center justify-center rounded-lg bg-primary px-5 py-2.5 font-semibold text-primary-foreground" href={item.url || '/'} target={item.url?.startsWith('https://') ? '_blank' : undefined} rel="noopener noreferrer">{item.title || 'En savoir plus'}</a>}
|
||||||
|
</section>)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CmsDynamicPage({ slug }) {
|
||||||
|
const { pages, user } = useEditor()
|
||||||
|
const page = pages.find((item) => item.slug === slug && (item.published || user))
|
||||||
|
if (!page) return <section className="mx-auto max-w-4xl px-5 py-20"><h1 className="text-2xl font-semibold">Page introuvable</h1></section>
|
||||||
|
return <section className="min-h-[60vh] py-12"><div className="mx-auto max-w-6xl px-4 sm:px-6"><h1 className="text-4xl font-semibold tracking-tight">{page.title}</h1>{page.description && <p className="mt-4 text-lg text-muted-foreground">{page.description}</p>}</div><CmsBlocks slug={slug}/></section>
|
||||||
|
}
|
||||||
53
frontend/src/components/cms/CmsFields.jsx
Normal file
53
frontend/src/components/cms/CmsFields.jsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ImagePlus, Pencil, RotateCcw, Save } from 'lucide-react'
|
||||||
|
import { useEditor } from '@/context/EditorContext'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
/** Champs utilisables n'importe où dans React ; édition visible aux administrateurs. */
|
||||||
|
export function CmsField({ id, children, type = 'text', label, className = '' }) {
|
||||||
|
const editor = useEditor()
|
||||||
|
const fallback = typeof children === 'string' ? children : ''
|
||||||
|
const shown = editor.get(id, fallback)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [draft, setDraft] = useState(shown)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
useEffect(() => { if (!open) setDraft(shown) }, [shown, open])
|
||||||
|
const editable = Boolean(editor.user && editor.editing)
|
||||||
|
|
||||||
|
async function save(reset = false) {
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
if (reset) await editor.resetContent(id)
|
||||||
|
else await editor.saveContent(id, draft)
|
||||||
|
setOpen(false)
|
||||||
|
} catch (err) { setError(err.message) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
async function upload(file) {
|
||||||
|
if (!file) return
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try { const data = await editor.uploadImage(file); setDraft(data.url) }
|
||||||
|
catch (err) { setError(err.message) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{type === 'image' ? <span className={`relative inline-block ${className}`}>
|
||||||
|
{shown ? <img src={shown} alt={label || ''} className="h-full w-full object-cover" /> : children || <span className="grid min-h-32 min-w-32 place-items-center bg-muted"><ImagePlus /></span>}
|
||||||
|
{editable && <button type="button" className="absolute bottom-2 right-2 flex items-center gap-1 rounded-lg bg-primary px-2 py-1 text-xs text-primary-foreground" onClick={() => setOpen(true)}><ImagePlus className="size-3"/> Image</button>}
|
||||||
|
</span> : <span className={`relative ${className} ${editable ? 'cms-editable' : ''}`}>
|
||||||
|
<span className={type === 'textarea' ? 'whitespace-pre-line' : ''}>{shown}</span>
|
||||||
|
{editable && <button type="button" className="cms-edit-button" title={`Modifier : ${label || id}`} aria-label={`Modifier : ${label || id}`} onClick={(event) => { event.preventDefault(); event.stopPropagation(); setOpen(true) }}><Pencil className="size-3.5" /></button>}
|
||||||
|
</span>}
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}><DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>{label || 'Modifier le contenu'}</DialogTitle><DialogDescription>Les modifications seront visibles immédiatement.</DialogDescription></DialogHeader>
|
||||||
|
{type === 'image' && <label className="block cursor-pointer rounded-lg border border-dashed p-4 text-center text-sm">Téléverser une image (PNG, JPEG ou WebP, 5 Mo maxi)<input className="mt-2 block w-full text-xs" type="file" accept="image/png,image/jpeg,image/webp" disabled={busy} onChange={(event) => { upload(event.target.files?.[0]); event.target.value = '' }}/></label>}
|
||||||
|
{type === 'textarea' ? <textarea className="min-h-36 w-full rounded-lg border border-input bg-background p-3 text-sm" value={draft} maxLength={12000} onChange={(event) => setDraft(event.target.value)} /> : <Input value={draft} maxLength={type === 'image' ? 1200 : 12000} onChange={(event) => setDraft(event.target.value)} />}
|
||||||
|
{error && <p className="text-sm text-destructive" role="alert">{error}</p>}
|
||||||
|
<div className="flex flex-wrap justify-end gap-2"><Button variant="outline" disabled={busy} onClick={() => save(true)}><RotateCcw className="size-4"/> Réinitialiser</Button><Button disabled={busy} onClick={() => save(false)}><Save className="size-4"/> Enregistrer</Button></div>
|
||||||
|
</DialogContent></Dialog>
|
||||||
|
</>
|
||||||
|
}
|
||||||
165
frontend/src/components/cms/CmsManager.jsx
Normal file
165
frontend/src/components/cms/CmsManager.jsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { ArrowDown, ArrowUp, Eye, EyeOff, FilePlus, ImagePlus, LayoutTemplate, Plus, RefreshCw, Save, Trash2 } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { cmsService } from '@/services/cmsService'
|
||||||
|
import { useEditor } from '@/context/EditorContext'
|
||||||
|
import { PALETTE } from '@/components/cms/CmsTheme'
|
||||||
|
|
||||||
|
const EMPTY_PAGE = { slug: '', title: '', description: '', nav_label: '', position: 0, published: true, show_in_nav: true }
|
||||||
|
const EMPTY_BLOCK = { page_slug: 'home', kind: 'text', title: '', body: '', image: '', url: '', published: true, position: 0 }
|
||||||
|
const modes = { pages: 'Pages', navigation: 'Navigation', blocks: 'Blocs', appearance: 'Apparence', values: 'Textes / liens' }
|
||||||
|
const selectClass = 'h-9 rounded-lg border border-input bg-background px-3 text-sm'
|
||||||
|
const textareaClass = 'w-full min-h-24 rounded-lg border border-input bg-background px-3 py-2 text-sm'
|
||||||
|
|
||||||
|
function CheckboxField({ label, checked, onChange }) {
|
||||||
|
return <label className="flex cursor-pointer items-center gap-2 text-sm"><input type="checkbox" checked={Boolean(checked)} onChange={(e) => onChange(e.target.checked)} className="size-4 accent-primary"/>{label}</label>
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormField({ label, children }) {
|
||||||
|
return <div className="space-y-1.5"><Label>{label}</Label>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CmsManager({ builtInPages = [{ slug: 'home', label: 'Accueil' }] }) {
|
||||||
|
const editor = useEditor()
|
||||||
|
const { pages, blocks, reloadStructure, content, saveContent, resetContent } = editor
|
||||||
|
const [tab, setTab] = useState('blocks')
|
||||||
|
const [activeSlug, setActiveSlug] = useState('home')
|
||||||
|
const [pageDraft, setPageDraft] = useState(null)
|
||||||
|
const [blockDraft, setBlockDraft] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [colorMode, setColorMode] = useState('light')
|
||||||
|
const [newKey, setNewKey] = useState('')
|
||||||
|
const [newValue, setNewValue] = useState('')
|
||||||
|
|
||||||
|
const locations = useMemo(() => [...builtInPages, ...pages.map((page) => ({ slug: page.slug, label: page.title }))], [builtInPages, pages])
|
||||||
|
const pageBlocks = useMemo(() => blocks.filter((block) => block.page_slug === activeSlug).sort((a,b) => a.position - b.position || a.id - b.id), [blocks, activeSlug])
|
||||||
|
|
||||||
|
async function change(task, message = 'Modifications enregistrées.') {
|
||||||
|
setBusy(true); setError(''); setSuccess('')
|
||||||
|
try { await task(); await reloadStructure(); setSuccess(message) }
|
||||||
|
catch (err) { setError(err.message) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
async function savePage(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
await change(async () => {
|
||||||
|
if (pageDraft.id) await cmsService.updatePage(pageDraft.id, pageDraft)
|
||||||
|
else await cmsService.createPage(pageDraft)
|
||||||
|
setPageDraft(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function saveBlock(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
await change(async () => {
|
||||||
|
if (blockDraft.id) await cmsService.updateBlock(blockDraft.id, blockDraft)
|
||||||
|
else await cmsService.createBlock(blockDraft)
|
||||||
|
setBlockDraft(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function upload(file) {
|
||||||
|
if (!file) return
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try { const { url } = await cmsService.uploadImage(file); setBlockDraft((prev) => ({ ...prev, image: url })) }
|
||||||
|
catch (err) { setError(err.message) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
async function reposition(item, offset) {
|
||||||
|
const index = pageBlocks.findIndex((current) => current.id === item.id)
|
||||||
|
const neighbor = pageBlocks[index + offset]
|
||||||
|
if (!neighbor) return
|
||||||
|
// Indices définis depuis la liste : fonctionne aussi avec des positions en double.
|
||||||
|
await change(async () => {
|
||||||
|
const ordered = [...pageBlocks]
|
||||||
|
;[ordered[index], ordered[index + offset]] = [ordered[index + offset], ordered[index]]
|
||||||
|
for (const [position, block] of ordered.entries()) await cmsService.updateBlock(block.id, { position })
|
||||||
|
}, 'Ordre mis à jour.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="space-y-5">
|
||||||
|
<div><h2 className="text-2xl font-semibold tracking-tight">Éditeur du site</h2><p className="mt-1 text-sm text-muted-foreground">Créer des pages et sections, modifier l’apparence et les textes, publier ou masquer sans toucher au code.</p></div>
|
||||||
|
<div className="flex flex-wrap gap-2">{Object.entries(modes).map(([key, title]) => <Button type="button" key={key} variant={key === tab ? 'default' : 'outline'} size="sm" onClick={() => { setTab(key); setError(''); setSuccess('') }}>{title}</Button>)}</div>
|
||||||
|
{error && <p role="alert" className="rounded-lg border border-destructive/25 p-3 text-sm text-destructive">{error}</p>}
|
||||||
|
{success && <p role="status" className="rounded-lg bg-muted p-3 text-sm">{success}</p>}
|
||||||
|
|
||||||
|
{tab === 'pages' && <div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between"><h3 className="font-semibold">Pages supplémentaires</h3><Button onClick={() => setPageDraft({ ...EMPTY_PAGE, position: pages.length })}><FilePlus className="size-4"/> Nouvelle page</Button></div>
|
||||||
|
<p className="text-xs text-muted-foreground">Les pages d’origine restent intactes et éditables directement ; crée ici des pages supplémentaires et leurs liens de navigation.</p>
|
||||||
|
{pages.map((page) => <Card key={page.id}><CardContent className="flex flex-wrap items-center gap-3 p-4"><div className="min-w-0 flex-1"><p className="font-semibold">{page.title} <span className="font-normal text-muted-foreground">/{page.slug}</span></p><p className="text-xs text-muted-foreground">{page.published ? 'Publiée' : 'Masquée'} · {page.show_in_nav ? 'Visible dans le menu' : 'Non affichée dans le menu'}</p></div><Button size="sm" variant="outline" onClick={() => { setTab('blocks'); setActiveSlug(page.slug) }}>Blocs</Button><Button size="sm" variant="outline" onClick={() => setPageDraft({ ...page })}>Modifier</Button><Button size="sm" variant="ghost" disabled={busy} onClick={() => { if (window.confirm(`Supprimer la page /${page.slug} ? Les blocs associés seront aussi supprimés.`)) change(async () => { for (const item of blocks.filter((block) => block.page_slug === page.slug)) await cmsService.deleteBlock(item.id); await cmsService.deletePage(page.id) }) }}><Trash2 className="size-4 text-destructive"/></Button></CardContent></Card>)}
|
||||||
|
{!pages.length && <p className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">Aucune page supplémentaire.</p>}
|
||||||
|
{pageDraft && <Card><CardHeader><CardTitle>{pageDraft.id ? 'Modifier la page' : 'Créer une page'}</CardTitle></CardHeader><CardContent><form onSubmit={savePage} className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField label="Chemin (sans /)"><Input required pattern="[a-z0-9-]+" placeholder="nouvelle-page" disabled={Boolean(pageDraft.id)} value={pageDraft.slug} onChange={(e) => setPageDraft({ ...pageDraft, slug: e.target.value })}/></FormField>
|
||||||
|
<FormField label="Titre"><Input required value={pageDraft.title} maxLength={200} onChange={(e) => setPageDraft({ ...pageDraft, title: e.target.value })}/></FormField>
|
||||||
|
<FormField label="Nom dans le menu"><Input value={pageDraft.nav_label} placeholder="Par défaut : titre" onChange={(e) => setPageDraft({ ...pageDraft, nav_label: e.target.value })}/></FormField>
|
||||||
|
<FormField label="Ordre du menu"><Input type="number" min="0" value={pageDraft.position} onChange={(e) => setPageDraft({ ...pageDraft, position: Number(e.target.value) || 0 })}/></FormField>
|
||||||
|
<div className="sm:col-span-2"><FormField label="Introduction / méta-description"><textarea className={textareaClass} value={pageDraft.description} onChange={(e) => setPageDraft({ ...pageDraft, description: e.target.value })}/></FormField></div>
|
||||||
|
<CheckboxField label="Page publiée" checked={pageDraft.published} onChange={(value) => setPageDraft({ ...pageDraft, published: value })}/>
|
||||||
|
<CheckboxField label="Afficher dans la navigation" checked={pageDraft.show_in_nav} onChange={(value) => setPageDraft({ ...pageDraft, show_in_nav: value })}/>
|
||||||
|
<div className="sm:col-span-2 flex justify-end gap-2"><Button variant="outline" type="button" onClick={() => setPageDraft(null)}>Annuler</Button><Button disabled={busy} type="submit"><Save className="size-4"/> Enregistrer</Button></div>
|
||||||
|
</form></CardContent></Card>}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{tab === 'navigation' && <div className="space-y-4"><h3 className="font-semibold">Navigation des pages intégrées</h3><p className="text-sm text-muted-foreground">Modifie les libellés, masque une rubrique ou ajuste son ordre dans son groupe. Les pages ajoutées se gèrent dans « Pages ».</p>{builtInPages.map((place, index) => <NavigationRow key={place.slug} place={place} index={index} get={editor.get} save={saveContent}/>)}</div>}
|
||||||
|
|
||||||
|
{tab === 'blocks' && <div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-3 justify-between"><div className="space-y-2"><Label>Page à personnaliser</Label><select className={selectClass} value={activeSlug} onChange={(e) => setActiveSlug(e.target.value)}>{locations.map((place) => <option value={place.slug} key={place.slug}>{place.label}</option>)}</select></div><Button onClick={() => setBlockDraft({ ...EMPTY_BLOCK, page_slug: activeSlug, position: pageBlocks.length })}><Plus className="size-4"/> Ajouter un bloc</Button></div>
|
||||||
|
{pageBlocks.map((block, index) => <Card key={block.id}><CardContent className="flex flex-wrap items-center gap-3 p-4"><span className="grid size-10 shrink-0 place-items-center rounded-xl bg-muted"><LayoutTemplate className="size-5"/></span><div className="min-w-0 flex-1"><p className="truncate font-semibold">{block.title || block.body?.slice(0,70) || 'Bloc sans titre'}</p><p className="text-xs text-muted-foreground">{block.kind} · {block.published ? 'Publié' : 'Masqué'}</p></div><div className="flex items-center gap-1"><Button variant="outline" size="sm" disabled={busy || index === 0} title="Monter" onClick={() => reposition(block,-1)}><ArrowUp className="size-4"/></Button><Button variant="outline" size="sm" disabled={busy || index === pageBlocks.length - 1} title="Descendre" onClick={() => reposition(block,1)}><ArrowDown className="size-4"/></Button><Button variant="outline" size="sm" title={block.published ? 'Masquer' : 'Publier'} disabled={busy} onClick={() => change(() => cmsService.updateBlock(block.id,{ published: !block.published }))}>{block.published ? <Eye className="size-4"/> : <EyeOff className="size-4"/>}</Button><Button variant="outline" size="sm" onClick={() => setBlockDraft({ ...block })}>Modifier</Button><Button variant="ghost" size="sm" disabled={busy} title="Supprimer" onClick={() => { if (window.confirm('Supprimer définitivement ce bloc ?')) change(() => cmsService.deleteBlock(block.id)) }}><Trash2 className="size-4 text-destructive"/></Button></div></CardContent></Card>)}
|
||||||
|
{!pageBlocks.length && <p className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">Aucun bloc ajouté : le site conserve son contenu initial.</p>}
|
||||||
|
{blockDraft && <Card><CardHeader><CardTitle>{blockDraft.id ? 'Modifier le bloc' : 'Nouveau bloc'}</CardTitle></CardHeader><CardContent><form onSubmit={saveBlock} className="space-y-4">
|
||||||
|
<FormField label="Type"><select className={selectClass} value={blockDraft.kind} onChange={(e) => setBlockDraft({ ...blockDraft, kind: e.target.value })}><option value="heading">Titre</option><option value="text">Texte</option><option value="image">Image</option><option value="button">Bouton</option></select></FormField>
|
||||||
|
<FormField label={blockDraft.kind === 'button' ? 'Texte du bouton' : blockDraft.kind === 'image' ? 'Légende / description' : 'Titre'}><Input maxLength={250} value={blockDraft.title} onChange={(e) => setBlockDraft({ ...blockDraft, title: e.target.value })}/></FormField>
|
||||||
|
{blockDraft.kind === 'text' && <FormField label="Contenu"><textarea className={textareaClass} maxLength={12000} value={blockDraft.body} onChange={(e) => setBlockDraft({ ...blockDraft, body: e.target.value })}/></FormField>}
|
||||||
|
{blockDraft.kind === 'image' && <><FormField label="Fichier image"><label className="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed p-4 text-sm"><ImagePlus className="size-4"/> Téléverser une image (5 Mo max)<input className="sr-only" type="file" accept="image/png,image/jpeg,image/webp" onChange={(e) => { upload(e.target.files?.[0]); e.target.value = '' }} disabled={busy}/></label></FormField><FormField label="URL de l'image"><Input value={blockDraft.image} onChange={(e) => setBlockDraft({ ...blockDraft, image: e.target.value })}/></FormField>{blockDraft.image && <img className="max-h-40 rounded-lg object-contain" src={blockDraft.image} alt="Aperçu"/>}</>}
|
||||||
|
{blockDraft.kind === 'button' && <FormField label="Destination (/chemin ou HTTPS)"><Input value={blockDraft.url} onChange={(e) => setBlockDraft({ ...blockDraft, url: e.target.value })}/></FormField>}
|
||||||
|
<CheckboxField label="Afficher le bloc" checked={blockDraft.published} onChange={(value) => setBlockDraft({ ...blockDraft, published: value })}/>
|
||||||
|
<div className="flex justify-end gap-2"><Button variant="outline" type="button" onClick={() => setBlockDraft(null)}>Annuler</Button><Button disabled={busy} type="submit"><Save className="size-4"/> Enregistrer</Button></div>
|
||||||
|
</form></CardContent></Card>}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{tab === 'appearance' && <div className="space-y-4"><p className="text-sm text-muted-foreground">Personnalise les couleurs principales. Elles s'appliqueront aux visiteurs après enregistrement.</p><div className="flex gap-2"><Button size="sm" variant={colorMode === 'light' ? 'default' : 'outline'} onClick={() => setColorMode('light')}>Thème clair</Button><Button size="sm" variant={colorMode === 'dark' ? 'default' : 'outline'} onClick={() => setColorMode('dark')}>Thème sombre</Button></div><div className="grid gap-4 sm:grid-cols-2">{PALETTE.map(([key,label]) => <ColorControl key={`${colorMode}-${key}`} field={`theme.${colorMode}.${key}`} label={label} saved={content[`theme.${colorMode}.${key}`]} onSave={saveContent} onReset={resetContent} />)}</div></div>}
|
||||||
|
|
||||||
|
{tab === 'values' && <div className="space-y-4"><p className="text-sm text-muted-foreground">Les éléments existants se modifient directement sur le site avec le mode « Modifier ». Tu peux aussi gérer ici toute clé, texte ou URL enregistrée.</p><Card><CardContent className="space-y-3 p-4"><Label>Ajouter un contenu</Label><div className="grid gap-2 sm:grid-cols-2"><Input value={newKey} placeholder="global.footer.text" onChange={(e) => setNewKey(e.target.value)}/><Input value={newValue} placeholder="Valeur" onChange={(e) => setNewValue(e.target.value)}/></div><Button disabled={busy || !newKey} onClick={() => change(async () => { await saveContent(newKey, newValue); setNewKey(''); setNewValue('') })}><Plus className="size-4"/> Ajouter</Button></CardContent></Card><div className="max-h-[650px] space-y-2 overflow-auto">{Object.entries(content).filter(([key]) => !key.startsWith('theme.')).sort(([a],[b]) => a.localeCompare(b)).map(([key,value]) => <ContentRow key={key} id={key} value={value} save={saveContent} reset={resetContent}/>)}</div></div>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContentRow({ id, value, save, reset }) {
|
||||||
|
const [draft, setDraft] = useState(value)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
useEffect(() => setDraft(value), [value])
|
||||||
|
async function perform(action) { setBusy(true); setError(''); try { await action() } catch (err) { setError(err.message) } finally { setBusy(false) } }
|
||||||
|
return <div className="space-y-2 rounded-lg border bg-card p-3"><Label>{id}</Label><textarea className={textareaClass} rows={2} value={draft} maxLength={12000} onChange={(e) => setDraft(e.target.value)}/>{error && <p className="text-xs text-destructive">{error}</p>}<div className="flex justify-end gap-2"><Button size="sm" variant="outline" disabled={busy} onClick={() => perform(() => reset(id))}>Réinitialiser</Button><Button size="sm" disabled={busy || draft === value} onClick={() => perform(() => save(id,draft))}>Enregistrer</Button></div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorControl({ field, label, saved, onSave, onReset }) {
|
||||||
|
const [draft, setDraft] = useState(saved || '#808080')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
useEffect(() => { setDraft(saved || '#808080') }, [saved])
|
||||||
|
async function perform(action) { setBusy(true); setError(''); try { await action() } catch (err) { setError(err.message) } finally { setBusy(false) } }
|
||||||
|
return <Card><CardContent className="p-4"><Label>{label}</Label><div className="mt-2 flex items-center gap-2"><input type="color" className="size-10 cursor-pointer rounded" value={draft} onChange={(e) => setDraft(e.target.value)} aria-label={label}/><Input className="min-w-0 flex-1" value={draft} maxLength={7} onChange={(e) => setDraft(e.target.value)}/><Button size="sm" disabled={busy || !/^#[\da-fA-F]{6}$/.test(draft)} onClick={() => perform(() => onSave(field,draft))}><Save className="size-4"/></Button>{saved && <Button variant="outline" size="sm" title="Revenir à la couleur du thème" disabled={busy} onClick={() => perform(() => onReset(field))}><RefreshCw className="size-4"/></Button>}</div>{error && <p className="mt-2 text-xs text-destructive">{error}</p>}</CardContent></Card>
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationRow({ place, index, get, save }) {
|
||||||
|
const prefix = `nav.${place.slug}`
|
||||||
|
const [label, setLabel] = useState(get(`${prefix}.label`, place.label))
|
||||||
|
const [position, setPosition] = useState(get(`${prefix}.order`, String(index)))
|
||||||
|
const [visible, setVisible] = useState(get(`${prefix}.visible`, 'true') !== 'false')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
async function submit(event) {
|
||||||
|
event.preventDefault(); setBusy(true); setMessage('')
|
||||||
|
try {
|
||||||
|
await save(`${prefix}.label`,label)
|
||||||
|
await save(`${prefix}.order`,String(Math.max(0,Number(position) || 0)))
|
||||||
|
await save(`${prefix}.visible`,visible ? 'true' : 'false')
|
||||||
|
setMessage('Enregistré')
|
||||||
|
} catch (error) { setMessage(error.message) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
return <Card><CardContent className="p-4"><form className="flex flex-wrap items-end gap-3" onSubmit={submit}><div className="min-w-40 flex-1 space-y-1"><Label>Rubrique {place.label}</Label><Input value={label} onChange={(event) => setLabel(event.target.value)}/></div><div className="w-20 space-y-1"><Label>Ordre</Label><Input type="number" min="0" value={position} onChange={(event) => setPosition(event.target.value)}/></div><CheckboxField label="Visible" checked={visible} onChange={setVisible}/><Button type="submit" disabled={busy}><Save className="size-4"/> Enregistrer</Button></form>{message && <p className="mt-2 text-xs text-muted-foreground">{message}</p>}</CardContent></Card>
|
||||||
|
}
|
||||||
31
frontend/src/components/cms/CmsTheme.jsx
Normal file
31
frontend/src/components/cms/CmsTheme.jsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useEditor } from '@/context/EditorContext'
|
||||||
|
|
||||||
|
export const PALETTE = [
|
||||||
|
['primary','Couleur principale'],
|
||||||
|
['primary-foreground','Texte sur la couleur principale'],
|
||||||
|
['background','Fond du site'],
|
||||||
|
['foreground','Texte principal'],
|
||||||
|
['card','Fond des cartes'],
|
||||||
|
['card-foreground','Texte des cartes'],
|
||||||
|
['accent','Accent'],
|
||||||
|
['muted','Fond secondaire'],
|
||||||
|
['border','Bordures'],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function CmsTheme() {
|
||||||
|
const { content } = useEditor()
|
||||||
|
useEffect(() => {
|
||||||
|
for (const mode of ['light', 'dark']) {
|
||||||
|
const themeTarget = mode === 'light' ? document.documentElement : document.documentElement
|
||||||
|
for (const [name] of PALETTE) {
|
||||||
|
const value = content[`theme.${mode}.${name}`]
|
||||||
|
// CSS variables du mode sombre sont appliquées par une feuille dédiée.
|
||||||
|
const property = mode === 'light' ? `--cms-light-${name}` : `--cms-dark-${name}`
|
||||||
|
if (value && /^#[a-fA-F0-9]{6}$/.test(value)) themeTarget.style.setProperty(property, value)
|
||||||
|
else themeTarget.style.removeProperty(property)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [content])
|
||||||
|
return null
|
||||||
|
}
|
||||||
38
frontend/src/components/cms/CmsUsers.jsx
Normal file
38
frontend/src/components/cms/CmsUsers.jsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { ShieldCheck, UserPlus } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { adminService } from '@/services/adminService'
|
||||||
|
import { useEditor } from '@/context/EditorContext'
|
||||||
|
|
||||||
|
export function CmsUsers() {
|
||||||
|
const { user } = useEditor()
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [form, setForm] = useState({ username: '', email: '', password: '' })
|
||||||
|
const reload = async () => { try { setData(await adminService.dashboard()) } catch (err) { setError(err.message) } }
|
||||||
|
useEffect(() => { reload() }, [])
|
||||||
|
const filtered = useMemo(() => (data?.users || []).filter((item) => `${item.username} ${item.email}`.toLowerCase().includes(search.toLowerCase())), [data,search])
|
||||||
|
async function create(event) {
|
||||||
|
event.preventDefault(); setBusy(true); setError('')
|
||||||
|
try { await adminService.createUser(form); setForm({ username: '', email: '', password: '' }); await reload() }
|
||||||
|
catch (err) { setError(err.message) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
async function toggle(target) {
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try { await adminService.setUserActive(target.id, !target.is_active); await reload() }
|
||||||
|
catch (err) { setError(err.message) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
return <div className="space-y-5"><div><h2 className="text-2xl font-semibold">Utilisateurs</h2><p className="text-sm text-muted-foreground">Gère les accès sans l'interface django-admin.</p></div>
|
||||||
|
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||||
|
{data && <div className="flex flex-wrap gap-2 text-xs"><span className="rounded-lg bg-muted p-2">{data.stats.total} comptes</span><span className="rounded-lg bg-muted p-2">{data.stats.active} actifs</span><span className="rounded-lg bg-muted p-2">{data.stats.staff} administrateurs</span></div>}
|
||||||
|
{user.is_superuser && <Card><CardContent className="p-5"><form className="flex flex-wrap items-end gap-3" onSubmit={create}><label className="space-y-1 text-xs">Identifiant<Input required minLength={3} maxLength={150} value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })}/></label><label className="space-y-1 text-xs">E-mail<Input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })}/></label><label className="space-y-1 text-xs">Mot de passe<Input type="password" minLength={8} required value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })}/></label><Button type="submit" disabled={busy}><UserPlus className="size-4"/> Ajouter</Button></form></CardContent></Card>}
|
||||||
|
<Input placeholder="Chercher un utilisateur…" value={search} onChange={(e) => setSearch(e.target.value)}/>
|
||||||
|
<div className="space-y-2">{filtered.map((item) => <Card key={item.id}><CardContent className="flex flex-wrap items-center gap-3 p-4"><ShieldCheck className="size-5 text-primary"/><div className="min-w-0 flex-1"><p className="font-medium">{item.username} {item.is_staff && <span className="text-xs text-primary">· Administrateur</span>}</p><p className="text-xs text-muted-foreground">{item.email || 'Pas d’e-mail'} · {item.is_active ? 'Actif' : 'Désactivé'}</p></div><Button size="sm" variant="outline" disabled={busy || !user.is_superuser || item.id === user.id || item.is_superuser} onClick={() => toggle(item)}>{item.is_active ? 'Désactiver' : 'Réactiver'}</Button></CardContent></Card>)}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@ -1,90 +1,62 @@
|
|||||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { authService } from '@/services/authService'
|
||||||
|
import { cmsService } from '@/services/cmsService'
|
||||||
|
|
||||||
const EditorContext = createContext(null);
|
const EditorContext = createContext(null)
|
||||||
const API_ROOT = '/api/education';
|
|
||||||
const TOKEN_KEY = 'accrocbio_editor_token';
|
|
||||||
|
|
||||||
async function parseResponse(response) {
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = typeof data.detail === 'string' ? data.detail : Object.entries(data).map(([key, value]) => `${key} : ${Array.isArray(value) ? value.join(', ') : value}`).join(' / ') || 'Une erreur est survenue.';
|
|
||||||
throw new Error(error);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditorProvider({ children }) {
|
export function EditorProvider({ children }) {
|
||||||
const [content, setContent] = useState({});
|
const [content, setContent] = useState({})
|
||||||
const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY) || '');
|
const [pages, setPages] = useState([])
|
||||||
const [user, setUser] = useState(null);
|
const [blocks, setBlocks] = useState([])
|
||||||
const [checking, setChecking] = useState(Boolean(sessionStorage.getItem(TOKEN_KEY)));
|
const [user, setUser] = useState(null)
|
||||||
const [editing, setEditing] = useState(false);
|
const [checking, setChecking] = useState(true)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
|
||||||
const loadContent = useCallback(async () => {
|
const loadContent = useCallback(async () => { setContent(await cmsService.content()) }, [])
|
||||||
try {
|
const reloadStructure = useCallback(async () => {
|
||||||
const response = await fetch(`${API_ROOT}/site-content/`, { cache: 'no-store' });
|
const [pageData, blockData] = await Promise.all([cmsService.pages(), cmsService.blocks()])
|
||||||
if (response.ok) setContent(await response.json());
|
setPages(pageData)
|
||||||
} catch { /* Le site conserve les textes par défaut si l'API est indisponible. */ }
|
setBlocks(blockData)
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { loadContent(); }, [loadContent]);
|
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
|
||||||
sessionStorage.removeItem(TOKEN_KEY);
|
|
||||||
setToken(''); setUser(null); setEditing(false); setChecking(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const authorizedFetch = useCallback(async (path, options = {}) => {
|
|
||||||
if (!token) throw new Error('Connectez-vous pour continuer.');
|
|
||||||
const headers = { ...(options.headers || {}), Authorization: `Bearer ${token}` };
|
|
||||||
if (options.body && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json';
|
|
||||||
const response = await fetch(`${API_ROOT}${path}`, { ...options, headers });
|
|
||||||
if (response.status === 401) { logout(); throw new Error('Session expirée : reconnectez-vous.'); }
|
|
||||||
return parseResponse(response);
|
|
||||||
}, [token, logout]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) { setChecking(false); return; }
|
// Les contenus publics restent visibles même sans session.
|
||||||
let active = true;
|
loadContent().catch(() => {})
|
||||||
setChecking(true);
|
reloadStructure().catch(() => {})
|
||||||
fetch(`${API_ROOT}/editor/me/`, { headers: { Authorization: `Bearer ${token}` } })
|
authService.me().then(({ user: active }) => { if (active.is_staff) { setUser(active); reloadStructure().catch(() => {}) } })
|
||||||
.then(parseResponse).then(data => { if (active) setUser(data); })
|
.catch(() => {}).finally(() => setChecking(false))
|
||||||
.catch(() => { if (active) logout(); })
|
}, [loadContent, reloadStructure])
|
||||||
.finally(() => { if (active) setChecking(false); });
|
|
||||||
return () => { active = false; };
|
|
||||||
}, [token, logout]);
|
|
||||||
|
|
||||||
const login = useCallback(async (username, password) => {
|
const login = useCallback(async (username, password) => {
|
||||||
const response = await fetch(`${API_ROOT}/editor/login/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
|
const result = await authService.login(username, password)
|
||||||
const data = await parseResponse(response);
|
setUser(result.user)
|
||||||
sessionStorage.setItem(TOKEN_KEY, data.access);
|
await reloadStructure()
|
||||||
setToken(data.access);
|
return result
|
||||||
setUser({ username: data.username, is_staff: true });
|
}, [reloadStructure])
|
||||||
return data;
|
const logout = useCallback(async () => {
|
||||||
}, []);
|
await authService.logout()
|
||||||
|
setUser(null)
|
||||||
|
setEditing(false)
|
||||||
|
await reloadStructure().catch(() => {})
|
||||||
|
}, [reloadStructure])
|
||||||
const saveContent = useCallback(async (key, value) => {
|
const saveContent = useCallback(async (key, value) => {
|
||||||
const data = await authorizedFetch(`/site-content/${encodeURIComponent(key)}/`, { method: 'PUT', body: JSON.stringify({ value }) });
|
await cmsService.saveContent(key, value)
|
||||||
setContent(prev => ({ ...prev, [data.key]: data.value }));
|
setContent((previous) => ({ ...previous, [key]: value }))
|
||||||
return data;
|
}, [])
|
||||||
}, [authorizedFetch]);
|
const resetContent = useCallback(async (key) => {
|
||||||
|
await cmsService.resetContent(key)
|
||||||
|
setContent((previous) => { const next = { ...previous }; delete next[key]; return next })
|
||||||
|
}, [])
|
||||||
|
const get = useCallback((key, fallback = '') => Object.hasOwn(content, key) ? content[key] : fallback, [content])
|
||||||
|
|
||||||
const resetContent = useCallback(async key => {
|
const value = useMemo(() => ({ content, pages, blocks, get, user, checking, editing, setEditing,
|
||||||
await authorizedFetch(`/site-content/${encodeURIComponent(key)}/`, { method: 'DELETE' });
|
login, logout, saveContent, resetContent, loadContent, reloadStructure, uploadImage: cmsService.uploadImage,
|
||||||
setContent(prev => { const copy = { ...prev }; delete copy[key]; return copy; });
|
}), [content, pages, blocks, get, user, checking, editing, login, logout, saveContent, resetContent, loadContent, reloadStructure])
|
||||||
}, [authorizedFetch]);
|
return <EditorContext.Provider value={value}>{children}</EditorContext.Provider>
|
||||||
|
|
||||||
const uploadImage = useCallback(async file => {
|
|
||||||
const data = new FormData(); data.append('image', file);
|
|
||||||
return authorizedFetch('/editor/upload/', { method: 'POST', body: data });
|
|
||||||
}, [authorizedFetch]);
|
|
||||||
|
|
||||||
const value = useMemo(() => ({ content, get: (key, fallback = '') => Object.hasOwn(content, key) ? content[key] : fallback, token, user, checking, editing, setEditing, login, logout, saveContent, resetContent, uploadImage, authorizedFetch, loadContent }), [content, token, user, checking, editing, login, logout, saveContent, resetContent, uploadImage, authorizedFetch, loadContent]);
|
|
||||||
return <EditorContext.Provider value={value}>{children}</EditorContext.Provider>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEditor() {
|
export function useEditor() {
|
||||||
const ctx = useContext(EditorContext);
|
const context = useContext(EditorContext)
|
||||||
if (!ctx) throw new Error('EditorProvider manquant.');
|
if (!context) throw new Error('EditorProvider manquant.')
|
||||||
return ctx;
|
return context
|
||||||
}
|
}
|
||||||
|
|||||||
@ -190,3 +190,30 @@
|
|||||||
@media (max-width: 768px) { .cms-toolbar { top: 68px; right: 9px; padding: 5px; } .cms-toolbar button { font-size: 11px; } }
|
@media (max-width: 768px) { .cms-toolbar { top: 68px; right: 9px; padding: 5px; } .cms-toolbar button { font-size: 11px; } }
|
||||||
|
|
||||||
.cms-link-button { position:absolute; top:-10px; right:-10px; z-index:10; display:grid; place-items:center; width:23px; height:23px; border:1px solid var(--primary); border-radius:8px; color:var(--primary); background:var(--background); box-shadow:0 2px 8px #0002; cursor:pointer; }
|
.cms-link-button { position:absolute; top:-10px; right:-10px; z-index:10; display:grid; place-items:center; width:23px; height:23px; border:1px solid var(--primary); border-radius:8px; color:var(--primary); background:var(--background); box-shadow:0 2px 8px #0002; cursor:pointer; }
|
||||||
|
|
||||||
|
/* Valeurs du thème définies dans l’éditeur React, avec repli sur le thème initial. */
|
||||||
|
:root:not(.dark) {
|
||||||
|
--primary: var(--cms-light-primary, #326d58);
|
||||||
|
--primary-foreground: var(--cms-light-primary-foreground, #fffefa);
|
||||||
|
--background: var(--cms-light-background, #f8f9f4);
|
||||||
|
--foreground: var(--cms-light-foreground, #1f3b33);
|
||||||
|
--card: var(--cms-light-card, #fffefa);
|
||||||
|
--card-foreground: var(--cms-light-card-foreground, #1f3b33);
|
||||||
|
--accent: var(--cms-light-accent, #e4efe5);
|
||||||
|
--muted: var(--cms-light-muted, #eef2eb);
|
||||||
|
--border: var(--cms-light-border, #dce6da);
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--primary: var(--cms-dark-primary, #95cbb0);
|
||||||
|
--primary-foreground: var(--cms-dark-primary-foreground, #143b2b);
|
||||||
|
--background: var(--cms-dark-background, #131f1a);
|
||||||
|
--foreground: var(--cms-dark-foreground, #eff6ee);
|
||||||
|
--card: var(--cms-dark-card, #1e2d26);
|
||||||
|
--card-foreground: var(--cms-dark-card-foreground, #eff6ee);
|
||||||
|
--accent: var(--cms-dark-accent, #2a4938);
|
||||||
|
--muted: var(--cms-dark-muted, #2a3b30);
|
||||||
|
--border: var(--cms-dark-border, #395043);
|
||||||
|
}
|
||||||
|
.cms-editable { outline: 1px dashed var(--primary); outline-offset: 3px; border-radius: 3px; }
|
||||||
|
.cms-edit-button { position: relative; top: -.15em; margin-left: .35rem; padding: .2rem; display: inline-flex; align-items: center; justify-content: center; background: var(--primary); color: var(--primary-foreground); border-radius: .4rem; vertical-align: middle; cursor: pointer; }
|
||||||
|
.cms-edit-button:hover { opacity: .8; }
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { ArrowLeft, ArrowRight, BookOpen, Check, ClipboardList, Edit3, ExternalLink, FileText, Gift, Home, Loader2, LogOut, MessageSquare, Plus, Save, ShieldCheck, Star, Trash2 } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, BookOpen, Check, ClipboardList, Edit3, ExternalLink, FilePlus, FileText, Gift, Home, Loader2, LogOut, MessageSquare, Plus, Save, ShieldCheck, Star, Trash2 } from 'lucide-react';
|
||||||
import { useEditor } from '@/context/EditorContext';
|
import { useEditor } from '@/context/EditorContext';
|
||||||
|
import { educationService } from '@/services/educationService';
|
||||||
|
import { CmsManager } from '@/components/cms/CmsManager';
|
||||||
|
import { CmsUsers } from '@/components/cms/CmsUsers';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@ -68,7 +71,7 @@ function DataCollection({ type }) {
|
|||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
setPending(true); setError('');
|
setPending(true); setError('');
|
||||||
try { setItems(await editor.authorizedFetch(`/editor/data/${type}/`)); }
|
try { setItems(await educationService.list(type)); }
|
||||||
catch (err) { setError(err.message); }
|
catch (err) { setError(err.message); }
|
||||||
finally { setPending(false); }
|
finally { setPending(false); }
|
||||||
}
|
}
|
||||||
@ -82,15 +85,14 @@ function DataCollection({ type }) {
|
|||||||
if (fieldType === 'date') payload[key] = payload[key] || null;
|
if (fieldType === 'date') payload[key] = payload[key] || null;
|
||||||
if (fieldType === 'number') payload[key] = Number(payload[key]) || 0;
|
if (fieldType === 'number') payload[key] = Number(payload[key]) || 0;
|
||||||
}
|
}
|
||||||
const path = selected.id ? `/editor/data/${type}/${selected.id}/` : `/editor/data/${type}/`;
|
await (selected.id ? educationService.update(type, selected.id, payload) : educationService.create(type, payload));
|
||||||
await editor.authorizedFetch(path, { method: selected.id ? 'PATCH' : 'POST', body: JSON.stringify(payload) });
|
|
||||||
setSelected(null); await reload();
|
setSelected(null); await reload();
|
||||||
} catch (err) { setError(err.message); }
|
} catch (err) { setError(err.message); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
}
|
}
|
||||||
async function remove(item) {
|
async function remove(item) {
|
||||||
if (!window.confirm(`Supprimer définitivement « ${item.title || item.author} » ?`)) return;
|
if (!window.confirm(`Supprimer définitivement « ${item.title || item.author} » ?`)) return;
|
||||||
try { await editor.authorizedFetch(`/editor/data/${type}/${item.id}/`, { method: 'DELETE' }); await reload(); }
|
try { await educationService.remove(type, item.id); await reload(); }
|
||||||
catch (err) { setError(err.message); }
|
catch (err) { setError(err.message); }
|
||||||
}
|
}
|
||||||
return <div className="space-y-5"><div className="flex flex-wrap gap-3 items-center justify-between"><div><h2 className="text-2xl font-semibold">{conf.name}</h2><p className="text-muted-foreground text-sm mt-1">{items.length} élément(s) · Publication immédiate</p></div><Button onClick={() => open(null)}><Plus className="size-4" /> Ajouter {conf.singular === 'avis' ? 'un avis' : `une ${conf.singular}`}</Button></div>
|
return <div className="space-y-5"><div className="flex flex-wrap gap-3 items-center justify-between"><div><h2 className="text-2xl font-semibold">{conf.name}</h2><p className="text-muted-foreground text-sm mt-1">{items.length} élément(s) · Publication immédiate</p></div><Button onClick={() => open(null)}><Plus className="size-4" /> Ajouter {conf.singular === 'avis' ? 'un avis' : `une ${conf.singular}`}</Button></div>
|
||||||
@ -106,10 +108,10 @@ function Messages() {
|
|||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const load = async () => { try { setItems(await editor.authorizedFetch('/editor/data/messages/')); } catch (e) { setError(e.message); } };
|
const load = async () => { try { setItems(await educationService.list('messages')); } catch (e) { setError(e.message); } };
|
||||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
async function toggle(msg) { try { await editor.authorizedFetch(`/editor/data/messages/${msg.id}/`, { method: 'PATCH', body: JSON.stringify({ read: !msg.read }) }); await load(); } catch (e) { setError(e.message); } }
|
async function toggle(msg) { try { await educationService.update('messages', msg.id, { read: !msg.read }); await load(); } catch (e) { setError(e.message); } }
|
||||||
async function remove(msg) { if (!window.confirm(`Supprimer le message de ${msg.name} ?`)) return; try { await editor.authorizedFetch(`/editor/data/messages/${msg.id}/`, { method: 'DELETE' }); await load(); } catch (e) { setError(e.message); } }
|
async function remove(msg) { if (!window.confirm(`Supprimer le message de ${msg.name} ?`)) return; try { await educationService.remove('messages', msg.id); await load(); } catch (e) { setError(e.message); } }
|
||||||
return <div className="space-y-4"><h2 className="text-2xl font-semibold">Messages reçus</h2>{error && <p className="text-destructive text-sm" role="alert">{error}</p>}{!items.length && <p className="text-muted-foreground">Aucun message pour le moment.</p>}{items.map(msg => <Card key={msg.id}><CardContent className="p-5"><div className="flex items-start justify-between gap-3"><div><strong>{msg.name}</strong><p className="text-sm text-muted-foreground">{msg.email} · {msg.phone || 'Sans téléphone'} · {msg.level || 'Niveau non précisé'}</p></div><span className="text-xs text-muted-foreground">{new Date(msg.created_at).toLocaleDateString('fr-FR')}</span></div><p className="my-4 whitespace-pre-wrap text-sm">{msg.message}</p><div className="flex flex-wrap gap-2"><Button variant="outline" size="sm" onClick={() => toggle(msg)}>{msg.read ? 'Marquer non lu' : <><Check className="size-4" /> Marquer lu</>}</Button><Button variant="ghost" size="sm" className="text-destructive" onClick={() => remove(msg)}><Trash2 className="size-4" /> Supprimer</Button></div></CardContent></Card>)}</div>;
|
return <div className="space-y-4"><h2 className="text-2xl font-semibold">Messages reçus</h2>{error && <p className="text-destructive text-sm" role="alert">{error}</p>}{!items.length && <p className="text-muted-foreground">Aucun message pour le moment.</p>}{items.map(msg => <Card key={msg.id}><CardContent className="p-5"><div className="flex items-start justify-between gap-3"><div><strong>{msg.name}</strong><p className="text-sm text-muted-foreground">{msg.email} · {msg.phone || 'Sans téléphone'} · {msg.level || 'Niveau non précisé'}</p></div><span className="text-xs text-muted-foreground">{new Date(msg.created_at).toLocaleDateString('fr-FR')}</span></div><p className="my-4 whitespace-pre-wrap text-sm">{msg.message}</p><div className="flex flex-wrap gap-2"><Button variant="outline" size="sm" onClick={() => toggle(msg)}>{msg.read ? 'Marquer non lu' : <><Check className="size-4" /> Marquer lu</>}</Button><Button variant="ghost" size="sm" className="text-destructive" onClick={() => remove(msg)}><Trash2 className="size-4" /> Supprimer</Button></div></CardContent></Card>)}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,8 +124,10 @@ export default function AdminPage() {
|
|||||||
function edit(href) { editor.setEditing(true); navigate(href); }
|
function edit(href) { editor.setEditing(true); navigate(href); }
|
||||||
return <div className="min-h-svh bg-background"><header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur"><div className="mx-auto max-w-6xl flex flex-wrap items-center justify-between gap-3 px-4 py-4"><div className="flex items-center gap-3"><span className="grid size-10 place-items-center rounded-xl bg-primary text-primary-foreground"><Edit3 className="size-5" /></span><div><h1 className="font-bold text-lg leading-tight">Éditeur AccroCbio</h1><p className="text-xs text-muted-foreground">Connecté : {editor.user.username}</p></div></div><div className="flex gap-2"><Button variant="outline" size="sm" render={<Link to="/"><ExternalLink className="size-4" /> Voir le site</Link>} /><Button variant="outline" size="sm" onClick={editor.logout}><LogOut className="size-4" /> Déconnexion</Button></div></div></header>
|
return <div className="min-h-svh bg-background"><header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur"><div className="mx-auto max-w-6xl flex flex-wrap items-center justify-between gap-3 px-4 py-4"><div className="flex items-center gap-3"><span className="grid size-10 place-items-center rounded-xl bg-primary text-primary-foreground"><Edit3 className="size-5" /></span><div><h1 className="font-bold text-lg leading-tight">Éditeur AccroCbio</h1><p className="text-xs text-muted-foreground">Connecté : {editor.user.username}</p></div></div><div className="flex gap-2"><Button variant="outline" size="sm" render={<Link to="/"><ExternalLink className="size-4" /> Voir le site</Link>} /><Button variant="outline" size="sm" onClick={editor.logout}><LogOut className="size-4" /> Déconnexion</Button></div></div></header>
|
||||||
<div className="mx-auto max-w-6xl p-4 md:py-9 space-y-6"><div><h2 className="text-3xl font-semibold tracking-tight">Gérer le contenu du site</h2><p className="mt-2 text-muted-foreground">Choisissez une page pour modifier directement ses textes et images, ou gérez vos données depuis les onglets.</p></div>
|
<div className="mx-auto max-w-6xl p-4 md:py-9 space-y-6"><div><h2 className="text-3xl font-semibold tracking-tight">Gérer le contenu du site</h2><p className="mt-2 text-muted-foreground">Choisissez une page pour modifier directement ses textes et images, ou gérez vos données depuis les onglets.</p></div>
|
||||||
<nav aria-label="Rubriques administration" className="flex gap-2 overflow-x-auto border-b pb-3">{[['pages', 'Pages'], ['resources', 'Ressources'], ['reviews', 'Avis'], ['announcements', 'Offres'], ['messages', 'Messages']].map(([key, name]) => <Button size="sm" key={key} variant={tab === key ? 'default' : 'ghost'} onClick={() => setTab(key)}>{name}</Button>)}</nav>
|
<nav aria-label="Rubriques administration" className="flex gap-2 overflow-x-auto border-b pb-3">{[['pages', 'Pages du site'], ['cms', 'Éditeur avancé'], ['users', 'Utilisateurs'], ['resources', 'Ressources'], ['reviews', 'Avis'], ['announcements', 'Offres'], ['messages', 'Messages']].map(([key, name]) => <Button size="sm" key={key} variant={tab === key ? 'default' : 'ghost'} onClick={() => setTab(key)}>{name}</Button>)}</nav>
|
||||||
{tab === 'pages' && <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">{pages.map(({ href, name, description, icon: Icon }) => <button type="button" onClick={() => edit(href)} key={href} className="group text-left rounded-2xl border bg-card p-5 transition-colors hover:border-primary/60 hover:bg-accent/30"><div className="flex justify-between items-center"><span className="grid size-11 place-items-center rounded-xl bg-primary/10 text-primary"><Icon className="size-5" /></span><ArrowRight className="size-4 text-muted-foreground group-hover:text-primary" /></div><h3 className="mt-5 text-lg font-semibold">{name}</h3><p className="text-sm text-muted-foreground mt-2">{description}</p><span className="text-primary font-semibold text-xs mt-5 inline-block">Modifier sur la page →</span></button>)}</div>}
|
{tab === 'pages' && <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">{[...pages, ...editor.pages.filter((page) => page.published).map((page) => ({ href: `/${page.slug}`, name: page.title, description: page.description, icon: FilePlus }))].map(({ href, name, description, icon: Icon }) => <button type="button" onClick={() => edit(href)} key={href} className="group text-left rounded-2xl border bg-card p-5 transition-colors hover:border-primary/60 hover:bg-accent/30"><div className="flex justify-between items-center"><span className="grid size-11 place-items-center rounded-xl bg-primary/10 text-primary"><Icon className="size-5" /></span><ArrowRight className="size-4 text-muted-foreground group-hover:text-primary" /></div><h3 className="mt-5 text-lg font-semibold">{name}</h3><p className="text-sm text-muted-foreground mt-2">{description}</p><span className="text-primary font-semibold text-xs mt-5 inline-block">Modifier sur la page →</span></button>)}</div>}
|
||||||
|
{tab === 'cms' && <CmsManager builtInPages={pages.map((page) => ({ slug: page.href === '/' ? 'home' : page.href.slice(1), label: page.name }))} />}
|
||||||
|
{tab === 'users' && <CmsUsers />}
|
||||||
{collections[tab] && <DataCollection key={tab} type={tab} />}
|
{collections[tab] && <DataCollection key={tab} type={tab} />}
|
||||||
{tab === 'messages' && <Messages />}
|
{tab === 'messages' && <Messages />}
|
||||||
<p className="pb-6 text-xs text-muted-foreground">Les modifications sont enregistrées dans Django. Elles ne nécessitent pas de reconstruire React ni Docker.</p></div></div>;
|
<p className="pb-6 text-xs text-muted-foreground">Les modifications sont enregistrées dans Django. Elles ne nécessitent pas de reconstruire React ni Docker.</p></div></div>;
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { EditText } from '@/components/education/Editable';
|
import { EditText } from '@/components/education/Editable';
|
||||||
|
import { educationService } from '@/services/educationService';
|
||||||
|
import { useEditor } from '@/context/EditorContext';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ExternalLink, Mail, MapPin, MessageSquareText, Phone, Send, CheckCircle2, AlertCircle } from 'lucide-react';
|
import { ExternalLink, Mail, MapPin, MessageSquareText, Phone, Send, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
@ -11,10 +13,11 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Breadcrumbs, PageHeading } from '@/components/education/PageLayout';
|
import { Breadcrumbs, PageHeading } from '@/components/education/PageLayout';
|
||||||
|
|
||||||
const API = '/api/education/contact/';
|
|
||||||
const initial = { name: '', email: '', phone: '', level: '', message: '', website: '', accepted_privacy: false };
|
const initial = { name: '', email: '', phone: '', level: '', message: '', website: '', accepted_privacy: false };
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
|
const editor = useEditor();
|
||||||
const [form, setForm] = useState(initial);
|
const [form, setForm] = useState(initial);
|
||||||
const [state, setState] = useState('idle');
|
const [state, setState] = useState('idle');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@ -24,14 +27,12 @@ export default function ContactPage() {
|
|||||||
if (state === 'pending') return;
|
if (state === 'pending') return;
|
||||||
setState('pending'); setError('');
|
setState('pending'); setError('');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) });
|
await educationService.sendContact(form);
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(response.status === 429 ? 'Trop de messages envoyés. Réessayez plus tard.' : (typeof data?.message === 'string' ? data.message : 'Merci de vérifier les champs et de réessayer.'));
|
|
||||||
setState('success'); setForm(initial);
|
setState('success'); setForm(initial);
|
||||||
} catch (err) { setError(err.message || 'Connexion impossible. Réessayez dans un instant.'); setState('error'); }
|
} catch (err) { setError(err.message || 'Connexion impossible. Réessayez dans un instant.'); setState('error'); }
|
||||||
}
|
}
|
||||||
return <div className="site-container page-shell"><Breadcrumbs items={[{ label: 'Me contacter' }]} /><PageHeading eyebrow="Parlons-en" title={<><EditText id="contact.heading.start" label="Titre contact">Chaque progression commence </EditText><span className="text-primary italic font-serif"><EditText id="contact.heading.accent" label="Titre contact accent">par une discussion.</EditText></span></>} description="Vous avez une question sur les cours, une matière ou un niveau ? Décrivez-moi vos besoins, je vous répondrai dès que possible." />
|
return <div className="site-container page-shell"><Breadcrumbs items={[{ label: 'Me contacter' }]} /><PageHeading eyebrow="Parlons-en" title={<><EditText id="contact.heading.start" label="Titre contact">Chaque progression commence </EditText><span className="text-primary italic font-serif"><EditText id="contact.heading.accent" label="Titre contact accent">par une discussion.</EditText></span></>} description="Vous avez une question sur les cours, une matière ou un niveau ? Décrivez-moi vos besoins, je vous répondrai dès que possible." />
|
||||||
<div className="grid lg:grid-cols-[.72fr_1.28fr] gap-7 mt-10 pb-14"><div className="space-y-4"><div className="feature-panel"><h2 className="text-xl font-semibold"><EditText id="contact.identity" label="Nom enseignant">Valérie Chaillou</EditText></h2><p className="mt-2 text-sm text-muted-foreground"><EditText id="contact.subtitle" label="Sous-titre contacts">Cours particuliers de biologie · AccroCbio</EditText></p><a className="contact-line mt-6" href="tel:+33617071717"><Phone className="size-5 text-primary" /><span><EditText id="contact.phone" label="Téléphone affiché">06 17 07 17 17</EditText></span><ExternalLink className="ml-auto size-4 text-muted-foreground" /></a><p className="contact-line"><MapPin className="size-5 text-primary" /><span><EditText id="contact.area" label="Zone cours">Bressuire & nord Deux-Sèvres</EditText></span></p><p className="text-sm text-muted-foreground mt-6 leading-relaxed"><EditText id="contact.travel" label="Conditions déplacement">Déplacements à domicile dans le secteur, et au-delà selon conditions.</EditText></p></div><div className="feature-panel feature-panel-accent"><MessageSquareText className="size-6 text-primary" /><h3 className="text-lg font-semibold mt-3"><EditText id="contact.help.title" label="Titre conseil">Bien préparer votre demande</EditText></h3><p className="text-sm text-muted-foreground leading-relaxed mt-2"><EditText id="contact.help.description" label="Conseil">Indiquez la classe de l’élève, la discipline concernée et ce que vous aimeriez travailler ensemble.</EditText></p></div></div>
|
<div className="grid lg:grid-cols-[.72fr_1.28fr] gap-7 mt-10 pb-14"><div className="space-y-4"><div className="feature-panel"><h2 className="text-xl font-semibold"><EditText id="contact.identity" label="Nom enseignant">Valérie Chaillou</EditText></h2><p className="mt-2 text-sm text-muted-foreground"><EditText id="contact.subtitle" label="Sous-titre contacts">Cours particuliers de biologie · AccroCbio</EditText></p><a className="contact-line mt-6" href={editor.get("contact.phone.href", "tel:+33617071717")}><Phone className="size-5 text-primary" /><span><EditText id="contact.phone" label="Téléphone affiché">06 17 07 17 17</EditText></span><ExternalLink className="ml-auto size-4 text-muted-foreground" /></a><p className="contact-line"><MapPin className="size-5 text-primary" /><span><EditText id="contact.area" label="Zone cours">Bressuire & nord Deux-Sèvres</EditText></span></p><p className="text-sm text-muted-foreground mt-6 leading-relaxed"><EditText id="contact.travel" label="Conditions déplacement">Déplacements à domicile dans le secteur, et au-delà selon conditions.</EditText></p></div><div className="feature-panel feature-panel-accent"><MessageSquareText className="size-6 text-primary" /><h3 className="text-lg font-semibold mt-3"><EditText id="contact.help.title" label="Titre conseil">Bien préparer votre demande</EditText></h3><p className="text-sm text-muted-foreground leading-relaxed mt-2"><EditText id="contact.help.description" label="Conseil">Indiquez la classe de l’élève, la discipline concernée et ce que vous aimeriez travailler ensemble.</EditText></p></div></div>
|
||||||
<Card className="border-border/70 shadow-none"><CardContent className="p-6 md:p-8"><h2 className="text-2xl font-semibold mb-1"><EditText id="contact.form.title" label="Titre formulaire">Envoyer un message</EditText></h2><p className="text-sm text-muted-foreground mb-7"><EditText id="contact.form.description" label="Explication formulaire">Les champs marqués d’un astérisque sont obligatoires.</EditText></p>
|
<Card className="border-border/70 shadow-none"><CardContent className="p-6 md:p-8"><h2 className="text-2xl font-semibold mb-1"><EditText id="contact.form.title" label="Titre formulaire">Envoyer un message</EditText></h2><p className="text-sm text-muted-foreground mb-7"><EditText id="contact.form.description" label="Explication formulaire">Les champs marqués d’un astérisque sont obligatoires.</EditText></p>
|
||||||
{state === 'success' ? <Alert className="border-primary/25 bg-accent mb-5"><CheckCircle2 className="size-5 text-primary" /><AlertTitle>Message envoyé !</AlertTitle><AlertDescription>Votre demande a été enregistrée. Nous vous répondrons dès que possible.</AlertDescription></Alert> : null}
|
{state === 'success' ? <Alert className="border-primary/25 bg-accent mb-5"><CheckCircle2 className="size-5 text-primary" /><AlertTitle>Message envoyé !</AlertTitle><AlertDescription>Votre demande a été enregistrée. Nous vous répondrons dès que possible.</AlertDescription></Alert> : null}
|
||||||
{error && <Alert variant="destructive" className="mb-5"><AlertCircle className="size-4" /><AlertTitle>Envoi impossible</AlertTitle><AlertDescription>{error}</AlertDescription></Alert>}
|
{error && <Alert variant="destructive" className="mb-5"><AlertCircle className="size-4" /><AlertTitle>Envoi impossible</AlertTitle><AlertDescription>{error}</AlertDescription></Alert>}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { educationService } from '@/services/educationService';
|
||||||
import { EditText } from '@/components/education/Editable';
|
import { EditText } from '@/components/education/Editable';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { ArrowRight, CalendarDays, Gift, Percent, Sparkles } from 'lucide-react';
|
import { ArrowRight, CalendarDays, Gift, Percent, Sparkles } from 'lucide-react';
|
||||||
@ -6,10 +7,10 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Breadcrumbs, CallToAction, PageHeading } from '@/components/education/PageLayout';
|
import { Breadcrumbs, CallToAction, PageHeading } from '@/components/education/PageLayout';
|
||||||
|
|
||||||
const API = '/api/education/announcements/';
|
|
||||||
export default function OfferPage() {
|
export default function OfferPage() {
|
||||||
const [offers, setOffers] = useState([]);
|
const [offers, setOffers] = useState([]);
|
||||||
useEffect(() => { const ctrl = new AbortController(); fetch(API, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject()).then(d => { if (Array.isArray(d)) setOffers(d); }).catch(() => {}); return () => ctrl.abort(); }, []);
|
useEffect(() => { educationService.announcements().then(d => { if (Array.isArray(d)) setOffers(d); }).catch(() => {}); }, []);
|
||||||
return <div className="site-container page-shell"><Breadcrumbs items={[{ label: 'Offre de rentrée' }]} /><PageHeading eyebrow="Actualités" title={<><EditText id="offers.heading.start" label="Titre offres">Pour une année pleine </EditText><span className="text-primary italic font-serif"><EditText id="offers.heading.accent" label="Titre offres accent">de découvertes.</EditText></span></>} description="Retrouvez les offres et actualités des cours particuliers AccroCbio." />
|
return <div className="site-container page-shell"><Breadcrumbs items={[{ label: 'Offre de rentrée' }]} /><PageHeading eyebrow="Actualités" title={<><EditText id="offers.heading.start" label="Titre offres">Pour une année pleine </EditText><span className="text-primary italic font-serif"><EditText id="offers.heading.accent" label="Titre offres accent">de découvertes.</EditText></span></>} description="Retrouvez les offres et actualités des cours particuliers AccroCbio." />
|
||||||
{offers.length ? <div className="grid gap-5 mt-10">{offers.map(o => <div className="rounded-3xl border border-border bg-accent p-7 md:p-10" key={o.id}><Badge className="mb-4">Offre en cours</Badge><h2 className="text-2xl md:text-3xl font-semibold">{o.title}</h2><p className="mt-4 text-muted-foreground max-w-3xl leading-relaxed whitespace-pre-line">{o.body}</p><Button size="lg" className="mt-6" render={<Link to="/contact">{o.cta_label}<ArrowRight className="size-4 ml-1" /></Link>} /></div>)}</div> : <div className="rounded-3xl bg-accent/65 p-8 md:p-12 mt-10"><Gift className="size-9 text-primary" /><h2 className="text-2xl md:text-3xl font-semibold mt-5"><EditText id="offers.empty.title" label="Offres par défaut">Des offres qui évoluent avec l’année scolaire.</EditText></h2><p className="text-muted-foreground leading-relaxed mt-3 max-w-3xl"><EditText id="offers.empty.description" label="Offre par défaut">L’offre publiée en septembre 2026 prévoyait une réduction de 20 % sur certains forfaits annuels pour l’année 2026-2027. Sa validité actuelle est à confirmer : contactez-nous pour connaître les conditions du moment.</EditText></p><Button className="mt-6" size="lg" render={<Link to="/contact">Demander les conditions <ArrowRight className="size-4 ml-1" /></Link>} /></div>}
|
{offers.length ? <div className="grid gap-5 mt-10">{offers.map(o => <div className="rounded-3xl border border-border bg-accent p-7 md:p-10" key={o.id}><Badge className="mb-4">Offre en cours</Badge><h2 className="text-2xl md:text-3xl font-semibold">{o.title}</h2><p className="mt-4 text-muted-foreground max-w-3xl leading-relaxed whitespace-pre-line">{o.body}</p><Button size="lg" className="mt-6" render={<Link to="/contact">{o.cta_label}<ArrowRight className="size-4 ml-1" /></Link>} /></div>)}</div> : <div className="rounded-3xl bg-accent/65 p-8 md:p-12 mt-10"><Gift className="size-9 text-primary" /><h2 className="text-2xl md:text-3xl font-semibold mt-5"><EditText id="offers.empty.title" label="Offres par défaut">Des offres qui évoluent avec l’année scolaire.</EditText></h2><p className="text-muted-foreground leading-relaxed mt-3 max-w-3xl"><EditText id="offers.empty.description" label="Offre par défaut">L’offre publiée en septembre 2026 prévoyait une réduction de 20 % sur certains forfaits annuels pour l’année 2026-2027. Sa validité actuelle est à confirmer : contactez-nous pour connaître les conditions du moment.</EditText></p><Button className="mt-6" size="lg" render={<Link to="/contact">Demander les conditions <ArrowRight className="size-4 ml-1" /></Link>} /></div>}
|
||||||
<div className="mt-12"><CallToAction description="Quel que soit le moment de l’année, nous pouvons réfléchir ensemble à un parcours adapté." /></div>
|
<div className="mt-12"><CallToAction description="Quel que soit le moment de l’année, nous pouvons réfléchir ensemble à un parcours adapté." /></div>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { EditText } from '@/components/education/Editable';
|
import { EditText } from '@/components/education/Editable';
|
||||||
|
import { educationService } from '@/services/educationService';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { ArrowLeft, ArrowRight, ArrowUpRight, BookOpen, ExternalLink, FileText, Filter, Search, Video } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, ArrowUpRight, BookOpen, ExternalLink, FileText, Filter, Search, Video } from 'lucide-react';
|
||||||
@ -9,16 +10,14 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Breadcrumbs, CallToAction, PageHeading, SectionHeader } from '@/components/education/PageLayout';
|
import { Breadcrumbs, CallToAction, PageHeading, SectionHeader } from '@/components/education/PageLayout';
|
||||||
import { fallbackResources, stlPremiereSections, stlTerminaleSections, subjects, tracks } from '@/data/education';
|
import { fallbackResources, stlPremiereSections, stlTerminaleSections, subjects, tracks } from '@/data/education';
|
||||||
|
|
||||||
const API = '/api/education';
|
|
||||||
|
|
||||||
function ResourceList({ chosenCategories }) {
|
function ResourceList({ chosenCategories }) {
|
||||||
const [resources, setResources] = useState(fallbackResources);
|
const [resources, setResources] = useState(fallbackResources);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [serverAvailable, setServerAvailable] = useState(false);
|
const [serverAvailable, setServerAvailable] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ctrl = new AbortController();
|
educationService.resources().then(data => { if (Array.isArray(data)) { setResources(data); setServerAvailable(true); } }).catch(() => {});
|
||||||
fetch(`${API}/resources/`, { signal: ctrl.signal }).then(response => { if (!response.ok) throw new Error('API unavailable'); return response.json(); }).then(data => { if (Array.isArray(data)) { setResources(data); setServerAvailable(true); } }).catch(() => {});
|
|
||||||
return () => ctrl.abort();
|
|
||||||
}, []);
|
}, []);
|
||||||
const visible = useMemo(() => resources.filter(r => chosenCategories.includes(r.category) && `${r.title} ${r.description || ''}`.toLocaleLowerCase('fr').includes(search.toLocaleLowerCase('fr'))), [resources, chosenCategories, search]);
|
const visible = useMemo(() => resources.filter(r => chosenCategories.includes(r.category) && `${r.title} ${r.description || ''}`.toLocaleLowerCase('fr').includes(search.toLocaleLowerCase('fr'))), [resources, chosenCategories, search]);
|
||||||
return <div className="mt-9">
|
return <div className="mt-9">
|
||||||
@ -50,7 +49,7 @@ export default function ResourcesPage() {
|
|||||||
<><PageHeading eyebrow={selectedSubject?.short || 'Ressources'} title={currentTrack ? currentTrack.label : selectedSubject?.title} description={currentTrack ? currentTrack.description : selectedSubject?.description} />
|
<><PageHeading eyebrow={selectedSubject?.short || 'Ressources'} title={currentTrack ? currentTrack.label : selectedSubject?.title} description={currentTrack ? currentTrack.description : selectedSubject?.description} />
|
||||||
{currentTrack && <div className="flex gap-2 flex-wrap mt-5">{currentTrack.tags.map(tag => <Badge key={tag} variant="secondary" className="px-3 py-1.5">{tag}</Badge>)}</div>}
|
{currentTrack && <div className="flex gap-2 flex-wrap mt-5">{currentTrack.tags.map(tag => <Badge key={tag} variant="secondary" className="px-3 py-1.5">{tag}</Badge>)}</div>}
|
||||||
{!currentTrack && <div className="mt-9"><SectionHeader eyebrow="Choisir son niveau" title="Un parcours, des ressources adaptées." /><TrackCards selected={relatedTracks} /></div>}
|
{!currentTrack && <div className="mt-9"><SectionHeader eyebrow="Choisir son niveau" title="Un parcours, des ressources adaptées." /><TrackCards selected={relatedTracks} /></div>}
|
||||||
{stlSections && <div className="grid lg:grid-cols-2 gap-4 mt-10">{stlSections.map(section => <Card key={section.title} className="shadow-none border-border/70"><CardContent className="p-6"><h2 className="text-xl font-semibold">{section.title}</h2><ul className="mt-4 space-y-3">{section.items.map(item => <li key={item} className="flex gap-3 text-sm text-muted-foreground"><span className="mt-2 size-1.5 rounded-full bg-primary shrink-0" />{item}</li>)}</ul></CardContent></Card>)}</div>}
|
{stlSections && <div className="grid lg:grid-cols-2 gap-4 mt-10">{stlSections.map(section => <Card key={section.title} className="shadow-none border-border/70"><CardContent className="p-6"><h2 className="text-xl font-semibold"><EditText id={`resources.stl.${currentTrack.id}.${section.title.toLowerCase().replaceAll(" ","-")}.title`}>{section.title}</EditText></h2><ul className="mt-4 space-y-3">{section.items.map(item => <li key={item} className="flex gap-3 text-sm text-muted-foreground"><span className="mt-2 size-1.5 rounded-full bg-primary shrink-0" /><EditText id={`resources.stl.${currentTrack.id}.${section.title.toLowerCase().replaceAll(" ","-")}.item.${section.items.indexOf(item)}`}>{item}</EditText></li>)}</ul></CardContent></Card>)}</div>}
|
||||||
</>}
|
</>}
|
||||||
<ResourceList chosenCategories={chosenCategories} />
|
<ResourceList chosenCategories={chosenCategories} />
|
||||||
<div className="my-12"><CallToAction title="Une notion vous résiste ?" description="Les documents sont utiles, mais un accompagnement personnalisé peut faire toute la différence." action="Demander un cours" /></div>
|
<div className="my-12"><CallToAction title="Une notion vous résiste ?" description="Les documents sont utiles, mais un accompagnement personnalisé peut faire toute la différence." action="Demander un cours" /></div>
|
||||||
|
|||||||
@ -1,19 +1,15 @@
|
|||||||
import { EditText } from '@/components/education/Editable';
|
import { EditText } from '@/components/education/Editable';
|
||||||
|
import { educationService } from '@/services/educationService';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Quote, Star } from 'lucide-react';
|
import { Quote, Star } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Breadcrumbs, CallToAction, PageHeading } from '@/components/education/PageLayout';
|
import { Breadcrumbs, CallToAction, PageHeading } from '@/components/education/PageLayout';
|
||||||
|
|
||||||
// Extraits courts publiés sur le site de référence ; pas de faux témoignages.
|
|
||||||
const sample = [
|
|
||||||
{ id: 'reference-1', author: 'Valentine D.', description: 'Élève · juin 2025', quote: 'Une professeure vraiment géniale. Très investie pour ses élèves, souriante, motivante et toujours à l’écoute. Elle m’a beaucoup aidée à progresser, à mieux m’organiser dans mes cours et à prendre confiance en moi. Grâce à elle, j’ai pu réussir mon bac et avancer avec plus d’assurance. Un grand merci pour tout.' },
|
|
||||||
{ id: 'reference-2', author: 'Margaux Couthouis', description: 'Ancienne élève', quote: 'Très bonne enseignante et très bonne tutrice qui a le goût de la perfection. Passionnée et créative, Valérie est très compétente et a su me donner des précieux conseils en tant que tutrice.' },
|
|
||||||
];
|
|
||||||
const API = '/api/education/testimonials/';
|
|
||||||
|
|
||||||
export default function ReviewsPage() {
|
export default function ReviewsPage() {
|
||||||
const [reviews, setReviews] = useState(sample);
|
const [reviews, setReviews] = useState([]);
|
||||||
useEffect(() => { const ctrl = new AbortController(); fetch(API, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject()).then(data => { if (Array.isArray(data) && data.length) setReviews(data); }).catch(() => {}); return () => ctrl.abort(); }, []);
|
useEffect(() => { educationService.testimonials().then(data => { if (Array.isArray(data) && data.length) setReviews(data); }).catch(() => {}); }, []);
|
||||||
return <div className="site-container page-shell"><Breadcrumbs items={[{ label: 'Avis' }]} /><PageHeading eyebrow="Ils en parlent" title={<><EditText id="reviews.heading.start" label="Titre avis">Le plus beau résultat, </EditText><span className="text-primary italic font-serif"><EditText id="reviews.heading.accent" label="Titre avis accent">c’est leur confiance.</EditText></span></>} description="Quelques témoignages d’élèves ayant bénéficié d’un accompagnement AccroCbio." />
|
return <div className="site-container page-shell"><Breadcrumbs items={[{ label: 'Avis' }]} /><PageHeading eyebrow="Ils en parlent" title={<><EditText id="reviews.heading.start" label="Titre avis">Le plus beau résultat, </EditText><span className="text-primary italic font-serif"><EditText id="reviews.heading.accent" label="Titre avis accent">c’est leur confiance.</EditText></span></>} description="Quelques témoignages d’élèves ayant bénéficié d’un accompagnement AccroCbio." />
|
||||||
<div className="grid md:grid-cols-2 gap-5 mt-10">{reviews.map(review => <Card key={review.id} className="border-border/70 shadow-none"><CardContent className="p-7"><Quote className="size-9 text-primary/35" /><p className="text-lg leading-relaxed mt-5">« {review.quote} »</p><div className="border-t border-border/70 pt-5 mt-7"><span className="font-semibold">{review.author}</span>{review.description && <span className="block mt-1 text-xs text-muted-foreground">{review.description}</span>}</div></CardContent></Card>)}</div>
|
<div className="grid md:grid-cols-2 gap-5 mt-10">{reviews.map(review => <Card key={review.id} className="border-border/70 shadow-none"><CardContent className="p-7"><Quote className="size-9 text-primary/35" /><p className="text-lg leading-relaxed mt-5">« {review.quote} »</p><div className="border-t border-border/70 pt-5 mt-7"><span className="font-semibold">{review.author}</span>{review.description && <span className="block mt-1 text-xs text-muted-foreground">{review.description}</span>}</div></CardContent></Card>)}</div>
|
||||||
<div className="my-12"><CallToAction title="À votre tour de prendre confiance ?" /></div>
|
<div className="my-12"><CallToAction title="À votre tour de prendre confiance ?" /></div>
|
||||||
|
|||||||
7
frontend/src/services/adminService.js
Normal file
7
frontend/src/services/adminService.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { apiRequest } from "@/services/apiClient"
|
||||||
|
|
||||||
|
export const adminService = {
|
||||||
|
dashboard: () => apiRequest("admin/dashboard/"),
|
||||||
|
createUser: (data) => apiRequest("admin/users/", { method: "POST", data }),
|
||||||
|
setUserActive: (userId, isActive) => apiRequest(`admin/users/${userId}/active/`, { method: "POST", data: { is_active: isActive } }),
|
||||||
|
}
|
||||||
49
frontend/src/services/apiClient.js
Normal file
49
frontend/src/services/apiClient.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/** Client HTTP partagé : aucune requête réseau dans les composants JSX. */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message, status) {
|
||||||
|
super(message)
|
||||||
|
this.name = "ApiError"
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCookie(name) {
|
||||||
|
const key = `${name}=`
|
||||||
|
const found = document.cookie.split(";").map((cookie) => cookie.trim()).find((cookie) => cookie.startsWith(key))
|
||||||
|
return found ? decodeURIComponent(found.slice(key.length)) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
let csrfRequest = null
|
||||||
|
async function ensureCsrf() {
|
||||||
|
if (readCookie("csrftoken")) return
|
||||||
|
if (!csrfRequest) {
|
||||||
|
csrfRequest = fetch("/api/auth/csrf/", { credentials: "same-origin" }).finally(() => { csrfRequest = null })
|
||||||
|
}
|
||||||
|
const response = await csrfRequest
|
||||||
|
if (!response.ok || !readCookie("csrftoken")) throw new ApiError("Impossible d'initialiser la sécurité CSRF.", response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRequest(path, { method = "GET", data, ...options } = {}) {
|
||||||
|
const isMutation = !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase())
|
||||||
|
if (isMutation) await ensureCsrf()
|
||||||
|
let response
|
||||||
|
try {
|
||||||
|
response = await fetch(`/api/${path.replace(/^\//, "")}`, {
|
||||||
|
method,
|
||||||
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(data !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...(isMutation ? { "X-CSRFToken": readCookie("csrftoken") || "" } : {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
...(data !== undefined ? { body: JSON.stringify(data) } : {}),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new ApiError("Impossible de joindre le serveur. Vérifie ta connexion.", 0)
|
||||||
|
}
|
||||||
|
const payload = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) throw new ApiError(payload.detail || `Erreur HTTP ${response.status}`, response.status)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
7
frontend/src/services/authService.js
Normal file
7
frontend/src/services/authService.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { apiRequest } from "@/services/apiClient"
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
me: () => apiRequest("auth/me/"),
|
||||||
|
login: (username, password) => apiRequest("auth/login/", { method: "POST", data: { username, password } }),
|
||||||
|
logout: () => apiRequest("auth/logout/", { method: "POST" }),
|
||||||
|
}
|
||||||
21
frontend/src/services/cmsService.js
Normal file
21
frontend/src/services/cmsService.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { apiRequest } from '@/services/apiClient'
|
||||||
|
|
||||||
|
const base = 'cms/'
|
||||||
|
export const cmsService = {
|
||||||
|
content: () => apiRequest(`${base}content/`),
|
||||||
|
saveContent: (key, value) => apiRequest(`${base}content/${encodeURIComponent(key)}/`, { method: 'PUT', data: { value } }),
|
||||||
|
resetContent: (key) => apiRequest(`${base}content/${encodeURIComponent(key)}/`, { method: 'DELETE' }),
|
||||||
|
pages: () => apiRequest(`${base}pages/`),
|
||||||
|
blocks: () => apiRequest(`${base}blocks/`),
|
||||||
|
createPage: (data) => apiRequest(`${base}pages/`, { method: 'POST', data }),
|
||||||
|
updatePage: (id, data) => apiRequest(`${base}pages/${id}/`, { method: 'PATCH', data }),
|
||||||
|
deletePage: (id) => apiRequest(`${base}pages/${id}/`, { method: 'DELETE' }),
|
||||||
|
createBlock: (data) => apiRequest(`${base}blocks/`, { method: 'POST', data }),
|
||||||
|
updateBlock: (id, data) => apiRequest(`${base}blocks/${id}/`, { method: 'PATCH', data }),
|
||||||
|
deleteBlock: (id) => apiRequest(`${base}blocks/${id}/`, { method: 'DELETE' }),
|
||||||
|
uploadImage: (file) => {
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('image', file)
|
||||||
|
return apiRequest(`${base}upload/`, { method: 'POST', body })
|
||||||
|
},
|
||||||
|
}
|
||||||
13
frontend/src/services/educationService.js
Normal file
13
frontend/src/services/educationService.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { apiRequest } from '@/services/apiClient'
|
||||||
|
|
||||||
|
const prefix = 'education/'
|
||||||
|
export const educationService = {
|
||||||
|
resources: () => apiRequest(`${prefix}resources/`),
|
||||||
|
testimonials: () => apiRequest(`${prefix}testimonials/`),
|
||||||
|
announcements: () => apiRequest(`${prefix}announcements/`),
|
||||||
|
sendContact: (data) => apiRequest(`${prefix}contact/`, { method: 'POST', data }),
|
||||||
|
list: (type) => apiRequest(`${prefix}editor/data/${type}/`),
|
||||||
|
create: (type, data) => apiRequest(`${prefix}editor/data/${type}/`, { method: 'POST', data }),
|
||||||
|
update: (type, id, data) => apiRequest(`${prefix}editor/data/${type}/${id}/`, { method: 'PATCH', data }),
|
||||||
|
remove: (type, id) => apiRequest(`${prefix}editor/data/${type}/${id}/`, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
@ -1,23 +1,48 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from "vite"
|
||||||
import react from '@vitejs/plugin-react'
|
import react from "@vitejs/plugin-react"
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from "node:url"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [
|
// En local, on lit .env à la racine ; dans Compose, env_file injecte
|
||||||
react(),
|
// directement ces variables au démarrage du conteneur frontend.
|
||||||
tailwindcss(),
|
const rootEnv = loadEnv(mode, fileURLToPath(new URL("../", import.meta.url)), "")
|
||||||
],
|
const backendPort = process.env.BACKEND_PORT || rootEnv.BACKEND_PORT
|
||||||
|
const frontendPort = process.env.FRONTEND_PORT || rootEnv.FRONTEND_PORT
|
||||||
|
const backendHost = process.env.BACKEND_PROXY_HOST || "127.0.0.1"
|
||||||
|
|
||||||
resolve: {
|
// Aucun backend n'est nécessaire pendant le build Vite (Docker).
|
||||||
alias: {
|
// Le proxy est créé au lancement de Vite preview / dev.
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
const proxy = backendPort
|
||||||
},
|
? Object.fromEntries(
|
||||||
},
|
["/api", "/static", "/media"].map((path) => [
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
target: `http://${backendHost}:${backendPort}`,
|
||||||
|
// Conserver le Host public pour ALLOWED_HOSTS et les sessions Django.
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
: {}
|
||||||
|
|
||||||
preview: {
|
const allowedHosts = (process.env.ALLOWED_HOSTS || rootEnv.ALLOWED_HOSTS || "")
|
||||||
allowedHosts: [
|
.split(",")
|
||||||
'accrocbio.louismazin.ovh',
|
.map((host) => host.trim())
|
||||||
],
|
.filter((host) => host && host !== "*")
|
||||||
},
|
|
||||||
})
|
const shared = {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
strictPort: true,
|
||||||
|
...(frontendPort ? { port: Number(frontendPort) } : {}),
|
||||||
|
allowedHosts,
|
||||||
|
proxy,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
|
||||||
|
server: shared,
|
||||||
|
preview: shared,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
101
start.sh
101
start.sh
@ -1,6 +1,5 @@
|
|||||||
|
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -Eeuo pipefail
|
||||||
|
|
||||||
cd /home/container
|
cd /home/container
|
||||||
|
|
||||||
@ -9,80 +8,110 @@ echo "AccroCBio"
|
|||||||
echo "======================================"
|
echo "======================================"
|
||||||
|
|
||||||
# --------------------------------------
|
# --------------------------------------
|
||||||
# Mise à jour du projet
|
# Configuration .env (sans `source` : mots de passe avec caractères spéciaux)
|
||||||
# --------------------------------------
|
# --------------------------------------
|
||||||
|
read_env() {
|
||||||
|
local key="$1" value
|
||||||
|
value="$(sed -n "s/^${key}=//p" /home/container/.env 2>/dev/null | tail -n 1 | tr -d '\r' || true)"
|
||||||
|
value="${value#\"}"
|
||||||
|
value="${value%\"}"
|
||||||
|
value="${value#\'}"
|
||||||
|
value="${value%\'}"
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
if [ ! -d ".git" ]; then
|
# --------------------------------------
|
||||||
echo "Dépôt Git absent → clonage..."
|
# Mise à jour du projet (comme le start.sh fourni)
|
||||||
|
# --------------------------------------
|
||||||
git clone https://gitea.louismazin.ovh/LouisMazin/AccroCBio.git /tmp/accrocbio
|
if [[ ! -d .git ]]; then
|
||||||
|
if [[ -f backend/manage.py && -f frontend/package.json ]]; then
|
||||||
cp -a /tmp/accrocbio/. /home/container/
|
echo "Projet présent sans .git : démarrage sans clonage."
|
||||||
rm -rf /tmp/accrocbio
|
else
|
||||||
|
REPOSITORY_URL="$(read_env REPOSITORY_URL)"
|
||||||
|
REPOSITORY_URL="${REPOSITORY_URL:-https://gitea.louismazin.ovh/LouisMazin/AccroCBio.git}"
|
||||||
|
if [[ -z "$REPOSITORY_URL" ]]; then
|
||||||
|
echo "Dépôt Git absent : ajoute REPOSITORY_URL au .env ou copie le projet." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Dépôt Git absent → clonage..."
|
||||||
|
git clone "$REPOSITORY_URL" /tmp/accrocbio-site
|
||||||
|
cp -a /tmp/accrocbio-site/. /home/container/
|
||||||
|
rm -rf /tmp/accrocbio-site
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "Mise à jour du dépôt Git..."
|
echo "Mise à jour du dépôt Git..."
|
||||||
|
|
||||||
git fetch origin
|
git fetch origin
|
||||||
git reset --hard origin/main
|
git reset --hard origin/main
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Git : $(git rev-parse --short HEAD)"
|
if [[ -d .git ]]; then
|
||||||
|
echo "Git : $(git rev-parse --short HEAD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
FRONTEND_PORT="$(read_env FRONTEND_PORT)"
|
||||||
|
BACKEND_PORT="$(read_env BACKEND_PORT)"
|
||||||
|
for variable in FRONTEND_PORT BACKEND_PORT; do
|
||||||
|
value="${!variable}"
|
||||||
|
if ! [[ "$value" =~ ^[1-9][0-9]{0,4}$ ]] || (( value > 65535 )); then
|
||||||
|
echo "Port $variable absent ou incorrect dans .env (attendu : 1 à 65535)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ "$FRONTEND_PORT" == "$BACKEND_PORT" ]]; then
|
||||||
|
echo "Les ports FRONTEND_PORT et BACKEND_PORT doivent être différents." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export FRONTEND_PORT BACKEND_PORT
|
||||||
|
# Sous Pterodactyl, les deux applications tournent dans le même conteneur.
|
||||||
|
export BACKEND_PROXY_HOST=127.0.0.1
|
||||||
|
|
||||||
# --------------------------------------
|
# --------------------------------------
|
||||||
# Backend
|
# Backend
|
||||||
# --------------------------------------
|
# --------------------------------------
|
||||||
|
|
||||||
cd /home/container/backend
|
cd /home/container/backend
|
||||||
|
|
||||||
python3 manage.py check
|
python3 manage.py check
|
||||||
|
|
||||||
echo "Démarrage Django sur :1037"
|
|
||||||
|
|
||||||
python3 manage.py migrate --noinput
|
python3 manage.py migrate --noinput
|
||||||
|
|
||||||
python3 manage.py ensure_admin
|
python3 manage.py ensure_admin
|
||||||
|
python3 manage.py collectstatic --noinput --verbosity 0
|
||||||
|
|
||||||
python3 manage.py runserver 0.0.0.0:1037 &
|
echo "Démarrage Django sur :${BACKEND_PORT}"
|
||||||
|
python3 manage.py runserver "0.0.0.0:${BACKEND_PORT}" --noreload &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
|
|
||||||
# --------------------------------------
|
# --------------------------------------
|
||||||
# Frontend
|
# Frontend
|
||||||
# --------------------------------------
|
# --------------------------------------
|
||||||
|
|
||||||
cd /home/container/frontend
|
cd /home/container/frontend
|
||||||
|
|
||||||
echo "Installation des dépendances frontend..."
|
echo "Installation des dépendances frontend..."
|
||||||
npm ci
|
if [[ -f package-lock.json ]]; then
|
||||||
|
npm ci
|
||||||
|
else
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Build React..."
|
echo "Build React..."
|
||||||
|
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
echo "Démarrage frontend sur :1036"
|
echo "Démarrage frontend sur :${FRONTEND_PORT}"
|
||||||
|
npm run preview -- --host 0.0.0.0 --port "$FRONTEND_PORT" --strictPort &
|
||||||
npm run preview -- --host 0.0.0.0 --port 1036 &
|
|
||||||
FRONTEND_PID=$!
|
FRONTEND_PID=$!
|
||||||
|
|
||||||
# --------------------------------------
|
# --------------------------------------
|
||||||
# Arrêt propre
|
# Arrêt propre
|
||||||
# --------------------------------------
|
# --------------------------------------
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
trap - SIGTERM SIGINT EXIT
|
||||||
echo "Arrêt des applications..."
|
echo "Arrêt des applications..."
|
||||||
|
kill "$BACKEND_PID" "$FRONTEND_PID" 2>/dev/null || true
|
||||||
kill "$BACKEND_PID" 2>/dev/null || true
|
wait "$BACKEND_PID" "$FRONTEND_PID" 2>/dev/null || true
|
||||||
kill "$FRONTEND_PID" 2>/dev/null || true
|
|
||||||
|
|
||||||
wait "$BACKEND_PID" 2>/dev/null || true
|
|
||||||
wait "$FRONTEND_PID" 2>/dev/null || true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trap cleanup SIGTERM SIGINT EXIT
|
trap cleanup SIGTERM SIGINT EXIT
|
||||||
|
|
||||||
echo "======================================"
|
echo "======================================"
|
||||||
echo " Frontend : :1036"
|
echo " Frontend : :${FRONTEND_PORT}"
|
||||||
echo " Backend : :1037"
|
echo " Backend : :${BACKEND_PORT}"
|
||||||
echo "======================================"
|
echo "======================================"
|
||||||
|
|
||||||
wait -n "$BACKEND_PID" "$FRONTEND_PID"
|
wait -n "$BACKEND_PID" "$FRONTEND_PID"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user