Files
donut-gestprojet/public/index.php
T
Arnaud adb2bd3fc1 feat: galerie d'images projet (upload disque) + dashboard /admin
==== Galerie d'images ====

Backend
  - Migration 003 : table project_images (FK CASCADE sur projects)
  - ImagesController : CRUD complet, deux modes de création
      * URL externe via JSON body
      * Upload disque via multipart/form-data avec :
          - validation MIME RÉELLE via finfo (jpeg/png/webp/gif)
          - taille max 5 Mo, vérifiée explicitement (au-delà du php.ini)
          - vérification UPLOAD_ERR_* avec messages clairs
          - stockage dans public/uploads/projects/:slug/<hash>.<ext>
          - dimensions image extraites via getimagesize (best-effort)
          - row DB avec mime_type / size_bytes / width / height
  - Request : ajout isMultipart(), formField(), file()
  - public/index.php : 3 routes (index public, store/update/destroy admin)

Proxy
  - caddy.conf : /uploads/* ajouté au reverse_proxy comme /api/*
  - vite.config.ts : pareil pour le dev local

Frontend
  - Type ProjectImage (api.ts) avec url + storage_path + dimensions
  - uploadFile() helper dans client.ts (FormData + Bearer)
  - ImageGallery.vue : grille thumbnails + lightbox plein-écran avec
    nav clavier (← → Esc), bouton supprimer par image en mode admin
  - ImageUploader.vue : drag-drop + file picker, multi-fichiers en série,
    validation taille côté client, légende commune optionnelle
  - ProjectDetailPage : nouvel onglet Images entre Roadmap et Discussions

==== Dashboard /admin ====

5 nouvelles pages sous /admin :
  - /admin                           : KPI + répartition catégorie + flux activité
  - /admin/projects                  : table CRUD (recherche + filtres status/categorie),
                                       actions Éditer / Archiver / Supprimer par ligne
  - /admin/projects/:slug/edit       : form complet d'édition (envoie un diff au PATCH)
  - /admin/members                   : annuaire avec recherche + Supprimer
  - /admin/categories                : table avec compte de projets ;
                                       Supprimer désactivé si la catégorie est utilisée
  - AdminSubNav.vue                  : 4 onglets en haut de chaque page admin

Garde d'auth :
  - beforeEnter sur les routes /admin/* (sauf /admin/login)
  - Redirige vers /admin/login?next=<url> si pas de token
  - Après login, retour automatique vers next= (sinon /admin)

Sidebar :
  - Nouvelle section "Administration" visible UNIQUEMENT quand le token
    est en localStorage, avec un lien vers le dashboard

Vérifié localement
  - Build vue-tsc : 91 modules, OK
  - Upload disque : fichier PNG stocké dans public/uploads/, accessible via /uploads/
  - GET /api/projects/nos-ecoles/images : 2 entrées renvoyées (1 URL + 1 disque)
  - Toutes les routes /admin/* répondent 200 via le proxy Vite

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:07:59 +02:00

133 lines
6.6 KiB
PHP

<?php
declare(strict_types=1);
// Front controller unique. Toutes les requêtes (sauf fichiers statiques
// existants côté Apache) tombent ici via la réécriture .htaccess.
require __DIR__ . '/../src/bootstrap.php';
use Infolab\Router;
use Infolab\Request;
use Infolab\Response;
use Infolab\Auth;
use Infolab\Controllers\HealthController;
use Infolab\Controllers\CategoriesController;
use Infolab\Controllers\MembersController;
use Infolab\Controllers\ProjectsController;
use Infolab\Controllers\ProjectMembersController;
use Infolab\Controllers\RoadmapController;
use Infolab\Controllers\DiscussionsController;
use Infolab\Controllers\ResourcesController;
use Infolab\Controllers\ActivityController;
use Infolab\Controllers\StatsController;
use Infolab\Controllers\ImagesController;
// CORS systématique, y compris sur la preflight OPTIONS.
Response::sendCorsHeaders();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
http_response_code(204);
return;
}
// On instancie Request DANS le try : le parsing JSON peut lever ApiException 400
// si le body est invalide, et on veut une réponse propre plutôt qu'une fatal error.
$router = new Router();
// ----------------------------------------------------------------------------
// Routes publiques (GET, pas de token).
// ----------------------------------------------------------------------------
$router->get('/api/health', [HealthController::class, 'index']);
$router->get('/api/categories', [CategoriesController::class, 'index']);
$router->get('/api/categories/:slug', [CategoriesController::class, 'show']);
$router->get('/api/members', [MembersController::class, 'index']);
$router->get('/api/members/:slug', [MembersController::class, 'show']);
$router->get('/api/projects', [ProjectsController::class, 'index']);
$router->get('/api/projects/:slug', [ProjectsController::class, 'show']);
$router->get('/api/projects/:slug/roadmap', [RoadmapController::class, 'index']);
$router->get('/api/projects/:slug/members', [ProjectMembersController::class, 'index']);
$router->get('/api/projects/:slug/discussions', [DiscussionsController::class, 'index']);
$router->get('/api/projects/:slug/resources', [ResourcesController::class, 'index']);
$router->get('/api/projects/:slug/images', [ImagesController::class, 'index']);
$router->get('/api/activity', [ActivityController::class, 'index']);
$router->get('/api/stats', [StatsController::class, 'index']);
// ----------------------------------------------------------------------------
// Écritures (POST/PATCH/DELETE) — protégées automatiquement par Bearer token
// (le Router marque ces méthodes comme `auth: true`).
// ----------------------------------------------------------------------------
$router->post ('/api/categories', [CategoriesController::class, 'store']);
$router->patch ('/api/categories/:slug', [CategoriesController::class, 'update']);
$router->delete('/api/categories/:slug', [CategoriesController::class, 'destroy']);
$router->post ('/api/members', [MembersController::class, 'store']);
$router->patch ('/api/members/:slug', [MembersController::class, 'update']);
$router->delete('/api/members/:slug', [MembersController::class, 'destroy']);
$router->post ('/api/projects', [ProjectsController::class, 'store']);
$router->patch ('/api/projects/:slug', [ProjectsController::class, 'update']);
$router->delete('/api/projects/:slug', [ProjectsController::class, 'destroy']);
$router->post ('/api/projects/:slug/members', [ProjectMembersController::class, 'store']);
$router->delete('/api/projects/:slug/members/:member_slug', [ProjectMembersController::class, 'destroy']);
$router->post ('/api/projects/:slug/roadmap', [RoadmapController::class, 'store']);
$router->patch ('/api/projects/:slug/roadmap/:id', [RoadmapController::class, 'update']);
$router->delete('/api/projects/:slug/roadmap/:id', [RoadmapController::class, 'destroy']);
$router->post ('/api/projects/:slug/discussions', [DiscussionsController::class, 'store']);
$router->patch ('/api/projects/:slug/discussions/:id', [DiscussionsController::class, 'update']);
$router->delete('/api/projects/:slug/discussions/:id', [DiscussionsController::class, 'destroy']);
$router->post ('/api/projects/:slug/resources', [ResourcesController::class, 'store']);
$router->patch ('/api/projects/:slug/resources/:id', [ResourcesController::class, 'update']);
$router->delete('/api/projects/:slug/resources/:id', [ResourcesController::class, 'destroy']);
$router->post ('/api/projects/:slug/images', [ImagesController::class, 'store']);
$router->patch ('/api/projects/:slug/images/:id', [ImagesController::class, 'update']);
$router->delete('/api/projects/:slug/images/:id', [ImagesController::class, 'destroy']);
// ----------------------------------------------------------------------------
// Dispatch + handler global d'exceptions.
// ----------------------------------------------------------------------------
try {
$request = new Request();
[$route, $params] = $router->dispatch($request->method(), $request->path());
if ($route['auth']) {
Auth::requireAdmin($request);
}
[$class, $action] = $route['handler'];
$controller = new $class();
$controller->$action($request, $params);
} catch (ApiException $e) {
Response::emit($e->statusCode, $e->payload);
} catch (\Throwable $e) {
// Toujours loggé vers stderr (visible dans Dozzle côté serveur).
error_log(sprintf(
'[unhandled] %s in %s:%d — %s',
$e::class,
$e->getFile(),
$e->getLine(),
$e->getMessage()
));
$debug = (getenv('APP_DEBUG') ?: 'false') === 'true';
if ($debug) {
Response::emit(500, [
'error' => 'internal_server_error',
'message' => $e->getMessage(),
'class' => $e::class,
'file' => $e->getFile(),
'line' => $e->getLine(),
]);
} else {
Response::emit(500, ['error' => 'internal_server_error']);
}
}