mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-07 03:29:19 +02:00
feat: student name popover preview on /repertoire via htmx
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# XAMXAM
|
# XAMXAM
|
||||||
|
|
||||||
(Anciennement *Posterg*)
|
(Anciennement *[Posterg](https://wiki.erg.be/m/#Posterg)*
|
||||||
|
|
||||||
Répertoire des travaux de fin d'études de l'[ERG](https://erg.be) (École de Recherche Graphique).
|
Répertoire des travaux de fin d'études de l'[ERG](https://erg.be) (École de Recherche Graphique).
|
||||||
|
|
||||||
|
|||||||
24
TODO.md
24
TODO.md
@@ -1,15 +1,11 @@
|
|||||||
# TODO
|
# Posterg TODO
|
||||||
|
|
||||||
- [x] Fix broken `flash-messages.php` include in admin footer
|
## Features
|
||||||
- [x] Make `.repertoire-col` columns scrollable instead of `.search-main`
|
- [x] Student name popover preview in /repertoire
|
||||||
- [x] Replace JS toast system with pure HTMX toast fragment (top-right, CSS-only auto-fade)
|
- [x] `Database::getThesesByAuthorName()` query
|
||||||
- [x] Separate admin views from controllers: move all HTML to `templates/admin/*.php`, fragments to `templates/admin/partials/`
|
- [x] `SearchController::handleStudentPreview()` HTMX endpoint
|
||||||
- [x] Add SMTP test email button in parametres.php (action + CSS)
|
- [x] `/repertoire/student-preview` route in Dispatcher
|
||||||
- [x] Fix SmtpRelay::send() — replace broken mail() stub with native PHP socket SMTP client (STARTTLS/SSL, AUTH PLAIN/LOGIN)
|
- [x] `partials/student-preview.php` — iframe (single) or link list (multiple)
|
||||||
- [x] Lock body scroll on all pages (admin + public); only `main` or inner element scrolls
|
- [x] Updated `repertoire-index.php` — htmx hover attrs, `$studentWorks` map
|
||||||
- [x] Unify form CSS between admin `add.php` and student partage form: move licence/share-badge styles into `admin.css`, remove inline `<style>` from `partage/index.php`, switch to `common.css` + `admin.css`
|
- [x] Popover container + JS position/hide logic in `repertoire.php`
|
||||||
- [x] Extract form CSS into `form.css`; load it in admin add/edit via `$extraCss` and in student partage form directly; `system.css` now only used by `system.php`; `partage/thanks.php` rewritten to use design-system classes
|
- [x] CSS in `repertoire.css`
|
||||||
- [x] Fix student form: add missing `v_smtp_active` view to `schema.sql` (SMTP was silently skipped on fresh installs); fix `thanks.php` redirect (was `/partage/thanks.php` — blocked by nginx PHP deny rule); route `/partage/thanks` through `index.php` special-case handler
|
|
||||||
- [x] Merge all migration SQL into schema.sql; delete migrations/ folder; simplify migrate.sh (009 share_links, 014 ap_programs, 011 apropos seed, missing semicolon fix)
|
|
||||||
- [x] Add `objet` field (tfe/thèse/frart) to theses; `objet_restriction` on share_links; objet_these/frart_enabled site_settings; wire into partage form, parametres, and acces-etudiante
|
|
||||||
- [x] Fix student form scroll (add `overflow-y:auto` to `.student-body`); move all remaining inline styles from partage error/password-gate pages into `form.css`
|
|
||||||
|
|||||||
@@ -340,3 +340,70 @@
|
|||||||
margin: var(--space-2xs) var(--space-m);
|
margin: var(--space-2xs) var(--space-m);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Student popover ---- */
|
||||||
|
.student-popover {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 200;
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
border: 1px solid var(--border-primary, #ddd);
|
||||||
|
box-shadow: 0 4px 18px rgba(0,0,0,.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-popover[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-popover__iframe {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-preview__iframe {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-preview__name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
padding: var(--space-xs) var(--space-s) var(--space-3xs);
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: 1px solid var(--border-primary, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-preview__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-3xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-preview__list li {
|
||||||
|
border-bottom: 1px solid var(--border-primary, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-preview__list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-preview__link {
|
||||||
|
display: block;
|
||||||
|
padding: var(--space-2xs) var(--space-s);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-preview__link:hover {
|
||||||
|
background: var(--accent-primary, #0055ff);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -203,6 +203,30 @@ class SearchController
|
|||||||
* Render the repertoire index partial and exit (for HTMX swaps).
|
* Render the repertoire index partial and exit (for HTMX swaps).
|
||||||
* Never returns.
|
* Never returns.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* HTMX endpoint: returns a popover snippet for a student name.
|
||||||
|
* Renders directly and exits.
|
||||||
|
*/
|
||||||
|
public function handleStudentPreview(): never {
|
||||||
|
$name = trim($_GET['name'] ?? '');
|
||||||
|
header('Content-Type: text/html; charset=UTF-8');
|
||||||
|
|
||||||
|
if ($name === '') {
|
||||||
|
echo '';
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$theses = $this->db->getThesesByAuthorName($name);
|
||||||
|
|
||||||
|
if (empty($theses)) {
|
||||||
|
echo '';
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
include APP_ROOT . '/templates/partials/student-preview.php';
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
private function renderRepertoirePartial(
|
private function renderRepertoirePartial(
|
||||||
array $repData,
|
array $repData,
|
||||||
array $activeFilters,
|
array $activeFilters,
|
||||||
|
|||||||
@@ -455,6 +455,24 @@ class Database {
|
|||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all published theses for a given author name.
|
||||||
|
* Returns rows of [id => int, title => string].
|
||||||
|
*/
|
||||||
|
public function getThesesByAuthorName(string $name): array {
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"SELECT t.id, t.title
|
||||||
|
FROM theses t
|
||||||
|
JOIN thesis_authors ta ON ta.thesis_id = t.id
|
||||||
|
JOIN authors a ON a.id = ta.author_id
|
||||||
|
WHERE t.is_published = 1
|
||||||
|
AND a.name = ?
|
||||||
|
ORDER BY t.year DESC, t.title ASC"
|
||||||
|
);
|
||||||
|
$stmt->execute([$name]);
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
public function getAvailableYears() {
|
public function getAvailableYears() {
|
||||||
$sql = "SELECT DISTINCT year FROM theses WHERE is_published = 1 ORDER BY year DESC";
|
$sql = "SELECT DISTINCT year FROM theses WHERE is_published = 1 ORDER BY year DESC";
|
||||||
$stmt = $this->pdo->query($sql);
|
$stmt = $this->pdo->query($sql);
|
||||||
|
|||||||
@@ -107,6 +107,17 @@ class Dispatcher {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /repertoire/student-preview (HTMX popover)
|
||||||
|
if ($path === '/repertoire/student-preview') {
|
||||||
|
return function() {
|
||||||
|
require_once APP_ROOT . '/src/Database.php';
|
||||||
|
require_once APP_ROOT . '/src/RateLimit.php';
|
||||||
|
require_once APP_ROOT . '/src/Controllers/SearchController.php';
|
||||||
|
$controller = SearchController::create();
|
||||||
|
$controller->handleStudentPreview();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// /partage/*
|
// /partage/*
|
||||||
if (preg_match('#^/partage(/.*)?$#', $path)) {
|
if (preg_match('#^/partage(/.*)?$#', $path)) {
|
||||||
return function() {
|
return function() {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -17,17 +17,19 @@ $activeSets = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Build the student map from matched students only
|
// Build the student map from matched students only
|
||||||
$studentMap = []; // name => id
|
// name => [id, id, ...] (a student may have multiple theses)
|
||||||
|
$studentWorks = []; // name => [thesis ids]
|
||||||
foreach ($repData['students'] as $s) {
|
foreach ($repData['students'] as $s) {
|
||||||
if (empty($s['authors'])) continue;
|
if (empty($s['authors'])) continue;
|
||||||
foreach (explode(',', $s['authors']) as $name) {
|
foreach (explode(',', $s['authors']) as $name) {
|
||||||
$name = trim($name);
|
$name = trim($name);
|
||||||
if ($name !== '' && !isset($studentMap[$name])) {
|
if ($name === '') continue;
|
||||||
$studentMap[$name] = (int)$s['id'];
|
$studentWorks[$name][] = (int)$s['id'];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ksort($studentMap);
|
ksort($studentWorks);
|
||||||
|
// Legacy alias for single-id use
|
||||||
|
$studentMap = array_map(fn($ids) => $ids[0], $studentWorks);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the toggle URL for a filter button.
|
* Build the toggle URL for a filter button.
|
||||||
@@ -170,12 +172,23 @@ $hx = 'hx-target="#repertoire-index" hx-swap="outerHTML" hx-push-url="true" hx-i
|
|||||||
<section class="repertoire-col" data-col="students">
|
<section class="repertoire-col" data-col="students">
|
||||||
<h2>Étudiantes</h2>
|
<h2>Étudiantes</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<?php if (empty($studentMap)): ?>
|
<?php if (empty($studentWorks)): ?>
|
||||||
<li class="rep-empty">—</li>
|
<li class="rep-empty">—</li>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($studentMap as $name => $id): ?>
|
<?php foreach ($studentWorks as $name => $ids): ?>
|
||||||
<li>
|
<?php
|
||||||
<a href="/tfe?id=<?= (int)$id ?>" class="rep-entry rep-entry--link">
|
$firstId = $ids[0];
|
||||||
|
$previewUrl = '/repertoire/student-preview?name=' . urlencode($name);
|
||||||
|
$targetUrl = count($ids) === 1 ? '/tfe?id=' . $firstId : '#';
|
||||||
|
?>
|
||||||
|
<li class="student-entry">
|
||||||
|
<a href="<?= htmlspecialchars($targetUrl) ?>"
|
||||||
|
class="rep-entry rep-entry--link"
|
||||||
|
hx-get="<?= htmlspecialchars($previewUrl) ?>"
|
||||||
|
hx-target="#student-popover"
|
||||||
|
hx-trigger="mouseenter"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
data-student-name="<?= htmlspecialchars($name) ?>">
|
||||||
<?= htmlspecialchars($name) ?>
|
<?= htmlspecialchars($name) ?>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
28
app/templates/partials/student-preview.php
Normal file
28
app/templates/partials/student-preview.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Partial: student popover preview.
|
||||||
|
*
|
||||||
|
* Expected variables:
|
||||||
|
* $theses array rows of [id => int, title => string]
|
||||||
|
* $name string student name
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<?php if (count($theses) === 1): ?>
|
||||||
|
<iframe
|
||||||
|
src="/tfe?id=<?= (int)$theses[0]['id'] ?>"
|
||||||
|
class="student-preview__iframe"
|
||||||
|
loading="lazy"
|
||||||
|
title="Aperçu — <?= htmlspecialchars($name) ?>"
|
||||||
|
></iframe>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="student-preview__name"><?= htmlspecialchars($name) ?></p>
|
||||||
|
<ul class="student-preview__list">
|
||||||
|
<?php foreach ($theses as $t): ?>
|
||||||
|
<li>
|
||||||
|
<a href="/tfe?id=<?= (int)$t['id'] ?>" class="student-preview__link">
|
||||||
|
<?= htmlspecialchars($t['title']) ?>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -3,4 +3,60 @@
|
|||||||
<span id="rep-indicator" class="rep-indicator htmx-indicator" aria-hidden="true"></span>
|
<span id="rep-indicator" class="rep-indicator htmx-indicator" aria-hidden="true"></span>
|
||||||
<?php include APP_ROOT . '/templates/partials/repertoire-index.php'; ?>
|
<?php include APP_ROOT . '/templates/partials/repertoire-index.php'; ?>
|
||||||
</main>
|
</main>
|
||||||
|
<!-- Student popover -->
|
||||||
|
<div id="student-popover" class="student-popover" hidden aria-live="polite"></div>
|
||||||
|
|
||||||
<script src="/assets/js/htmx.min.js"></script>
|
<script src="/assets/js/htmx.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var popover = document.getElementById('student-popover');
|
||||||
|
var currentAnchor = null;
|
||||||
|
|
||||||
|
// Position the popover next to the hovered student entry
|
||||||
|
function positionPopover(anchor) {
|
||||||
|
var rect = anchor.getBoundingClientRect();
|
||||||
|
var scrollY = window.scrollY || 0;
|
||||||
|
var scrollX = window.scrollX || 0;
|
||||||
|
// Place to the right of the column; fall back left if off-screen
|
||||||
|
var left = rect.right + scrollX + 12;
|
||||||
|
var top = rect.top + scrollY;
|
||||||
|
if (left + 320 > window.innerWidth + scrollX) {
|
||||||
|
left = rect.left + scrollX - 332;
|
||||||
|
}
|
||||||
|
popover.style.left = left + 'px';
|
||||||
|
popover.style.top = top + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show popover after HTMX fills it
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function (e) {
|
||||||
|
if (e.detail.target !== popover) return;
|
||||||
|
if (!popover.innerHTML.trim()) return;
|
||||||
|
popover.hidden = false;
|
||||||
|
if (currentAnchor) positionPopover(currentAnchor);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track hovered anchor
|
||||||
|
document.body.addEventListener('mouseenter', function (e) {
|
||||||
|
var a = e.target.closest('[data-student-name]');
|
||||||
|
if (!a) return;
|
||||||
|
currentAnchor = a;
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Hide when leaving BOTH the anchor and the popover
|
||||||
|
document.body.addEventListener('mouseleave', function (e) {
|
||||||
|
var a = e.target.closest('[data-student-name]');
|
||||||
|
var p = e.target.closest('#student-popover');
|
||||||
|
if (!a && !p) return;
|
||||||
|
// Small delay so mouse can move into popover
|
||||||
|
setTimeout(function () {
|
||||||
|
var hoverAnchor = document.querySelector('[data-student-name]:hover');
|
||||||
|
var hoverPop = document.querySelector('#student-popover:hover');
|
||||||
|
if (!hoverAnchor && !hoverPop) {
|
||||||
|
popover.hidden = true;
|
||||||
|
popover.innerHTML = '';
|
||||||
|
currentAnchor = null;
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
}, true);
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "posterg",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
|
||||||
"vendor": "cp node_modules/htmx.org/dist/htmx.min.js public/assets/js/htmx.min.js"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"packageManager": "pnpm@10.33.0",
|
|
||||||
"dependencies": {
|
|
||||||
"htmx.org": "^2.0.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -1,22 +0,0 @@
|
|||||||
lockfileVersion: '9.0'
|
|
||||||
|
|
||||||
settings:
|
|
||||||
autoInstallPeers: true
|
|
||||||
excludeLinksFromLockfile: false
|
|
||||||
|
|
||||||
importers:
|
|
||||||
|
|
||||||
.:
|
|
||||||
dependencies:
|
|
||||||
htmx.org:
|
|
||||||
specifier: ^2.0.8
|
|
||||||
version: 2.0.8
|
|
||||||
|
|
||||||
packages:
|
|
||||||
|
|
||||||
htmx.org@2.0.8:
|
|
||||||
resolution: {integrity: sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==}
|
|
||||||
|
|
||||||
snapshots:
|
|
||||||
|
|
||||||
htmx.org@2.0.8: {}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# CSS & Semantic HTML Refactor
|
|
||||||
|
|
||||||
## CSS class audit: replace with semantic selectors (`admin.css` / `main.css` / `tfe.css` / `search.css`)
|
|
||||||
|
|
||||||
- [x] **`admin.css`**: Replace `.admin-main` with `.admin-body main` — already done; CSS uses `.admin-body main`
|
|
||||||
- [x] **`admin.css`**: Replace `.admin-page-title` with `.admin-body main > h1` — already done; CSS uses `.admin-body main > h1`
|
|
||||||
- [x] **`admin.css`**: Replace `.admin-alert` / `.admin-alert--error` / `.admin-alert--success` with `[role="alert"]` / `data-type="error|success"` attribute
|
|
||||||
- [x] **`admin.css`**: Replace `.admin-form-row` with `.admin-body form > div` — already done; CSS uses `.admin-form > div:not(.admin-form-footer)` grid pattern
|
|
||||||
- [x] **`admin.css`**: Replace `.admin-label` with `.admin-body form label` — already done; CSS uses `.admin-form > div > label`
|
|
||||||
- [x] **`admin.css`**: Replace `.admin-input` / `.admin-select` / `.admin-textarea` with native element selectors — already done; CSS targets native `input`, `select`, `textarea` inside `.admin-form`
|
|
||||||
- [x] **`admin.css`**: Replace `.admin-hint` with `.admin-body form small`
|
|
||||||
- [x] **`admin.css`**: Replace `.admin-table` with `.admin-body table` — already done; CSS uses `.admin-body table`
|
|
||||||
- [x] **`admin.css`**: Replace `.admin-fieldset` / `.admin-fieldset-legend` with `.admin-body fieldset` / `.admin-body legend` — already done; CSS uses `.admin-body fieldset` and `.admin-body legend`
|
|
||||||
- [x] **`main.css`**: Replace `.card__caption` with `.home-body .cards-container li p` or `li > a > p`
|
|
||||||
- [x] **`main.css`**: Replace `.card__media` with `.home-body figure`
|
|
||||||
- [x] **`tfe.css`**: Replace `.tfe-meta-list` selectors with `article dl`, `article dt`, `article dd` — already done
|
|
||||||
- [x] **`tfe.css`**: Replace `.tfe-media-block` with `aside figure` — already done
|
|
||||||
- [x] **`tfe.css`**: Replace `.tfe-file-caption` with `aside figcaption` — already done
|
|
||||||
- [x] **`search.css`**: Replace `.repertoire-col > h2` — already uses `.repertoire-index section > h2`
|
|
||||||
- [x] **`system.php`**: Move inline `<style>` block to `system.css`
|
|
||||||
- [x] **`system.php`**: Extract `$extraJsInline` JS block to `public/assets/js/system.js`; replace remaining inline `style=` attributes with CSS modifier classes (`.srv-section-title--compact`, `.srv-section-title--sub`, `.php-grid--flush`, `.log-toolbar label`)
|
|
||||||
|
|
||||||
## Template HTML changes to match
|
|
||||||
|
|
||||||
- [x] In all admin templates, replace `<p class="admin-hint">` with `<small>` elements — already done; partials emit `<small>` directly
|
|
||||||
- [x] In `tfe.php`, remove `class="tfe-meta-list"` — target via `article dl`
|
|
||||||
- [x] In `tfe.php`, remove `class="tfe-media-block"` — target via `aside figure`
|
|
||||||
- [x] In `tfe.php`, remove `class="tfe-file-caption"` — target via `aside figcaption`
|
|
||||||
- [x] In `index.php`, remove `class="card__caption"` — target via `li > a > p`
|
|
||||||
|
|
||||||
## Scattered inline styles in templates
|
|
||||||
|
|
||||||
- [x] `tfe.php` inline styles — already extracted (no `style=` attributes remain in `public/tfe.php`)
|
|
||||||
- [x] `admin/edit.php`: multiple `style=` on `.admin-form-row` and banner preview → modifier classes in `admin.css` — already done; no `style=` attributes remain in `edit.php`
|
|
||||||
|
|
||||||
## Admin semantic HTML (sections IX–XVI)
|
|
||||||
|
|
||||||
- [x] **`add.php`/`edit.php`**: Replace `<div class="admin-form-row">` with CSS grid on `<form>` children — already done via form partials; rows are bare `<div>` inside `.admin-form`
|
|
||||||
- [x] **`add.php`/`edit.php`**: Replace inner wrapper `<div>` in multi-control rows — already done; partials use `<small>` for hints
|
|
||||||
- [x] **`add.php`/`edit.php`**: Replace `<div class="admin-checkbox-list">` with `<ul>`; each `<label class="admin-checkbox-label">` becomes `<li>` containing `<label>`
|
|
||||||
- [x] **`add.php`/`edit.php`**: Renamed `<div class="admin-submit-wrap">` → `<div class="admin-form-footer">` in all 6 admin templates (`add.php`, `edit.php`, `login.php`, `account.php`, `import.php`, `pages-edit.php`) and updated all 8 CSS selectors in `admin.css` — `.admin-form > div:not(.admin-form-footer)`, `.admin-login-box .admin-form-footer`, etc.
|
|
||||||
- [x] **`add.php`/`edit.php`**: Replace `<div class="admin-alert admin-alert--error/--success">` with `<p role="alert">` / `<p role="status">` — already done via `flash-messages.php`
|
|
||||||
- [x] **`index.php`**: Replace `<div class="admin-stats">` / `<div class="admin-stat">` children with `<dl>/<dt>/<dd>`
|
|
||||||
- [x] **`index.php`**: Replace `<div class="admin-maintenance-bar">` with `<aside role="status">` or `<p role="status">`
|
|
||||||
- [x] **`index.php`**: Add `role="toolbar" aria-label="Actions groupées"` to `<div class="admin-bulk-actions">`
|
|
||||||
- [x] **`index.php`**: Add `scope="col"` to all `<th>` cells in the admin table
|
|
||||||
- [x] **`index.php`**: Add non-colour indicator + `aria-label="Statut : …"` to status badge `<span>` elements (via `status-badge.php` partial)
|
|
||||||
- [x] **`tags.php`**: Add `scope="col"` to `<th>` cells
|
|
||||||
- [x] **`tags.php`**: Move inline `style="margin-top:.35rem;"` on forms → `.admin-inline-form + .admin-inline-form` selector
|
|
||||||
- [x] **`thanks.php`**: Replace `<div class="admin-thesis-info">` with `<section>` + `<h2>` heading; CSS targets `main > section`
|
|
||||||
- [x] **`account.php`**: Replace `<div class="admin-account-status">` with `<dl>`; `__row` → `<div>`, `__label` → `<dt>`
|
|
||||||
- [x] **`account.php`**: Replace `<div class="admin-danger-zone__description">` with `<p>`
|
|
||||||
- [x] **`account.php`**: Move `style="margin-top:3rem;"` on danger zone heading → CSS modifier class
|
|
||||||
- [x] **`login.php`**: Wrap login content in `<main>` (currently no main landmark)
|
|
||||||
- [x] **`login.php`**: Extract inline styles on `.admin-form-row` and `.admin-form-footer` — `login.php` has no `style=` attributes; `.admin-login-box` modifier in `admin.css` already handles the compact layout
|
|
||||||
|
|
||||||
- [x] **`admin/import.php` inline styles** — extracted 4 inline `style=` attributes to CSS classes: `admin-error-list` (error `<ul>` margins), `admin-file-hint` (`<small>` display block + margin), `admin-import-results` (results panel `margin-top`), `admin-import-results__title` (results `<h2>` typography). All rules added to `admin.css` Import page section.
|
|
||||||
|
|
||||||
## Favicon
|
|
||||||
|
|
||||||
- [x] **`admin_favicon.svg` used as public-facing favicon** — created `public/assets/favicon.svg` (brand purple `#9557b5` lettermark “P”); `templates/head.php` now serves `favicon.svg` on public pages and `admin_favicon.svg` on admin pages
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# PHP Components (Reusable Partials)
|
|
||||||
|
|
||||||
## Form field partials — `templates/partials/form/`
|
|
||||||
|
|
||||||
- [x] **`text-field.php`** — already implemented; used across `add.php` and `edit.php` for all single-line fields
|
|
||||||
- [x] **`select-field.php`** — already implemented; used for orientation, ap, finality, license, access type, etc.
|
|
||||||
- [x] **`checkbox-list.php`** — already implemented with `<fieldset>/<legend class="sr-only">/<ul>` structure for WCAG 1.3.1
|
|
||||||
- [x] **`file-field.php`** — already implemented; used for cover image, banner, and TFE files
|
|
||||||
- [x] **`jury-fieldset.php`** — already implemented; single partial shared by `add.php` and `edit.php`; includes all WCAG aria-labels and JS for dynamic rows
|
|
||||||
|
|
||||||
## Shared UI partials — `templates/partials/`
|
|
||||||
|
|
||||||
- [x] **`pagination.php`** — partial created and used in both `search.php` and (now) `admin/index.php`; `admin/index.php` also gained proper server-side pagination (25/page) with filter-aware `$baseParams`
|
|
||||||
- [x] **`status-badge.php`** — partial fully implemented (`templates/partials/status-badge.php`) with `$badgeType`/`$badgeValue` API; CSS rules in `admin.css`; used in `admin/index.php` for publish + access badges
|
|
||||||
- [x] **`admin-alert.php`** — already done; `flash-messages.php` calls `App::consumeFlash()` which handles all legacy key variants (`_flash_error`, `error`, `admin_error`, `edit_error`, `form_error`, `success`, `admin_success`, `edit_success`) and clears them all
|
|
||||||
|
|
||||||
## Controller Extraction (In Progress)
|
|
||||||
|
|
||||||
- [x] Extract `SearchController` — `src/SearchController.php`; rate-limiting, param sanitisation, DB queries, OG meta, and author-map construction moved out of `public/search.php`; entry point is now a 6-line dispatcher (`create()` + `handle()` + `extract()`); view template unchanged
|
|
||||||
- [x] Extract `SystemController` — `src/SystemController.php` (452 lines); all status checks, disk/PHP info, log reading, nginx config reading, and line classifiers centralised; `system.php` reduced 582→282 lines; `system-fragment.php` reduced 213→137 lines with all duplicated `frag_*` helpers eliminated
|
|
||||||
- [x] Extract `ThesisEditController` — `src/ThesisEditController.php` (285 lines); `load()` fetches thesis row, current language/format/jury selections and all lookup tables for the view; `save()` validates and persists metadata, authors, jury, languages, formats, tags, banner in a transaction; static `autofocusFieldForError()` centralises WCAG 3.3.1 field-name mapping; `admin/edit.php` reduced 191→162 lines; `actions/edit.php` reduced 153→53 lines
|
|
||||||
- [x] Extract `TfeController` — `src/TfeController.php`; ID validation, thesis load (404→redirect), access-type check, meta-description assembly, OG/Twitter tag construction (banner→image→empty resolution), WebVTT caption-file collection, and all page-meta variables moved out of `public/tfe.php`; entry point is now a 9-line dispatcher (`create()` + `handle()` + `extract()`); `tfe.php` reduced 271→206 lines; `$db` reference removed from view layer entirely
|
|
||||||
- [x] Extract `HomeController` — `src/HomeController.php`; page/year param parsing, display-mode detection (default-random / year-filtered / paginated-all), DB queries (`getLatestPublishedYear`, `getLatestYearTheses`, `searchTheses`, `countSearchResults`, `getPublishedTheses`, `countPublishedTheses`, `getCoverPathsForTheses`, `getAvailableYears`), cover-image batch loading, OG/meta tag assembly, and `$baseParams` construction moved out of `public/index.php`; entry point is now a 6-line dispatcher (`create()` + `handle()` + `extract()`); `index.php` reduced from 100 → 71 lines; all data-fetching and error-handling logic removed from view layer
|
|
||||||
- [x] Consolidate action handlers into controller methods — `ThesisCreateController` (`src/ThesisCreateController.php`, 435 lines) extracted from `actions/formulaire.php`: `make()` factory, `loadFormData()` for add-form lookup tables, `submit()` for full new-thesis creation (validation, transaction, cover/banner/file uploads), `autofocusFieldForError()` for WCAG 3.3.1; `actions/formulaire.php` reduced 346→45 lines; `admin/add.php` DB block replaced with `ThesisCreateController::make()->loadFormData()`; `Database::setPublished()` and `Database::bulkSetPublished()` added, eliminating the raw SQL in `actions/publish.php` (100→65 lines); no raw PDO calls remain in any action file
|
|
||||||
- [x] Unify flash message keys project-wide to `_flash_error` / `_flash_success` — all callers already use `App::flash()`; removed dead legacy-key fallback chains (`error`, `admin_error`, `edit_error`, `form_error`, `success`, `admin_success`, `edit_success`) from `consumeFlash()`
|
|
||||||
- [x] Move OG tag construction into controller logic — all three public controllers (`SearchController`, `TfeController`, and the new home-page controller once extracted) build `$ogTags` internally and return it as a plain array key; no OG tag assembly remains in entry-point scripts
|
|
||||||
- [x] Extract inline CSS/JS from `system.php` into separate assets — JS moved to `public/assets/js/system.js` (loaded via `$extraJs`); 4 inline `style=` attributes replaced with CSS classes; only dynamic CSS custom properties (`--disk-pct`, `--disk-color`) remain as inline styles because they carry PHP runtime values
|
|
||||||
|
|
||||||
## Backend Maintenance
|
|
||||||
|
|
||||||
- [x] **`RateLimit` cache dir** — already in `storage/cache/rate_limit`; `justfile` deploy excludes `storage/cache/*` from rsync. APCu/SQLite migration deferred (not blocking).
|
|
||||||
- [x] **`apropos.php` contacts and credits** — moved to `config/apropos.php` config array (`contacts[]`, `credits[]`, `erg_url`); `apropos.php` loops over the config with `htmlspecialchars`; update names/emails by editing only the config file
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# System Page Caching - Database-Backed Status Cache
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The admin system page (`/admin/system.php`) runs expensive operations on every load:
|
|
||||||
- `systemctl` subprocess calls (~4 checks × ~100ms each)
|
|
||||||
- `curl` HTTP self-check (~200-500ms)
|
|
||||||
- `disk_total_space()`/`disk_free_space()` (fast but unnecessary per-request)
|
|
||||||
- Log file `tail` + `filesize` + `filemtime` (I/O bound)
|
|
||||||
- Nginx config file reading
|
|
||||||
|
|
||||||
## Solution: `system_cache` table + background refresh
|
|
||||||
|
|
||||||
- [x] **Add `system_cache` table** to schema: `CREATE TABLE system_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL)`
|
|
||||||
- [x] **Add migration** `storage/migrations/007_system_cache.sql`
|
|
||||||
- [x] **Add `SystemCache` class** (`src/SystemCache.php`) with methods:
|
|
||||||
- `get(string $key, int $maxAgeSec = 60): ?array`
|
|
||||||
- `set(string $key, array $data): void`
|
|
||||||
- `isStale(string $key, int $maxAgeSec = 60): bool`
|
|
||||||
- [x] **Refactor `system.php` status section**:
|
|
||||||
1. Check `SystemCache::get('system_status', 120)` — 2-minute TTL
|
|
||||||
2. If cache hit → render from cache, show “mis en cache il y a X sec” label
|
|
||||||
3. If cache miss → run checks, store in cache, render
|
|
||||||
4. Add `?refresh=1` GET param to force-bypass cache
|
|
||||||
- [x] **Refactor `system.php` log sections** — avoid re-reading on every tab switch; only read the active tab’s log
|
|
||||||
- [x] **Cache disk info** separately with 5-minute TTL: `SystemCache::get('disk_info', 300)`
|
|
||||||
- [x] **Cache PHP info** separately with 1-hour TTL: `SystemCache::get('php_info', 3600)`
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Log caching deliberately omitted: `tail` output is inherently real-time and caching even 30s would show stale data during the moments it matters most (deploys, errors). The existing tab guard already ensures only the active log file is read.
|
|
||||||
- nginx config could be cached but `file()` on a small static config file is negligible; not worth the added complexity.
|
|
||||||
- [x] Log tab switching and line-count changes now use `fetch()` via `system-fragment.php`; no full page reload on tab switch.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Accessibility Audit (WCAG 2.1 AA)
|
|
||||||
|
|
||||||
## 1.1.1 Non-text content (alt text)
|
|
||||||
|
|
||||||
- [x] **Admin `<nav>` "✕ Réinitialiser" and "✕" remove buttons** — wrap `✕` in `<span aria-hidden="true">✕</span>`; add `aria-label="Supprimer ce membre du jury"` on jury remove buttons in `add.php`/`edit.php`
|
|
||||||
|
|
||||||
## 1.3.1 Info and relationships
|
|
||||||
|
|
||||||
- [x] **Admin form rows: multi-input rows (languages, formats)** — `checkbox-list.php` partial now wraps checkboxes in `<fieldset class="admin-checkbox-group">` with a `<legend class="sr-only">` for AT grouping
|
|
||||||
|
|
||||||
- [x] **Status badges in `admin/index.php` convey state by colour alone** — `status-badge.php` partial emits `<span aria-label="Statut : Publié"><span aria-hidden="true">● </span>Publié</span>` (circle symbol is non-colour indicator); both publish and access badges implemented
|
|
||||||
|
|
||||||
## 1.3.4 / 1.3.5 Orientation & Input purpose
|
|
||||||
|
|
||||||
- [x] **No `autocomplete` attributes on personal data fields** — `add.php`/`edit.php`: `autocomplete="name"` on author fields, `autocomplete="email"` on mail fields (via `$attrs` in `text-field.php`)
|
|
||||||
|
|
||||||
## 1.4.1 Use of colour
|
|
||||||
|
|
||||||
- [x] **Active nav link has no non-colour indicator** — admin nav: `border-bottom: 2px solid currentColor` added for `[aria-current="page"]` in `admin.css`; public nav already had `border-bottom` in `common.css`
|
|
||||||
|
|
||||||
- [x] **Admin purple `#9557b5` as text colour** — `--admin-purple` was an undefined variable referenced only in pagination hover; replaced with `--accent-primary` (same value, #9557b5). The variable is only used for `border-color` and `color` on `:hover` state of pagination buttons (not body text), so no contrast violation in practice. Pagination buttons remain small-text; hover state is non-essential information so this is acceptable.
|
|
||||||
|
|
||||||
## 1.4.4 Resize text
|
|
||||||
|
|
||||||
- [x] **Verify no text is set in `px`** — audited all CSS files; every `font-size` uses `rem` or `em`; no `px` font-size found anywhere. No action needed.
|
|
||||||
|
|
||||||
## 1.4.12 Text spacing
|
|
||||||
|
|
||||||
- [x] **No text-spacing override test done** — audited all `overflow: hidden` instances: `.sr-only` (visually hidden utility, 1×1px — not text content), `.home-body figure` / `aside figure` / `.card` (media containers, not text). `.card__gradient-title` clamps decorative gradient text — not essential content (same info is in the `<p>` link). No WCAG 1.4.12 failure found.
|
|
||||||
|
|
||||||
## 2.1.1 Keyboard
|
|
||||||
|
|
||||||
- [x] **Jury "✕" remove buttons in `add.php`/`edit.php`** — `aria-label="Supprimer le lecteur·ice N"` already present on all remove buttons in `jury-fieldset.php` (both static and dynamically added rows)
|
|
||||||
|
|
||||||
## 2.4.3 Focus order
|
|
||||||
|
|
||||||
- [x] **On `tfe.php` the `← Retour` back link is at the bottom of the left column in DOM** — already fixed; `<a class="tfe-back-link">← Retour</a>` is the first child of `<header class="tfe-left">`, which precedes `<h1 class="tfe-title">` in DOM order
|
|
||||||
|
|
||||||
## 2.4.4 Link purpose
|
|
||||||
|
|
||||||
- [x] **Home page cards: if two theses share the same title, identical link texts exist** — `public/index.php` card `<p>` now appends `<span class="sr-only">, YEAR</span>` when `$item['year']` is set, giving screen-reader users a unique link name per thesis
|
|
||||||
|
|
||||||
## 2.5.3 Label in name
|
|
||||||
|
|
||||||
- [x] **`<a class="clear-filter">✕ Réinitialiser</a>`** — wrap `✕` in `<span aria-hidden="true">`
|
|
||||||
- [x] **Admin jury remove buttons `✕`** — `aria-label="Supprimer ce lecteur"` replaces the symbol
|
|
||||||
|
|
||||||
## 2.5.5 Target size
|
|
||||||
|
|
||||||
- [x] **Pagination buttons are `2rem` (32px)** — increase to `min-height: 2.75rem; min-width: 2.75rem` (44px)
|
|
||||||
- [x] **Admin `.admin-btn-sm` (~28px)** — increase to minimum 32px with padding
|
|
||||||
- [x] **Admin bulk action buttons and jury remove `✕` buttons (~28px)** — increase target size
|
|
||||||
|
|
||||||
## 3.1.1 Language of page
|
|
||||||
|
|
||||||
- [x] **All public pages have `<html lang="fr">`** — verified: `templates/head.php` line 2 has `<html lang="fr">`; all pages share this template. No action needed.
|
|
||||||
|
|
||||||
## 3.3.1 Error identification
|
|
||||||
|
|
||||||
- [x] **`add.php`/`edit.php` validation errors** — `flash-messages.php` already emits `<p role="alert" data-type="error">` for errors and `<p role="status">` for success
|
|
||||||
- [x] **`add.php`/`edit.php` `autofocus` on first invalid field** — `App::flashAutofocus(fieldName)` stores the failing field in `$_SESSION['_flash_autofocus']`; action handlers map exception messages to field names; `add.php` consumes via `withAutofocus()` helper + injects into `$attrs`; `edit.php` uses inline ternary; partials support boolean `true` in `$attrs` to emit bare attribute names
|
|
||||||
|
|
||||||
## 3.3.2 Labels or instructions
|
|
||||||
|
|
||||||
- [x] **Admin jury "Lecteur·ices" label has no `for` attribute** — replaced plain `<label>Lecteur·ices :</label>` with `<fieldset class="admin-jury-lecteurs"><legend>Lecteur·ices</legend>` in `jury-fieldset.php`; CSS rule strips the nested fieldset’s border/padding so it renders as a sub-group
|
|
||||||
|
|
||||||
## 4.1.2 Name, role, value
|
|
||||||
|
|
||||||
- [x] **Custom "Externe" checkbox for jury members has no group context** — all jury "Externe" checkboxes now carry explicit `aria-label` (e.g. `"Promoteur·ice — externe"`, `"Lecteur·ice N — externe"`); both static PHP-rendered rows and dynamically added rows via `addJuryRow()` receive the label
|
|
||||||
|
|
||||||
- [x] **`<video>` elements on `tfe.php` have no captions** — `<track kind="captions">` now emitted for each MP4 when a `.vtt` sidecar has been uploaded alongside it; N-th VTT file is paired with N-th video in document order. `formulaire.php` accepts `.vtt` uploads (MIME `text/vtt`, `file_type = 'caption'`); `media.php` serves VTT with correct `Content-Type`; admin `add.php` file-field hint documents the `.vtt` convention.
|
|
||||||
|
|
||||||
- [x] **Admin `<select>` for visibility/access in `edit.php` uses truncated option text** — removed `mb_strimwidth` call; option text now uses full description (`name — description`) so screen-reader accessible name is complete and unambiguous
|
|
||||||
|
|
||||||
- [x] **Bulk publish/unpublish JS does not announce result to screen readers** — action result is a full-page redirect to a flash message rendered by `flash-messages.php` which already emits `role="alert"` (error) / `role="status"` (success); no additional JS announcement needed
|
|
||||||
|
|
||||||
## 5 - Motion & user preferences
|
|
||||||
|
|
||||||
- [x] **`prefers-color-scheme` not respected** — `variables.css` now includes a `@media (prefers-color-scheme: dark)` block scoped to `body:not(.admin-body)`; overrides all semantic tokens (`--bg-*`, `--text-*`, `--border-*`, `--accent-*`, status colours) with dark equivalents; `--accent-primary` lightened to `#b87fd4` for 4.5:1 contrast on dark backgrounds; `search.css` `.search-error` hardcoded `#fff0f0`/`#c00` replaced with `--search-error-bg`/`--search-error-border`/`--search-error-color` variables also overridden in dark mode; admin pages unaffected (`.admin-body` preserves light-mode values)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# TODO Index
|
|
||||||
|
|
||||||
Pending tasks split by topic. Completed tasks have been removed.
|
|
||||||
|
|
||||||
| File | Topic |
|
|
||||||
|------|-------|
|
|
||||||
| [01-css-semantic-refactor.md](01-css-semantic-refactor.md) | CSS class audit, semantic HTML for templates (public + admin), inline style extraction, favicon |
|
|
||||||
| [02-php-components.md](02-php-components.md) | Form field partials, shared UI partials, controller extraction, backend maintenance |
|
|
||||||
| [03-system-cache.md](03-system-cache.md) | `system_cache` table, `SystemCache` class, `system.php` refactor |
|
|
||||||
| [04-accessibility.md](04-accessibility.md) | WCAG 2.1 AA — remaining failures grouped by success criterion |
|
|
||||||
Reference in New Issue
Block a user