From b063312642c5a52f45db59e2d39b02280519cab7 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Tue, 5 May 2026 18:27:47 +0200 Subject: [PATCH] centralise repertoire filter column rendering - shared repFilterEntry() and config array - shared repFilterEntry() and $filterColumns config array - fix single-valued FK fading via full intersection --- TODO.md | 12 ++ app/public/admin/index.php | 55 +++--- app/public/assets/css/common.css | 3 +- app/src/Database.php | 29 ++- app/storage/logs/admin.log | 1 + app/templates/partials/repertoire-index.php | 198 ++++++-------------- 6 files changed, 121 insertions(+), 177 deletions(-) diff --git a/TODO.md b/TODO.md index 134dd7b..ace66ef 100644 --- a/TODO.md +++ b/TODO.md @@ -75,6 +75,18 @@ - [x] `templates/admin/edit.php` — moved `.admin-form-footer` from bottom to top-right, right after `

` - [x] `admin.css` — added `.admin-form-footer--sticky` variant with `position:sticky; top:0; justify-content:flex-end` +## Fix CSV importer robustness +- [x] Pad rows to expected column count to avoid offset warnings from short rows +- [x] Distinguish `$yearRaw !== ''` before `intval()` to handle empty-year rows correctly +- [x] Improve missing-field error message: lists which fields are missing, includes identifier/title snippet + +## Standardise répertoire filter column rendering +- [x] Centralise filter column rendering into a shared `repFilterEntry()` function +- [x] Define `$filterColumns` config array as single source of truth for the 5 filter columns +- [x] All columns (years, ap, or, fi, kw) now share identical fade/select/HTMX logic via the same code path +- [x] Fix single-valued FK columns (years, ap, or, fi): matched entries now use full intersection so clicking one entry correctly fades others with zero results +- [x] Fix column ordering: students between finalité and mots-clés + ## Standardise buttons with .btn base class - [x] Create `.btn` base class in common.css: `border-radius: 10px; padding: var(--space-xs)` + background + cursor - [x] Add `.btn--primary` (accent bg), `.btn--secondary` (--bg bg + border), `.btn--sm`, `.btn--lg`, `.btn--danger`, `.btn--warning`, `.btn--success`, `.btn--ghost`, `.btn--muted`, `.btn--blue`, `.btn--yellow`, `.btn--green`, `.btn--red` modifiers diff --git a/app/public/admin/index.php b/app/public/admin/index.php index b073de3..7573e6f 100644 --- a/app/public/admin/index.php +++ b/app/public/admin/index.php @@ -139,29 +139,40 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) { try { $importDb->beginTransaction(); - $identifier = trim($row[0] ?? ''); - $title = trim($row[1] ?? ''); - $subtitle = trim($row[2] ?? ''); - $authorsRaw = trim($row[3] ?? ''); - $contact = trim($row[4] ?? ''); - $supervisorsRaw = trim($row[5] ?? ''); - $formatsRaw = trim($row[6] ?? ''); - $year = intval($row[7] ?? 0); - $apCode = trim($row[8] ?? ''); - $orientationCode = trim($row[9] ?? ''); - $finalityName = trim($row[10] ?? ''); - $keywordsRaw = trim($row[11] ?? ''); - $synopsis = trim($row[12] ?? ''); - $context = trim($row[13] ?? ''); - $remarks = trim($row[14] ?? ''); - $languageRaw = trim($row[15] ?? ''); - $access = trim($row[16] ?? ''); - $license = trim($row[17] ?? ''); - $sizeInfo = trim($row[18] ?? ''); - $juryPoints = !empty($row[19]) ? floatval($row[19]) : null; - $baiuLink = trim($row[20] ?? ''); + // Pad row to expected column count to avoid offset warnings. + $expectedCols = 21; + while (count($row) < $expectedCols) $row[] = ''; - if (empty($title) || empty($year)) throw new Exception("Titre et année requis."); + $identifier = trim($row[0]); + $title = trim($row[1]); + $subtitle = trim($row[2]); + $authorsRaw = trim($row[3]); + $contact = trim($row[4]); + $supervisorsRaw = trim($row[5]); + $formatsRaw = trim($row[6]); + $yearRaw = trim($row[7]); + $year = $yearRaw !== '' ? intval($yearRaw) : 0; + $apCode = trim($row[8]); + $orientationCode = trim($row[9]); + $finalityName = trim($row[10]); + $keywordsRaw = trim($row[11]); + $synopsis = trim($row[12]); + $context = trim($row[13]); + $remarks = trim($row[14]); + $languageRaw = trim($row[15]); + $access = trim($row[16]); + $license = trim($row[17]); + $sizeInfo = trim($row[18]); + $juryPoints = !empty($row[19]) ? floatval($row[19]) : null; + $baiuLink = trim($row[20]); + + if ($title === '' || $year === 0) { + $missing = []; + if ($title === '') $missing[] = 'titre'; + if ($year === 0) $missing[] = 'année'; + throw new Exception("Champ(s) requis manquant(s) : " . implode(', ', $missing) + . " (id=\"" . ($identifier ?: '?') . "\", titre=\"" . mb_substr($title, 0, 80) . "\")"); + } $orientationId = $resolveOrientation($orientationCode); $apProgramId = $resolveAP($apCode); diff --git a/app/public/assets/css/common.css b/app/public/assets/css/common.css index 3a571fb..25105d9 100644 --- a/app/public/assets/css/common.css +++ b/app/public/assets/css/common.css @@ -416,6 +416,7 @@ main { opacity 0.15s, box-shadow 0.15s, filter 0.15s; + width: fit-content; } .btn:hover { @@ -563,7 +564,7 @@ main { ============================================================ */ fieldset { - background: var(--bg-secondary); + /*background: var(--bg-secondary);*/ border: 1px solid var(--border-primary); border-radius: 4px; padding: var(--space-m); diff --git a/app/src/Database.php b/app/src/Database.php index 300a9c5..35f1559 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -653,29 +653,26 @@ class Database [$wAll, $bAll] = $buildWhere('__none__'); $matchedIds = array_column($exec("SELECT t.id $baseJoins WHERE $wAll", $bAll), 'id'); - // Years - [$w, $b] = $buildWhere('years'); - $matchedYears = array_column($exec("SELECT DISTINCT t.year $baseJoins WHERE $w ORDER BY t.year DESC", $b), 'year'); + // Years — single-valued FK: use full intersection (including own filter). + // Clicking one year should fade years that have zero theses in the current result. + $matchedYearsIds = array_column($exec("SELECT DISTINCT t.year $baseJoins WHERE $wAll", $bAll), 'year'); $allYears = array_column($exec('SELECT DISTINCT year FROM theses WHERE is_published=1 ORDER BY year DESC', []), 'year'); - $yearsOut = array_map(fn ($y) => ['value' => $y, 'matched' => in_array($y, $matchedYears, true)], $allYears); + $yearsOut = array_map(fn ($y) => ['value' => $y, 'matched' => in_array($y, $matchedYearsIds, true)], $allYears); - // AP programs - [$w, $b] = $buildWhere('ap'); - $matchedAp = array_column($exec("SELECT DISTINCT ap.name $baseJoins WHERE $w AND ap.name IS NOT NULL ORDER BY ap.name", $b), 'name'); + // AP programs — single-valued FK: use full intersection. + $matchedApIds = array_column($exec("SELECT DISTINCT ap.name $baseJoins WHERE $wAll AND ap.name IS NOT NULL", $bAll), 'name'); $allAp = array_column($exec('SELECT name FROM ap_programs ORDER BY name', []), 'name'); - $apOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedAp, true)], $allAp); + $apOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedApIds, true)], $allAp); - // Orientations - [$w, $b] = $buildWhere('or'); - $matchedOr = array_column($exec("SELECT DISTINCT o.name $baseJoins WHERE $w AND o.name IS NOT NULL ORDER BY o.name", $b), 'name'); + // Orientations — single-valued FK: use full intersection. + $matchedOrIds = array_column($exec("SELECT DISTINCT o.name $baseJoins WHERE $wAll AND o.name IS NOT NULL", $bAll), 'name'); $allOr = array_column($exec('SELECT name FROM orientations ORDER BY name', []), 'name'); - $orOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedOr, true)], $allOr); + $orOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedOrIds, true)], $allOr); - // Finality types - [$w, $b] = $buildWhere('fi'); - $matchedFi = array_column($exec("SELECT DISTINCT ft.name $baseJoins WHERE $w AND ft.name IS NOT NULL ORDER BY ft.name", $b), 'name'); + // Finality types — single-valued FK: use full intersection. + $matchedFiIds = array_column($exec("SELECT DISTINCT ft.name $baseJoins WHERE $wAll AND ft.name IS NOT NULL", $bAll), 'name'); $allFi = array_column($exec('SELECT name FROM finality_types ORDER BY name', []), 'name'); - $fiOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedFi, true)], $allFi); + $fiOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedFiIds, true)], $allFi); // Keywords [$w, $b] = $buildWhere('kw'); diff --git a/app/storage/logs/admin.log b/app/storage/logs/admin.log index ede0c31..6901b8d 100644 --- a/app/storage/logs/admin.log +++ b/app/storage/logs/admin.log @@ -7,3 +7,4 @@ {"timestamp":"2026-05-05T09:19:46+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"thesis","action":"edit","status":"success","context":{"thesis_id":38,"title":"Jouer l'espace : dispositifs scénographiques pour l'expérience participative"}} {"timestamp":"2026-05-05T09:33:13+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"thesis","action":"csv_export","status":"success"} {"timestamp":"2026-05-05T09:33:44+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"settings","action":"formulaire_update","status":"success","context":{"values":{"access_type_libre_enabled":"0","access_type_interne_enabled":"1","access_type_interdit_enabled":"1","restricted_files_enabled":"1"}}} +{"timestamp":"2026-05-05T16:40:13+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"system","action":"delete_all_theses","status":"success","context":{"count":13}} diff --git a/app/templates/partials/repertoire-index.php b/app/templates/partials/repertoire-index.php index 08d0f26..0c43239 100644 --- a/app/templates/partials/repertoire-index.php +++ b/app/templates/partials/repertoire-index.php @@ -16,9 +16,8 @@ $activeSets = [ 'kw' => $activeFilters['kw'] ?? [], ]; -// Build the student map from matched students only -// name => [id, id, ...] (a student may have multiple theses) -$studentWorks = []; // name => [thesis ids] +// ── Students ──────────────────────────────────────────────────────────────── +$studentWorks = []; foreach ($repData['students'] as $s) { if (empty($s['authors'])) continue; foreach (explode(',', $s['authors']) as $name) { @@ -28,15 +27,9 @@ foreach ($repData['students'] as $s) { } } ksort($studentWorks); -// Legacy alias for single-id use -$studentMap = array_map(fn($ids) => $ids[0], $studentWorks); +// ── Shared helpers ────────────────────────────────────────────────────────── - -/** - * Build the toggle URL for a filter button. - * Toggles $value in $dim; keeps all other active filters intact. - */ function repToggleUrl(array $sets, string $dim, string $value): string { if (in_array($value, $sets[$dim], true)) { $sets[$dim] = array_values(array_filter($sets[$dim], fn($v) => $v !== $value)); @@ -53,123 +46,64 @@ function repToggleUrl(array $sets, string $dim, string $value): string { return '/repertoire' . ($qs ? '?' . $qs : ''); } +function repFilterEntry( + array $item, + string $dim, + array $activeSets, + bool $anyActive, + bool $colHasMatch, + string $hx, +): void { + $val = (string)$item['value']; + $isActive = in_array($val, $activeSets[$dim], true); + $isFaded = $anyActive && $colHasMatch && !$item['matched'] && !$isActive; + $cls = 'rep-entry' + . ($isActive ? ' rep-entry--selected' : '') + . ($isFaded ? ' rep-entry--faded' : ''); + $url = repToggleUrl($activeSets, $dim, $val); +?> +
  • + +
  • + !empty(array_filter($repData['years'], fn($i) => $i['matched'])), - 'ap' => !empty(array_filter($repData['ap_programs'], fn($i) => $i['matched'])), - 'or' => !empty(array_filter($repData['orientations'], fn($i) => $i['matched'])), - 'fi' => !empty(array_filter($repData['finality_types'], fn($i) => $i['matched'])), - 'kw' => !empty(array_filter($repData['keywords'], fn($i) => $i['matched'])), +$filterColumns = [ + ['dataKey' => 'years', 'dim' => 'years', 'heading' => 'Années'], + ['dataKey' => 'ap_programs', 'dim' => 'ap', 'heading' => 'Ateliers Pluridisciplinaires'], + ['dataKey' => 'orientations', 'dim' => 'or', 'heading' => 'Orientations'], + ['dataKey' => 'finality_types', 'dim' => 'fi', 'heading' => 'Finalité du Master'], + ['dataKey' => 'keywords', 'dim' => 'kw', 'heading' => 'Mots-clés'], ]; -// Common HTMX attributes for all active filter buttons -$hx = 'hx-target="#repertoire-index" hx-swap="outerHTML" hx-push-url="true" hx-indicator="#rep-indicator"'; +$colHasMatches = []; +foreach ($filterColumns as $col) { + $colHasMatches[$col['dim']] = !empty(array_filter( + $repData[$col['dataKey']], + fn($i) => $i['matched'] + )); +} ?>
    - -
    -

    Années

    -
      - -
    • - -
    • - -
    -
    - - -
    -

    Ateliers Pluridisciplinaires

    -
      - -
    • - -
    • - -
    -
    - - -
    -

    Orientations

    -
      - -
    • - -
    • - -
    -
    - - -
    -

    Finalité du Master

    -
      - -
    • - -
    • - -
    -
    +

    Étudiantes

    @@ -198,29 +132,17 @@ $hx = 'hx-target="#repertoire-index" hx-swap="outerHTML" hx-push-url="true" hx-i
    - - -
    -

    Mots-clés

    + $c['dim'] === $colKey))[0]; ?> +
    +

      - -
    • - -
    • - +
    +