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);
+?>
+