mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
centralise repertoire filter column rendering
- shared repFilterEntry() and config array - shared repFilterEntry() and $filterColumns config array - fix single-valued FK fading via full intersection
This commit is contained in:
12
TODO.md
12
TODO.md
@@ -75,6 +75,18 @@
|
||||
- [x] `templates/admin/edit.php` — moved `.admin-form-footer` from bottom to top-right, right after `<h1>`
|
||||
- [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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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);
|
||||
?>
|
||||
<li>
|
||||
<button type="button" class="<?= $cls ?>"
|
||||
aria-pressed="<?= $isActive ? 'true' : 'false' ?>"
|
||||
<?= $isFaded ? 'disabled' : "hx-get=\"" . htmlspecialchars($url) . "\" $hx" ?>>
|
||||
<?= htmlspecialchars($val) ?>
|
||||
</button>
|
||||
</li>
|
||||
<?php
|
||||
}
|
||||
|
||||
// ── Column definitions ──────────────────────────────────────────────────────
|
||||
$hx = 'hx-target="#repertoire-index" hx-swap="outerHTML" hx-push-url="true" hx-indicator="#rep-indicator"';
|
||||
|
||||
$anyActive = !empty($activeSets['years']) || !empty($activeSets['ap'])
|
||||
|| !empty($activeSets['or']) || !empty($activeSets['fi'])
|
||||
|| !empty($activeSets['kw']);
|
||||
|
||||
// Per-column: does this dimension have ANY matched entry in the current result set?
|
||||
// When a column has zero matched entries despite other filters being active, it means
|
||||
// no thesis in the matched set carries that FK — the column has no useful cross-filter
|
||||
// signal and its entries must NOT be faded (the user may still want to select them).
|
||||
$colHasMatches = [
|
||||
'years' => !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']
|
||||
));
|
||||
}
|
||||
?>
|
||||
<div id="repertoire-index" class="repertoire-index">
|
||||
|
||||
<!-- ANNÉES -->
|
||||
<section class="repertoire-col" data-col="years">
|
||||
<h2>Années</h2>
|
||||
<ul>
|
||||
<?php foreach ($repData['years'] as $item):
|
||||
$val = (string)$item['value'];
|
||||
$isActive = in_array($val, $activeSets['years'], true);
|
||||
$isFaded = $anyActive && $colHasMatches['years'] && !$item['matched'] && !$isActive;
|
||||
$cls = 'rep-entry'
|
||||
. ($isActive ? ' rep-entry--selected' : '')
|
||||
. ($isFaded ? ' rep-entry--faded' : '');
|
||||
$url = repToggleUrl($activeSets, 'years', $val);
|
||||
?>
|
||||
<li>
|
||||
<button type="button" class="<?= $cls ?>"
|
||||
aria-pressed="<?= $isActive ? 'true' : 'false' ?>"
|
||||
<?= $isFaded ? 'disabled' : "hx-get=\"" . htmlspecialchars($url) . "\" $hx" ?>>
|
||||
<?= htmlspecialchars($val) ?>
|
||||
</button>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ATELIERS PLURIDISCIPLINAIRES -->
|
||||
<section class="repertoire-col" data-col="ap">
|
||||
<h2>Ateliers Pluridisciplinaires</h2>
|
||||
<ul>
|
||||
<?php foreach ($repData['ap_programs'] as $item):
|
||||
$val = $item['value'];
|
||||
$isActive = in_array($val, $activeSets['ap'], true);
|
||||
$isFaded = $anyActive && $colHasMatches['ap'] && !$item['matched'] && !$isActive;
|
||||
$cls = 'rep-entry'
|
||||
. ($isActive ? ' rep-entry--selected' : '')
|
||||
. ($isFaded ? ' rep-entry--faded' : '');
|
||||
$url = repToggleUrl($activeSets, 'ap', $val);
|
||||
?>
|
||||
<li>
|
||||
<button type="button" class="<?= $cls ?>"
|
||||
aria-pressed="<?= $isActive ? 'true' : 'false' ?>"
|
||||
<?= $isFaded ? 'disabled' : "hx-get=\"" . htmlspecialchars($url) . "\" $hx" ?>>
|
||||
<?= htmlspecialchars($val) ?>
|
||||
</button>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ORIENTATIONS -->
|
||||
<section class="repertoire-col" data-col="or">
|
||||
<h2>Orientations</h2>
|
||||
<ul>
|
||||
<?php foreach ($repData['orientations'] as $item):
|
||||
$val = $item['value'];
|
||||
$isActive = in_array($val, $activeSets['or'], true);
|
||||
$isFaded = $anyActive && $colHasMatches['or'] && !$item['matched'] && !$isActive;
|
||||
$cls = 'rep-entry'
|
||||
. ($isActive ? ' rep-entry--selected' : '')
|
||||
. ($isFaded ? ' rep-entry--faded' : '');
|
||||
$url = repToggleUrl($activeSets, 'or', $val);
|
||||
?>
|
||||
<li>
|
||||
<button type="button" class="<?= $cls ?>"
|
||||
aria-pressed="<?= $isActive ? 'true' : 'false' ?>"
|
||||
<?= $isFaded ? 'disabled' : "hx-get=\"" . htmlspecialchars($url) . "\" $hx" ?>>
|
||||
<?= htmlspecialchars($val) ?>
|
||||
</button>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- FINALITÉ DU MASTER -->
|
||||
<section class="repertoire-col" data-col="fi">
|
||||
<h2>Finalité du Master</h2>
|
||||
<ul>
|
||||
<?php foreach ($repData['finality_types'] as $item):
|
||||
$val = $item['value'];
|
||||
$isActive = in_array($val, $activeSets['fi'], true);
|
||||
$isFaded = $anyActive && $colHasMatches['fi'] && !$item['matched'] && !$isActive;
|
||||
$cls = 'rep-entry'
|
||||
. ($isActive ? ' rep-entry--selected' : '')
|
||||
. ($isFaded ? ' rep-entry--faded' : '');
|
||||
$url = repToggleUrl($activeSets, 'fi', $val);
|
||||
?>
|
||||
<li>
|
||||
<button type="button" class="<?= $cls ?>"
|
||||
aria-pressed="<?= $isActive ? 'true' : 'false' ?>"
|
||||
<?= $isFaded ? 'disabled' : "hx-get=\"" . htmlspecialchars($url) . "\" $hx" ?>>
|
||||
<?= htmlspecialchars($val) ?>
|
||||
</button>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</section>
|
||||
<?php
|
||||
// Render filter columns in the correct left-to-right order.
|
||||
// Students column (non-filter) is inserted between keywords and AP/or/fi/years.
|
||||
$renderOrder = ['years', 'ap', 'or', 'fi', 'students', 'kw'];
|
||||
|
||||
foreach ($renderOrder as $colKey):
|
||||
if ($colKey === 'students'): ?>
|
||||
<!-- ÉTUDIANTES -->
|
||||
<section class="repertoire-col" data-col="students">
|
||||
<h2>Étudiantes</h2>
|
||||
@@ -198,29 +132,17 @@ $hx = 'hx-target="#repertoire-index" hx-swap="outerHTML" hx-push-url="true" hx-i
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- MOTS-CLÉS -->
|
||||
<section class="repertoire-col" data-col="kw">
|
||||
<h2>Mots-clés</h2>
|
||||
<?php else:
|
||||
$col = array_values(array_filter($filterColumns, fn($c) => $c['dim'] === $colKey))[0]; ?>
|
||||
<section class="repertoire-col" data-col="<?= $col['dim'] ?>">
|
||||
<h2><?= htmlspecialchars($col['heading']) ?></h2>
|
||||
<ul>
|
||||
<?php foreach ($repData['keywords'] as $item):
|
||||
$val = $item['value'];
|
||||
$isActive = in_array($val, $activeSets['kw'], true);
|
||||
$isFaded = $anyActive && $colHasMatches['kw'] && !$item['matched'] && !$isActive;
|
||||
$cls = 'rep-entry'
|
||||
. ($isActive ? ' rep-entry--selected' : '')
|
||||
. ($isFaded ? ' rep-entry--faded' : '');
|
||||
$url = repToggleUrl($activeSets, 'kw', $val);
|
||||
?>
|
||||
<li>
|
||||
<button type="button" class="<?= $cls ?>"
|
||||
aria-pressed="<?= $isActive ? 'true' : 'false' ?>"
|
||||
<?= $isFaded ? 'disabled' : "hx-get=\"" . htmlspecialchars($url) . "\" $hx" ?>>
|
||||
<?= htmlspecialchars($val) ?>
|
||||
</button>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
<?php foreach ($repData[$col['dataKey']] as $item):
|
||||
repFilterEntry($item, $col['dim'], $activeSets, $anyActive, $colHasMatches[$col['dim']], $hx);
|
||||
endforeach; ?>
|
||||
</ul>
|
||||
</section>
|
||||
<?php endif;
|
||||
endforeach; ?>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user