nice
This commit is contained in:
parent
a2fda03471
commit
663268bbe9
94
README.md
94
README.md
@ -1,22 +1,46 @@
|
||||
# Template Django + React + MySQL
|
||||
|
||||
Un squelette réutilisable inspiré de l'organisation de GoodDrive, **sans ses fonctionnalités** :
|
||||
Django REST Framework, React/Vite, Tailwind 4, composants shadcn/ui, MySQL 8 et Docker Compose.
|
||||
Django REST Framework, React/Vite, Tailwind 4, composants shadcn/ui, Oxlint, MySQL 8 et Docker Compose.
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
1. Compléter les secrets du `.env` (ou les générer avec `python scripts/init_env.py`).
|
||||
2. Modifier si nécessaire `FRONTEND_PORT`, `BACKEND_PORT`, `ALLOWED_HOSTS` et les origines autorisées.
|
||||
3. Démarrer :
|
||||
3. Démarrer avec le script principal :
|
||||
|
||||
```sh
|
||||
python scripts/init_env.py
|
||||
docker compose up -d --build
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
`start.sh` initialise le `.env`, puis détecte Docker Compose. Si Docker est disponible,
|
||||
il construit et démarre les trois services. Sinon, il lance **Django et React dans le même
|
||||
environnement** (mode Pterodactyl, avec une base MySQL externe déjà accessible).
|
||||
|
||||
Tu peux choisir explicitement :
|
||||
|
||||
```sh
|
||||
./start.sh --compose # Docker Compose
|
||||
./start.sh --standalone # Un seul environnement, sans Docker
|
||||
```
|
||||
|
||||
En mode standalone, Python 3, Node.js/npm et les bibliothèques nécessaires à `mysqlclient`
|
||||
doivent être disponibles. Le script installe les dépendances Python manquantes, installe
|
||||
les dépendances React et compile le frontend avant de lancer les deux serveurs.
|
||||
La variable `MYSQL_HOST=db` du modèle fonctionne **uniquement en mode Compose** :
|
||||
en mode standalone, indique l'adresse réelle de ton serveur MySQL dans `.env`.
|
||||
|
||||
Le mode standalone utilise `vite preview` pour servir le build et le proxy API ; pour
|
||||
un site public à forte charge, préfère le mode Docker Compose avec Nginx.
|
||||
|
||||
Le script ne télécharge ni n'écrase automatiquement le dépôt Git.
|
||||
|
||||
|
||||
- **Frontend** : http://localhost:1028 (ou `FRONTEND_PORT`)
|
||||
- **Backend** : http://localhost:1029/api/health/ (ou `BACKEND_PORT`)
|
||||
- **Admin Django** : http://localhost:1028/admin/
|
||||
- **Administration React (shadcn/ui)** : http://localhost:1028/admin/
|
||||
- **Admin Django technique** : http://localhost:1028/django-admin/
|
||||
|
||||
Au premier lancement, `ensure_admin` crée le compte à partir de `ADMIN_*`. Si le compte existe déjà, il **ne change pas son mot de passe** à chaque redémarrage.
|
||||
|
||||
@ -29,6 +53,7 @@ Au premier lancement, `ensure_admin` crée le compte à partir de `ADMIN_*`. Si
|
||||
├── .env # paramètres locaux, non versionné
|
||||
├── .env.example # modèle sans secrets
|
||||
├── docker-compose.yml
|
||||
├── start.sh # Démarrage Compose ou standalone / Pterodactyl
|
||||
├── scripts/
|
||||
│ └── init_env.py
|
||||
├── backend/
|
||||
@ -43,17 +68,29 @@ Au premier lancement, `ensure_admin` crée le compte à partir de `ADMIN_*`. Si
|
||||
│ └── common/
|
||||
│ ├── urls.py
|
||||
│ ├── views.py
|
||||
│ ├── admin_api.py # API sécurisée pour le tableau de bord React
|
||||
│ └── management/commands/ensure_admin.py
|
||||
└── frontend/
|
||||
├── Dockerfile
|
||||
├── nginx.conf
|
||||
├── package.json
|
||||
├── .oxlintrc.json # Oxlint : JS/JSX, React et accessibilité
|
||||
├── components.json
|
||||
├── vite.config.js
|
||||
└── src/
|
||||
├── App.jsx
|
||||
├── App.jsx # sélection des pages
|
||||
├── index.css
|
||||
└── components/ui/
|
||||
├── pages/ # HomePage, AdminPage
|
||||
├── hooks/ # logique React et états réutilisables
|
||||
├── services/ # appels HTTP centralisés, AUCUN fetch dans JSX
|
||||
│ ├── apiClient.js
|
||||
│ ├── healthService.js
|
||||
│ ├── authService.js
|
||||
│ └── adminService.js
|
||||
└── components/
|
||||
├── admin/ # widgets de la page d'administration
|
||||
├── layout/ # composants partagés
|
||||
└── ui/ # composants shadcn/ui
|
||||
```
|
||||
|
||||
## Origines CORS/CSRF et ports automatiques
|
||||
@ -97,17 +134,30 @@ configuré si une autre application appelle directement l'API exposée sur le po
|
||||
- `db` : MySQL 8.4 avec volume `db_data` (non exposé sur le réseau hôte).
|
||||
- `backend` : Django + Gunicorn sur le port interne 8000, publié sur `BACKEND_PORT`.
|
||||
- `frontend` : React compilé puis servi par Nginx sur le port interne 80, publié sur `FRONTEND_PORT`.
|
||||
- Nginx relaie `/api/`, `/admin/` et `/static/` vers Django.
|
||||
- Nginx relaie `/api/`, `/django-admin/` et `/static/` vers Django. `/admin/` est une page React.
|
||||
|
||||
Le port `MYSQL_PORT` est **le port de MySQL dans le réseau des conteneurs** (3306 avec le service `db`). Pour une base distante, adapte `MYSQL_HOST` et `MYSQL_PORT`. Le service `db` reste créé par Compose dans cette version du template.
|
||||
|
||||
## Ajouter tes fonctionnalités
|
||||
|
||||
- Django : nouvelle application via `python manage.py startapp monapp` ; l'ajouter dans `INSTALLED_APPS` et ses URL dans `core/urls.py`.
|
||||
- React : nouvelles pages/composants dans `frontend/src/`.
|
||||
- shadcn/ui : le fichier `components.json` est présent. Ajouter les composants via la CLI shadcn (`npx shadcn@latest add dialog`, par exemple). Le template inclut déjà Button et Card.
|
||||
- React : nouvelles pages dans `frontend/src/pages/`, hooks dans `hooks/` et appels à l’API dans `services/`. Ne pas appeler `fetch` dans les composants JSX.
|
||||
- shadcn/ui : le fichier `components.json` est présent. Ajouter les composants via la CLI shadcn (`npx shadcn@latest add dialog`, par exemple). Le template inclut Button, Card, Input, Label, Table, Badge, Dialog, Separator et Alert.
|
||||
- Variables supplémentaires : les ajouter au `.env.example` et les lire via `decouple.config` côté Django.
|
||||
|
||||
## Qualité du code avec Oxlint
|
||||
|
||||
Oxlint est inclus dans les dépendances de développement du frontend. Il contrôle le JavaScript/JSX, les règles React, les erreurs fréquentes et certaines règles d’accessibilité. Il ne remplace pas un formateur de code.
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
npm install
|
||||
npm run lint # Contrôle sans modifier les fichiers
|
||||
npm run lint:fix # Applique les corrections automatiques sûres disponibles
|
||||
```
|
||||
|
||||
La configuration se trouve dans `frontend/.oxlintrc.json`. Les dossiers `dist` et `coverage` sont ignorés. Oxlint est volontairement **indépendant du démarrage** : `start.sh` ne bloque pas le lancement du site sur un avertissement de lint.
|
||||
|
||||
## Vérifier et exploiter
|
||||
|
||||
```sh
|
||||
@ -120,6 +170,8 @@ docker compose exec backend python manage.py shell -c "from django.conf import s
|
||||
|
||||
# Tests unitaires des origines (sans Django)
|
||||
cd backend && python -m unittest discover -s tests
|
||||
# Avec une base Django de test :
|
||||
python manage.py test common
|
||||
|
||||
# Stopper sans détruire MySQL
|
||||
docker compose down
|
||||
@ -134,3 +186,25 @@ docker compose down
|
||||
- La route de diagnostic `/api/health/` est volontairement publique ; les futures routes API sont protégées par défaut dans DRF.
|
||||
- Sur Internet, configure HTTPS, `ALLOWED_HOSTS`, origines, cookies sécurisés et reverse proxy avant mise en ligne.
|
||||
- En production, évite d'exposer directement le port backend si les appels passent seulement par Nginx.
|
||||
|
||||
## Tableau de bord administrateur (shadcn/ui)
|
||||
|
||||
- `/admin/` : interface React avec connexion par session Django, thème clair/sombre,
|
||||
statistiques, recherche, création de comptes et activation/désactivation.
|
||||
- `/django-admin/` : administration Django native conservée pour la gestion avancée.
|
||||
- Seuls les comptes `is_staff` accèdent au tableau de bord. Pour **créer ou désactiver**
|
||||
des comptes, il faut en plus être **superutilisateur**. Le compte connecté et les
|
||||
autres superutilisateurs sont protégés contre la désactivation.
|
||||
- L'API se trouve dans `backend/common/admin_api.py`. La session Django et les jetons
|
||||
CSRF protègent les actions, **les secrets ne sont jamais stockés dans le frontend**.
|
||||
- Le frontend parle au backend par un chemin relatif `/api/`, proxifié en Compose
|
||||
comme en standalone. `frontend/src/services/apiClient.js` centralise les erreurs,
|
||||
cookies et en-têtes CSRF.
|
||||
- L'interface ne remplace pas les permissions fines de Django Admin pour les modèles
|
||||
que tu ajouteras : la page React sert de base claire et extensible.
|
||||
|
||||
## Validation
|
||||
|
||||
Le template inclut les tests `backend/common/tests.py` pour l'authentification,
|
||||
les autorisations administrateur et la CSRF. Pour les lancer, une base Django de test
|
||||
accessible est requise : `python manage.py test common`.
|
||||
|
||||
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)})
|
||||
54
backend/common/tests.py
Normal file
54
backend/common/tests.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""Régressions des droits admin. Lancer avec : python manage.py test common."""
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import 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)
|
||||
@ -1,4 +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")]
|
||||
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"),
|
||||
]
|
||||
|
||||
@ -7,7 +7,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
SECRET_KEY = config("SECRET_KEY", default="")
|
||||
if not SECRET_KEY:
|
||||
raise RuntimeError("SECRET_KEY est vide. Exécuter : python scripts/init_env.py")
|
||||
raise RuntimeError("SECRET_KEY est vide : renseigne SECRET_KEY dans le fichier .env")
|
||||
DEBUG = config("DEBUG", default=False, cast=bool)
|
||||
|
||||
FRONTEND_PORT = config("FRONTEND_PORT", default=1028, cast=int)
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
admin.site.site_header = "Administration avancée"
|
||||
admin.site.site_title = "Administration Django"
|
||||
admin.site.index_title = "Gestion technique"
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
# /admin/ est le tableau de bord React basé sur shadcn/ui.
|
||||
# Django Admin reste accessible pour les fonctions avancées.
|
||||
path("django-admin/", admin.site.urls),
|
||||
path("api/", include("common.urls")),
|
||||
]
|
||||
|
||||
28
frontend/.oxlintrc.json
Normal file
28
frontend/.oxlintrc.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": [
|
||||
"eslint",
|
||||
"unicorn",
|
||||
"typescript",
|
||||
"oxc",
|
||||
"react",
|
||||
"jsx-a11y",
|
||||
"import"
|
||||
],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"dist/**",
|
||||
"coverage/**"
|
||||
],
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off"
|
||||
}
|
||||
}
|
||||
@ -9,13 +9,14 @@ server {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
location /django-admin/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
@ -23,6 +24,7 @@ server {
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
|
||||
# /admin/ et toutes les autres pages sont gérées par React.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"lint": "oxlint src vite.config.js",
|
||||
"lint:fix": "oxlint src vite.config.js --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.0",
|
||||
@ -16,11 +18,13 @@
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.3.0"
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"oxlint": "^1.76.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"vite": "^7.2.0"
|
||||
}
|
||||
|
||||
@ -1,81 +1,9 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Activity, ArrowUpRight, Blocks, Database, Moon, RefreshCw, Server, Sun } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import HomePage from "@/pages/HomePage"
|
||||
import AdminPage from "@/pages/AdminPage"
|
||||
|
||||
/** Le template garde un routeur minimal ; React Router pourra être ajouté plus tard. */
|
||||
export default function App() {
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem("theme") || "light")
|
||||
const [api, setApi] = useState({ loading: true, ok: false, message: "Connexion en cours..." })
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark")
|
||||
localStorage.setItem("theme", theme)
|
||||
}, [theme])
|
||||
|
||||
async function checkApi() {
|
||||
setApi({ loading: true, ok: false, message: "Vérification..." })
|
||||
try {
|
||||
const res = await fetch("/api/health/", { cache: "no-store" })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const body = await res.json()
|
||||
setApi({ loading: false, ok: body.status === "ok", message: body.message })
|
||||
} catch (error) {
|
||||
setApi({ loading: false, ok: false, message: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { checkApi() }, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="border-b border-border bg-card/80">
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid size-9 place-items-center rounded-xl bg-primary text-primary-foreground"><Blocks className="size-5" /></div>
|
||||
<span className="font-semibold tracking-tight">Mon Projet</span>
|
||||
<span className="hidden rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground sm:inline">Template</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button aria-label="Changer de thème" variant="ghost" size="icon" onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
|
||||
{theme === "dark" ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</Button>
|
||||
<Button variant="outline" asChild><a href="/admin/">Administration <ArrowUpRight className="size-4" /></a></Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-6xl px-5 py-14">
|
||||
<div className="mb-10 max-w-2xl">
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
<span className="size-2 rounded-full bg-emerald-500"/> Django · React · MySQL
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">Ton nouveau projet commence ici.</h1>
|
||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground">Une base propre avec Docker, API Django, React + Vite, composants shadcn et thème clair/sombre. Tu peux remplacer cette page par ton application.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader><div className="mb-2 text-primary"><Server className="size-6"/></div><CardTitle>API Django</CardTitle><CardDescription>Endpoint de vérification : /api/health/</CardDescription></CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex items-center gap-2 text-sm"><span className={`size-2.5 rounded-full ${api.loading ? "bg-amber-500" : api.ok ? "bg-emerald-500" : "bg-red-500"}`}/>{api.loading ? "Connexion..." : api.ok ? "Connectée" : "Indisponible"}</div>
|
||||
<p className="mb-4 min-h-12 break-words text-sm text-muted-foreground">{api.message}</p>
|
||||
<Button variant="outline" onClick={checkApi}><RefreshCw className="size-4"/> Réessayer</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><div className="mb-2 text-primary"><Database className="size-6"/></div><CardTitle>MySQL</CardTitle><CardDescription>MySQL 8, volume persistant et Django ORM</CardDescription></CardHeader>
|
||||
<CardContent><p className="text-sm leading-relaxed text-muted-foreground">La connexion est définie dans <code>backend/core/settings.py</code> à partir des variables <code>MYSQL_*</code>.</p></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><div className="mb-2 text-primary"><Activity className="size-6"/></div><CardTitle>Frontend</CardTitle><CardDescription>React, Vite, Tailwind CSS v4 et shadcn/ui</CardDescription></CardHeader>
|
||||
<CardContent><p className="text-sm leading-relaxed text-muted-foreground">Composants de départ prêts à personnaliser dans <code>frontend/src/components/ui/</code>.</p></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 rounded-xl border border-dashed border-border bg-card/60 p-6 text-sm text-muted-foreground">
|
||||
<strong className="text-foreground">Prochaine étape :</strong> crée ton application Django dans <code>backend/</code> et tes pages React dans <code>frontend/src/</code>.
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
return window.location.pathname === "/admin" || window.location.pathname.startsWith("/admin/")
|
||||
? <AdminPage />
|
||||
: <HomePage />
|
||||
}
|
||||
|
||||
38
frontend/src/components/admin/CreateUserDialog.jsx
Normal file
38
frontend/src/components/admin/CreateUserDialog.jsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { useState } from "react"
|
||||
import { UserPlus } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Alert } from "@/components/ui/alert"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
|
||||
export function CreateUserDialog({ onCreate }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState({ username: "", email: "", password: "" })
|
||||
const [error, setError] = useState("")
|
||||
const [busy, setBusy] = useState(false)
|
||||
const update = (name, value) => setForm((current) => ({ ...current, [name]: value }))
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
setError("")
|
||||
setBusy(true)
|
||||
try {
|
||||
await onCreate(form)
|
||||
setForm({ username: "", email: "", password: "" })
|
||||
setOpen(false)
|
||||
} catch (err) { setError(err.message) } finally { setBusy(false) }
|
||||
}
|
||||
return <Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild><Button size="sm"><UserPlus className="size-4"/> Ajouter un utilisateur</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Nouvel utilisateur</DialogTitle><DialogDescription>Crée un compte standard. Les permissions administrateur se gèrent dans Django.</DialogDescription></DialogHeader>
|
||||
<form className="space-y-4" onSubmit={submit}>
|
||||
<div className="space-y-2"><Label htmlFor="new-username">Identifiant</Label><Input id="new-username" required minLength={3} maxLength={150} autoComplete="off" value={form.username} onChange={(event) => update("username", event.target.value)}/></div>
|
||||
<div className="space-y-2"><Label htmlFor="new-email">Adresse email (facultative)</Label><Input id="new-email" type="email" value={form.email} onChange={(event) => update("email", event.target.value)}/></div>
|
||||
<div className="space-y-2"><Label htmlFor="new-password">Mot de passe</Label><Input id="new-password" type="password" required minLength={8} value={form.password} onChange={(event) => update("password", event.target.value)}/></div>
|
||||
{error && <Alert variant="destructive">{error}</Alert>}
|
||||
<div className="flex justify-end gap-2 pt-2"><Button type="button" variant="outline" onClick={() => setOpen(false)}>Annuler</Button><Button type="submit" disabled={busy}>{busy ? "Création..." : "Créer le compte"}</Button></div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
36
frontend/src/components/admin/LoginForm.jsx
Normal file
36
frontend/src/components/admin/LoginForm.jsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { useState } from "react"
|
||||
import { ArrowRight, LockKeyhole, Shield } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Alert } from "@/components/ui/alert"
|
||||
|
||||
export function LoginForm({ onLogin, error }) {
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [busy, setBusy] = useState(false)
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
try { await onLogin(username.trim(), password) } finally { setBusy(false) }
|
||||
}
|
||||
return <div className="mx-auto flex min-h-[calc(100vh-4rem)] max-w-md items-center px-4 py-12">
|
||||
<Card className="w-full shadow-lg shadow-foreground/5">
|
||||
<CardHeader className="pb-5">
|
||||
<div className="mb-4 grid size-11 place-items-center rounded-xl bg-primary/10 text-primary"><LockKeyhole className="size-5"/></div>
|
||||
<CardTitle className="text-2xl">Administration</CardTitle>
|
||||
<CardDescription>Connecte-toi avec ton compte administrateur.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-5" onSubmit={submit}>
|
||||
<div className="space-y-2"><Label htmlFor="admin-user">Nom d’utilisateur</Label><Input id="admin-user" value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" required autoFocus/></div>
|
||||
<div className="space-y-2"><Label htmlFor="admin-pass">Mot de passe</Label><Input id="admin-pass" type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" required/></div>
|
||||
{error && <Alert variant="destructive">{error}</Alert>}
|
||||
<Button className="w-full" disabled={busy} type="submit">{busy ? "Connexion..." : "Se connecter"}<ArrowRight className="size-4"/></Button>
|
||||
</form>
|
||||
<div className="mt-6 flex items-center gap-2 text-xs text-muted-foreground"><Shield className="size-4"/> Session sécurisée par Django et protection CSRF</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
21
frontend/src/components/admin/UsersTable.jsx
Normal file
21
frontend/src/components/admin/UsersTable.jsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
function initials(user) { return (user.first_name?.[0] || user.username?.[0] || "?").toUpperCase() }
|
||||
|
||||
export function UsersTable({ users, currentUser, onToggle, busyUser }) {
|
||||
if (!users.length) return <p className="px-6 py-14 text-center text-sm text-muted-foreground">Aucun utilisateur trouvé.</p>
|
||||
return <Table>
|
||||
<TableHeader><TableRow><TableHead>Utilisateur</TableHead><TableHead>Rôle</TableHead><TableHead>Statut</TableHead><TableHead className="text-right">Action</TableHead></TableRow></TableHeader>
|
||||
<TableBody>{users.map((user) => {
|
||||
const protectedUser = user.id === currentUser.id || user.is_superuser
|
||||
return <TableRow key={user.id}>
|
||||
<TableCell><div className="flex min-w-44 items-center gap-3"><span className="grid size-9 shrink-0 place-items-center rounded-full bg-primary/10 font-semibold text-primary">{initials(user)}</span><div className="min-w-0"><div className="truncate font-medium">{[user.first_name, user.last_name].filter(Boolean).join(" ") || user.username}</div><div className="truncate text-xs text-muted-foreground">{user.username}{user.email ? ` · ${user.email}` : ""}</div></div></div></TableCell>
|
||||
<TableCell><Badge variant={user.is_staff ? "default" : "secondary"}>{user.is_superuser ? "Superadmin" : user.is_staff ? "Admin" : "Utilisateur"}</Badge></TableCell>
|
||||
<TableCell><Badge variant={user.is_active ? "success" : "destructive"}><span className={`size-1.5 rounded-full ${user.is_active ? "bg-emerald-500" : "bg-red-500"}`}/>{user.is_active ? "Actif" : "Désactivé"}</Badge></TableCell>
|
||||
<TableCell className="text-right"><Button variant="outline" size="sm" disabled={protectedUser || busyUser === user.id || !currentUser.is_superuser} title={protectedUser ? "Compte protégé" : undefined} onClick={() => onToggle(user)}>{busyUser === user.id ? "Patiente..." : user.is_active ? "Désactiver" : "Réactiver"}</Button></TableCell>
|
||||
</TableRow>
|
||||
})}</TableBody>
|
||||
</Table>
|
||||
}
|
||||
26
frontend/src/components/layout/AppShell.jsx
Normal file
26
frontend/src/components/layout/AppShell.jsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { ArrowUpRight, Blocks, LayoutDashboard, Moon, Sun } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useTheme } from "@/hooks/useTheme"
|
||||
|
||||
export function AppShell({ children, admin = false }) {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
return <div className="min-h-screen bg-background text-foreground">
|
||||
<header className="sticky top-0 z-20 border-b border-border/75 bg-card/90 backdrop-blur-lg">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
|
||||
<a href="/" className="flex min-w-0 items-center gap-3 font-semibold tracking-tight">
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-primary text-primary-foreground shadow-sm"><Blocks className="size-5"/></span>
|
||||
<span className="truncate">Mon Projet</span>
|
||||
<Badge variant="secondary" className="hidden sm:inline-flex">Template</Badge>
|
||||
</a>
|
||||
<nav className="flex shrink-0 items-center gap-2" aria-label="Navigation principale">
|
||||
<Button aria-label="Changer de thème" variant="ghost" size="icon" onClick={toggleTheme} title={theme === "dark" ? "Mode clair" : "Mode sombre"}>
|
||||
{theme === "dark" ? <Sun className="size-4"/> : <Moon className="size-4"/>}
|
||||
</Button>
|
||||
{admin ? <Button asChild variant="outline" size="sm"><a href="/"><ArrowUpRight className="size-4"/> Retour au site</a></Button> : <Button asChild size="sm"><a href="/admin/"><LayoutDashboard className="size-4"/> Administration</a></Button>}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
5
frontend/src/components/ui/alert.jsx
Normal file
5
frontend/src/components/ui/alert.jsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Alert({ className, variant = "default", ...props }) {
|
||||
return <div role="alert" className={cn("rounded-lg border px-4 py-3 text-sm", variant === "destructive" ? "border-red-500/20 bg-red-500/5 text-red-700 dark:text-red-300" : "border-border bg-muted/60 text-foreground", className)} {...props} />
|
||||
}
|
||||
17
frontend/src/components/ui/badge.jsx
Normal file
17
frontend/src/components/ui/badge.jsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const variants = cva("inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary/10 text-primary",
|
||||
secondary: "border-border bg-muted text-muted-foreground",
|
||||
success: "border-emerald-500/15 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
destructive: "border-red-500/15 bg-red-500/10 text-red-700 dark:text-red-300",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
})
|
||||
export function Badge({ className, variant, ...props }) {
|
||||
return <span className={cn(variants({ variant }), className)} {...props} />
|
||||
}
|
||||
19
frontend/src/components/ui/dialog.jsx
Normal file
19
frontend/src/components/ui/dialog.jsx
Normal file
@ -0,0 +1,19 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export const Dialog = DialogPrimitive.Root
|
||||
export const DialogTrigger = DialogPrimitive.Trigger
|
||||
export const DialogClose = DialogPrimitive.Close
|
||||
export function DialogContent({ className, children, ...props }) {
|
||||
return <DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-slate-950/50 backdrop-blur-[2px] data-[state=open]:animate-in" />
|
||||
<DialogPrimitive.Content className={cn("fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-card p-6 text-card-foreground shadow-2xl outline-none", className)} {...props}>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground" aria-label="Fermer"><X className="size-4" /></DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
}
|
||||
export function DialogHeader({ className, ...props }) { return <div className={cn("mb-5 space-y-1.5", className)} {...props} /> }
|
||||
export function DialogTitle({ className, ...props }) { return <DialogPrimitive.Title className={cn("text-lg font-semibold tracking-tight", className)} {...props} /> }
|
||||
export function DialogDescription({ className, ...props }) { return <DialogPrimitive.Description className={cn("text-sm text-muted-foreground", className)} {...props} /> }
|
||||
5
frontend/src/components/ui/input.jsx
Normal file
5
frontend/src/components/ui/input.jsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Input({ className, type = "text", ...props }) {
|
||||
return <input data-slot="input" type={type} className={cn("flex h-10 w-full min-w-0 rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50", className)} {...props} />
|
||||
}
|
||||
5
frontend/src/components/ui/label.jsx
Normal file
5
frontend/src/components/ui/label.jsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Label({ className, ...props }) {
|
||||
return <label className={cn("text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)} {...props} />
|
||||
}
|
||||
5
frontend/src/components/ui/separator.jsx
Normal file
5
frontend/src/components/ui/separator.jsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Separator({ className, orientation = "horizontal", ...props }) {
|
||||
return <div role="separator" aria-orientation={orientation} className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-px w-full" : "h-full w-px", className)} {...props} />
|
||||
}
|
||||
8
frontend/src/components/ui/table.jsx
Normal file
8
frontend/src/components/ui/table.jsx
Normal file
@ -0,0 +1,8 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Table({ className, ...props }) { return <div className="relative w-full overflow-x-auto"><table className={cn("w-full caption-bottom text-sm", className)} {...props} /></div> }
|
||||
export function TableHeader({ className, ...props }) { return <thead className={cn("[&_tr]:border-b", className)} {...props} /> }
|
||||
export function TableBody({ className, ...props }) { return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} /> }
|
||||
export function TableRow({ className, ...props }) { return <tr className={cn("border-b border-border transition-colors hover:bg-muted/35", className)} {...props} /> }
|
||||
export function TableHead({ className, ...props }) { return <th className={cn("h-11 whitespace-nowrap px-4 text-left align-middle text-xs font-medium text-muted-foreground", className)} {...props} /> }
|
||||
export function TableCell({ className, ...props }) { return <td className={cn("p-4 align-middle", className)} {...props} /> }
|
||||
17
frontend/src/hooks/useHealth.js
Normal file
17
frontend/src/hooks/useHealth.js
Normal file
@ -0,0 +1,17 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { healthService } from "@/services/healthService"
|
||||
|
||||
export function useHealth() {
|
||||
const [health, setHealth] = useState({ loading: true, ok: false, message: "Connexion en cours..." })
|
||||
const refresh = useCallback(async () => {
|
||||
setHealth((current) => ({ ...current, loading: true }))
|
||||
try {
|
||||
const body = await healthService.check()
|
||||
setHealth({ loading: false, ok: body.status === "ok", message: body.message || "Serveur disponible" })
|
||||
} catch (error) {
|
||||
setHealth({ loading: false, ok: false, message: error.message })
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
return { health, refresh }
|
||||
}
|
||||
10
frontend/src/hooks/useTheme.js
Normal file
10
frontend/src/hooks/useTheme.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem("theme") || "light")
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark")
|
||||
localStorage.setItem("theme", theme)
|
||||
}, [theme])
|
||||
return { theme, toggleTheme: () => setTheme((current) => current === "dark" ? "light" : "dark") }
|
||||
}
|
||||
@ -8,39 +8,61 @@
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-ring: var(--ring);
|
||||
--color-destructive: var(--destructive);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(.982 .007 86);
|
||||
--foreground: oklch(.23 .027 255);
|
||||
color-scheme: light;
|
||||
--background: oklch(.982 .006 260);
|
||||
--foreground: oklch(.22 .028 260);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: var(--foreground);
|
||||
--primary: oklch(.49 .119 262);
|
||||
--popover: var(--card);
|
||||
--popover-foreground: var(--foreground);
|
||||
--primary: oklch(.50 .14 259);
|
||||
--primary-foreground: oklch(.99 0 0);
|
||||
--muted: oklch(.959 .010 85);
|
||||
--muted-foreground: oklch(.52 .025 254);
|
||||
--border: oklch(.90 .013 251);
|
||||
--secondary: oklch(.955 .012 260);
|
||||
--secondary-foreground: var(--foreground);
|
||||
--muted: oklch(.957 .009 260);
|
||||
--muted-foreground: oklch(.52 .028 260);
|
||||
--input: oklch(.89 .013 260);
|
||||
--border: oklch(.91 .012 260);
|
||||
--ring: var(--primary);
|
||||
--destructive: oklch(.56 .19 24);
|
||||
}
|
||||
.dark {
|
||||
--background: oklch(.17 .021 256);
|
||||
--foreground: oklch(.97 .010 255);
|
||||
--card: oklch(.22 .022 255);
|
||||
color-scheme: dark;
|
||||
--background: oklch(.16 .020 260);
|
||||
--foreground: oklch(.96 .010 260);
|
||||
--card: oklch(.205 .022 260);
|
||||
--card-foreground: var(--foreground);
|
||||
--primary: oklch(.70 .125 255);
|
||||
--primary-foreground: oklch(.18 .022 255);
|
||||
--muted: oklch(.27 .02 255);
|
||||
--muted-foreground: oklch(.73 .018 255);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--popover: var(--card);
|
||||
--popover-foreground: var(--foreground);
|
||||
--primary: oklch(.73 .132 255);
|
||||
--primary-foreground: oklch(.17 .018 260);
|
||||
--secondary: oklch(.29 .026 260);
|
||||
--secondary-foreground: var(--foreground);
|
||||
--muted: oklch(.265 .020 260);
|
||||
--muted-foreground: oklch(.73 .025 260);
|
||||
--input: oklch(1 0 0 / 16%);
|
||||
--border: oklch(1 0 0 / 11%);
|
||||
--ring: var(--primary);
|
||||
--destructive: oklch(.7 .16 22);
|
||||
}
|
||||
@layer base {
|
||||
*, ::before, ::after { box-sizing: border-box; border-color: var(--border); }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; background: var(--background); color: var(--foreground); font-family: var(--font-sans); }
|
||||
button, input { font: inherit; }
|
||||
}
|
||||
|
||||
129
frontend/src/pages/AdminPage.jsx
Normal file
129
frontend/src/pages/AdminPage.jsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Activity, ArrowUpRight, Database, LayoutDashboard, LogOut, RefreshCw, Search, ShieldCheck, Users } from "lucide-react"
|
||||
import { AppShell } from "@/components/layout/AppShell"
|
||||
import { LoginForm } from "@/components/admin/LoginForm"
|
||||
import { CreateUserDialog } from "@/components/admin/CreateUserDialog"
|
||||
import { UsersTable } from "@/components/admin/UsersTable"
|
||||
import { Alert } from "@/components/ui/alert"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { authService } from "@/services/authService"
|
||||
import { adminService } from "@/services/adminService"
|
||||
|
||||
export default function AdminPage() {
|
||||
const [status, setStatus] = useState("loading")
|
||||
const [user, setUser] = useState(null)
|
||||
const [data, setData] = useState(null)
|
||||
const [message, setMessage] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [loadingData, setLoadingData] = useState(false)
|
||||
const [busyUser, setBusyUser] = useState(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoadingData(true)
|
||||
try { setData(await adminService.dashboard()); setError("") }
|
||||
catch (err) { setError(err.message) }
|
||||
finally { setLoadingData(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
authService.me().then(async ({ user: loggedUser }) => {
|
||||
if (!mounted) return
|
||||
if (!loggedUser.is_staff) { setStatus("forbidden"); return }
|
||||
setUser(loggedUser)
|
||||
setStatus("ready")
|
||||
}).catch((err) => {
|
||||
if (!mounted) return
|
||||
setStatus(err.status === 401 ? "login" : "error")
|
||||
setError(err.status === 401 ? "" : err.message)
|
||||
})
|
||||
return () => { mounted = false }
|
||||
}, [])
|
||||
useEffect(() => { if (status === "ready") reload() }, [status, reload])
|
||||
|
||||
async function handleLogin(username, password) {
|
||||
setError("")
|
||||
try {
|
||||
const result = await authService.login(username, password)
|
||||
setUser(result.user)
|
||||
setStatus("ready")
|
||||
} catch (err) { setError(err.message) }
|
||||
}
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await authService.logout()
|
||||
setUser(null); setData(null); setStatus("login"); setMessage(""); setError("")
|
||||
} catch (err) { setError(err.message) }
|
||||
}
|
||||
async function handleCreate(form) {
|
||||
await adminService.createUser(form)
|
||||
setMessage("Utilisateur créé avec succès.")
|
||||
await reload()
|
||||
}
|
||||
async function handleToggle(target) {
|
||||
setBusyUser(target.id)
|
||||
setMessage("")
|
||||
try {
|
||||
await adminService.setUserActive(target.id, !target.is_active)
|
||||
setMessage(target.is_active ? "Compte désactivé." : "Compte réactivé.")
|
||||
await reload()
|
||||
} catch (err) { setError(err.message) }
|
||||
finally { setBusyUser(null) }
|
||||
}
|
||||
const filtered = useMemo(() => (data?.users || []).filter((item) => [item.username, item.first_name, item.last_name, item.email].join(" ").toLowerCase().includes(search.toLowerCase().trim())), [data, search])
|
||||
|
||||
return <AppShell admin>
|
||||
{status === "loading" && <div className="mx-auto flex min-h-[70vh] items-center justify-center gap-3 text-sm text-muted-foreground"><RefreshCw className="size-4 animate-spin"/> Chargement de la session...</div>}
|
||||
{status === "login" && <LoginForm onLogin={handleLogin} error={error}/>}
|
||||
{status === "forbidden" && <main className="mx-auto max-w-xl px-5 py-20"><Alert variant="destructive">Ce compte ne dispose pas des droits d’administration.</Alert></main>}
|
||||
{status === "error" && <main className="mx-auto max-w-xl space-y-4 px-5 py-20"><Alert variant="destructive">{error}</Alert><Button onClick={() => window.location.reload()}>Réessayer</Button></main>}
|
||||
{status === "ready" && <div className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-8 sm:px-6 lg:flex-row lg:py-10">
|
||||
<aside className="lg:w-56 lg:shrink-0">
|
||||
<div className="mb-3 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Administration</div>
|
||||
<div className="flex gap-2 lg:flex-col">
|
||||
<a href="#vue-ensemble" className="flex items-center gap-2 rounded-lg bg-primary/10 px-3 py-2.5 text-sm font-medium text-primary"><LayoutDashboard className="size-4"/> Vue d’ensemble</a>
|
||||
<a href="#utilisateurs" className="flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground"><Users className="size-4"/> Utilisateurs</a>
|
||||
</div>
|
||||
<Separator className="my-5 hidden lg:block"/>
|
||||
<div className="hidden px-3 lg:block"><p className="text-sm font-medium">{user.first_name || user.username}</p><p className="mt-1 text-xs text-muted-foreground">{user.is_superuser ? "Superadministrateur" : "Administrateur"}</p></div>
|
||||
<Button variant="ghost" size="sm" className="mt-3 hidden w-full justify-start text-muted-foreground lg:flex" onClick={handleLogout}><LogOut className="size-4"/> Déconnexion</Button>
|
||||
</aside>
|
||||
<main className="min-w-0 flex-1 space-y-7">
|
||||
<section id="vue-ensemble" className="space-y-2">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><Badge variant="secondary">Espace administrateur</Badge><h1 className="mt-3 text-3xl font-semibold tracking-tight">Vue d’ensemble</h1><p className="mt-2 text-sm text-muted-foreground">Gère les comptes et consulte l’activité générale de ton application.</p></div>
|
||||
<div className="flex gap-2"><Button variant="outline" size="sm" disabled={loadingData} onClick={reload}><RefreshCw className={`size-4 ${loadingData ? "animate-spin" : ""}`}/> Actualiser</Button><Button variant="ghost" size="sm" className="lg:hidden" onClick={handleLogout}><LogOut className="size-4"/> Quitter</Button></div>
|
||||
</div>
|
||||
</section>
|
||||
{error && <Alert variant="destructive">{error}</Alert>}
|
||||
{message && <Alert>{message}</Alert>}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<StatCard icon={Users} label="Utilisateurs" value={data?.stats.total} helper="Comptes enregistrés"/>
|
||||
<StatCard icon={Activity} label="Actifs" value={data?.stats.active} helper="Accès autorisés"/>
|
||||
<StatCard icon={ShieldCheck} label="Administrateurs" value={data?.stats.staff} helper="Comptes avec accès admin"/>
|
||||
</div>
|
||||
<Card id="utilisateurs" className="overflow-hidden">
|
||||
<CardHeader className="gap-4 border-b border-border pb-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><CardTitle className="text-lg">Utilisateurs</CardTitle><CardDescription>Comptes et accès à l’application</CardDescription></div>
|
||||
{user.is_superuser && <CreateUserDialog onCreate={handleCreate}/>}
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="p-4 sm:px-6"><div className="relative max-w-sm"><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/><Input className="pl-9" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Rechercher un utilisateur..." aria-label="Rechercher un utilisateur"/></div></div>
|
||||
<UsersTable users={filtered} currentUser={user} onToggle={handleToggle} busyUser={busyUser}/>
|
||||
<div className="border-t border-border bg-muted/20 px-6 py-3 text-xs text-muted-foreground">{filtered.length} compte(s) affiché(s) · Les comptes sensibles sont protégés.</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-dashed shadow-none"><CardContent className="flex flex-wrap items-center justify-between gap-4 py-5"><div className="flex items-center gap-3"><Database className="size-5 text-primary"/><div><p className="text-sm font-medium">Administration Django avancée</p><p className="text-xs text-muted-foreground">Groupes, permissions et modèles Django</p></div></div><Button size="sm" variant="outline" asChild><a href="/django-admin/">Ouvrir <ArrowUpRight className="size-4"/></a></Button></CardContent></Card>
|
||||
</main>
|
||||
</div>}
|
||||
</AppShell>
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, helper }) {
|
||||
return <Card><CardContent className="flex items-start justify-between gap-3 p-5"><div><p className="text-sm font-medium text-muted-foreground">{label}</p><div className="mt-3 text-3xl font-semibold tabular-nums tracking-tight">{value ?? "—"}</div><p className="mt-2 text-xs text-muted-foreground">{helper}</p></div><span className="grid size-10 place-items-center rounded-xl bg-primary/10 text-primary"><Icon className="size-5"/></span></CardContent></Card>
|
||||
}
|
||||
45
frontend/src/pages/HomePage.jsx
Normal file
45
frontend/src/pages/HomePage.jsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Activity, ArrowRight, Code2, Database, RefreshCw, Server, ShieldCheck } from "lucide-react"
|
||||
import { AppShell } from "@/components/layout/AppShell"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useHealth } from "@/hooks/useHealth"
|
||||
|
||||
export default function HomePage() {
|
||||
const { health, refresh } = useHealth()
|
||||
return <AppShell>
|
||||
<main className="mx-auto max-w-7xl space-y-10 px-4 py-12 sm:px-6 sm:py-16">
|
||||
<section className="relative overflow-hidden rounded-3xl border border-border bg-card px-7 py-10 shadow-sm sm:px-12 sm:py-14">
|
||||
<div className="pointer-events-none absolute -right-28 -top-44 size-96 rounded-full bg-primary/8 blur-3xl" aria-hidden="true"/>
|
||||
<div className="relative max-w-2xl">
|
||||
<Badge variant="default"><Code2 className="size-3.5"/> Django + React + MySQL</Badge>
|
||||
<h1 className="mt-6 text-3xl font-semibold tracking-tight sm:text-5xl">Une base propre pour tes prochains projets.</h1>
|
||||
<p className="mt-5 max-w-xl text-base leading-relaxed text-muted-foreground">Docker Compose, Django REST Framework, React avec shadcn/ui, MySQL 8 et Oxlint. Une architecture modulaire, prête à être personnalisée.</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Button asChild><a href="/admin/">Ouvrir l’administration <ArrowRight className="size-4"/></a></Button>
|
||||
<Button variant="outline" asChild><a href="/django-admin/">Django avancé <ArrowRight className="size-4"/></a></Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section aria-label="Services du template" className="grid gap-5 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader><Server className="mb-3 size-5 text-primary"/><CardTitle>API Django</CardTitle><CardDescription>Connexion avec le backend</CardDescription></CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-3 flex items-center gap-2"><span className={`size-2.5 rounded-full ${health.loading ? "bg-amber-500" : health.ok ? "bg-emerald-500" : "bg-red-500"}`}/><span className="text-sm font-medium">{health.loading ? "Vérification" : health.ok ? "Connectée" : "Indisponible"}</span></div>
|
||||
<p className="mb-5 min-h-10 break-words text-sm text-muted-foreground">{health.message}</p>
|
||||
<Button variant="outline" size="sm" disabled={health.loading} onClick={refresh}><RefreshCw className={`size-4 ${health.loading ? "animate-spin" : ""}`}/> Actualiser</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><Database className="mb-3 size-5 text-primary"/><CardTitle>MySQL 8</CardTitle><CardDescription>Volume persistant et ORM Django</CardDescription></CardHeader>
|
||||
<CardContent><p className="text-sm leading-relaxed text-muted-foreground">La configuration est centralisée dans le fichier <code className="font-medium text-foreground">.env</code>, sans identifiants en dur dans le code.</p></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><Activity className="mb-3 size-5 text-primary"/><CardTitle>React & shadcn/ui</CardTitle><CardDescription>Interface réutilisable et responsive</CardDescription></CardHeader>
|
||||
<CardContent><p className="text-sm leading-relaxed text-muted-foreground">Pages, composants, hooks et services séparés pour faire évoluer ton projet facilement.</p></CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/40 p-4 text-sm text-muted-foreground"><ShieldCheck className="size-5 shrink-0 text-primary"/><span>Pour commencer, ajoute tes modules Django dans <code>backend/</code> et tes nouvelles pages React dans <code>frontend/src/pages/</code>.</span></div>
|
||||
</main>
|
||||
</AppShell>
|
||||
}
|
||||
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" }),
|
||||
}
|
||||
5
frontend/src/services/healthService.js
Normal file
5
frontend/src/services/healthService.js
Normal file
@ -0,0 +1,5 @@
|
||||
import { apiRequest } from "@/services/apiClient"
|
||||
|
||||
export const healthService = {
|
||||
check: () => apiRequest("health/"),
|
||||
}
|
||||
@ -1,17 +1,24 @@
|
||||
import { defineConfig } from "vite"
|
||||
import { defineConfig, loadEnv } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath, URL } from "node:url"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:1029", changeOrigin: true },
|
||||
"/admin": { target: "http://localhost:1029", changeOrigin: true },
|
||||
"/static": { target: "http://localhost:1029", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Variable fournie par start.sh, ou par un .env local au frontend.
|
||||
const env = loadEnv(mode, process.cwd(), "")
|
||||
const backendPort = process.env.BACKEND_PORT || env.BACKEND_PORT || "1029"
|
||||
const proxyTarget = `http://127.0.0.1:${backendPort}`
|
||||
const proxy = Object.fromEntries(
|
||||
["/api", "/django-admin", "/static"].map((path) => [
|
||||
path,
|
||||
{ target: proxyTarget, changeOrigin: true },
|
||||
]),
|
||||
)
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
|
||||
server: { host: "0.0.0.0", proxy },
|
||||
preview: { host: "0.0.0.0", proxy },
|
||||
}
|
||||
})
|
||||
|
||||
100
start.sh
Normal file
100
start.sh
Normal file
@ -0,0 +1,100 @@
|
||||
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cd /home/container
|
||||
|
||||
echo "======================================"
|
||||
echo "Template"
|
||||
echo "======================================"
|
||||
|
||||
# --------------------------------------
|
||||
# Mise à jour du projet
|
||||
# --------------------------------------
|
||||
|
||||
if [ ! -d ".git" ]; then
|
||||
echo "Dépôt Git absent → clonage..."
|
||||
|
||||
git clone https://gitea.louismazin.ovh/LouisMazin/GooDrive.git /tmp/goodrive
|
||||
|
||||
cp -a /tmp/goodrive/. /home/container/
|
||||
rm -rf /tmp/goodrive
|
||||
else
|
||||
echo "Mise à jour du dépôt Git..."
|
||||
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
fi
|
||||
|
||||
echo "Git : $(git rev-parse --short HEAD)"
|
||||
|
||||
# --------------------------------------
|
||||
# Backend
|
||||
# --------------------------------------
|
||||
|
||||
cd /home/container/backend
|
||||
|
||||
python3 manage.py check
|
||||
|
||||
echo "Démarrage Django sur :1035"
|
||||
|
||||
python3 manage.py migrate --noinput
|
||||
|
||||
python manage.py ensure_admin
|
||||
|
||||
# Purge au démarrage puis une fois par heure (éléments de plus de 5 jours).
|
||||
trash_cleanup_loop() {
|
||||
while true; do
|
||||
python3 manage.py cleanup_trash --days 5 || echo "Échec du nettoyage de la corbeille (nouvel essai dans 1 heure)." >&2
|
||||
sleep 3600
|
||||
done
|
||||
}
|
||||
trash_cleanup_loop &
|
||||
CLEANER_PID=$!
|
||||
|
||||
python3 manage.py runserver 0.0.0.0:1035 &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# --------------------------------------
|
||||
# Frontend
|
||||
# --------------------------------------
|
||||
|
||||
cd /home/container/frontend
|
||||
|
||||
echo "Installation des dépendances frontend..."
|
||||
npm ci
|
||||
|
||||
|
||||
echo "Build React..."
|
||||
|
||||
npm run build
|
||||
|
||||
echo "Démarrage frontend sur :1034"
|
||||
|
||||
npm run preview -- --host 0.0.0.0 --port 1034 &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
# --------------------------------------
|
||||
# Arrêt propre
|
||||
# --------------------------------------
|
||||
|
||||
cleanup() {
|
||||
echo "Arrêt des applications..."
|
||||
|
||||
kill "$BACKEND_PID" 2>/dev/null || true
|
||||
kill "$FRONTEND_PID" 2>/dev/null || true
|
||||
kill "$CLEANER_PID" 2>/dev/null || true
|
||||
|
||||
wait "$BACKEND_PID" 2>/dev/null || true
|
||||
wait "$FRONTEND_PID" 2>/dev/null || true
|
||||
wait "$CLEANER_PID" 2>/dev/null || true
|
||||
}
|
||||
|
||||
trap cleanup SIGTERM SIGINT EXIT
|
||||
|
||||
echo "======================================"
|
||||
echo " Frontend : :1034"
|
||||
echo " Backend : :1035"
|
||||
echo "======================================"
|
||||
|
||||
wait -n "$BACKEND_PID" "$FRONTEND_PID"
|
||||
Loading…
x
Reference in New Issue
Block a user