689dfeee14
Backend
- ProjectMembersController::update : nouveau PATCH /api/projects/:slug/members/:member_slug
pour changer le rôle (admin/contributor/observer). 404 si pivot absent,
no-op idempotent si déjà même rôle, 422 si rôle invalide. Route câblée
dans public/index.php.
Frontend
- JoinProjectModal : 2 onglets (Membre existant / Nouveau membre)
* "existant" : <select> de l'annuaire filtré sur excludeSlugs (membres
déjà sur le projet), preview avatar+bio du membre sélectionné
* "nouveau" : flow d'origine (créer le membre puis l'attacher)
* sélecteur de rôle visible dans les 2 modes
* onJoined remonte un Member complet (au lieu de {id,slug,name})
-> ProjectDetailPage peut pousser un ProjectMemberEntry avec ses
vraies initials/avatar/kind dans la liste optimiste
- ProjectDetailPage panneau Équipe (mode admin uniquement) :
* Rôle inline éditable via <select> stylé minimal -> PATCH optimiste
avec rollback en cas d'échec
* Bouton retirer-membre par ligne (DELETE) avec confirm
* Le badge "Vous" / "Admin connecté" ne change pas
Code-review (high effort, --fix appliqué) :
- loadDirectory affiche maintenant une vraie erreur réseau au lieu de
silencieusement présenter "tous les membres déjà sur ce projet"
- loadDirectory est lazy : on ne fetche l'annuaire que si l'onglet
"existant" est actif (économise 1 requête si admin saute direct à "nouveau")
- Aperçu du membre sélectionné : computed `selectedMember` au lieu de
<template v-for><template v-if>
- Bouton retirer-membre : opacity-60 sur mobile (sm: et plus garde le
behavior hover-to-reveal) -> reste atteignable sur touch
- + label `aria-label` pour l'accessibilité
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
134 lines
6.7 KiB
PHP
134 lines
6.7 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->patch ('/api/projects/:slug/members/:member_slug', [ProjectMembersController::class, 'update']);
|
|
$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']);
|
|
}
|
|
}
|