adb2bd3fc1
==== 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>
133 lines
6.6 KiB
PHP
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']);
|
|
}
|
|
}
|