merge banners into covers: remove banner field, migrate files, add covers to search/home/repertoire cards

This commit is contained in:
Pontoporeia
2026-05-08 10:46:02 +02:00
parent e3896811c4
commit f3d9615562
15 changed files with 198 additions and 407 deletions

241
TODO.md
View File

@@ -1,226 +1,21 @@
# XAMXAM TODO # XAMXAM TODO
## Extract shared TFE form partial (single source of truth) ## Completed
- [x] Create `templates/partials/form/form.php` — unified form with `$mode`-driven conditionals
- [x] Refactor `templates/admin/add.php` → thin wrapper setting variables + including form partial
- [x] Refactor `templates/admin/edit.php` → thin wrapper with unified `$oldFn` + form partial
- [x] Refactor `partage/index.php``renderShareLinkForm()` delegates to form partial
- [x] Test all three forms render correctly (add, edit, partage) — syntax verified, logic reviewed
## Fix password-protected share links — form never loads after password entry - [x] Merge banner images into cover images
- [x] `partage/index.php` — main GET handler: check `$_SESSION['share_verified_' . $slug]` before showing password gate; skip to form if already verified - [x] Migration 016: copy `storage/banners/*``storage/covers/`, insert `thesis_files` cover records, clear `banner_path`, remove banners dir
- [x] `partage/index.php` — add `error_log()` calls throughout password flow (gate entry, hash state, verification result, session check) for debugging - [x] Remove banner fieldset from edit form (`form.php`)
- [x] Remove banner fieldset from student submission form (`fieldset-files.php`: rename to couverture)
## Merge apropos editables into À propos page + remove charte + source code URL - [x] Update `ThesisEditController::save()` — remove banner upload/removal logic
- [x] `actions/apropos.php` — only `contacts`; removed credits, erg_url - [x] Update `ThesisCreateController::submit()` — remove `handleBannerUpload` call
- [x] `actions/page.php` — remove `charte` from allowed slugs - [x] Update `Database::handleCoverUpload()` — add webp support, raise limit to 20 MB
- [x] `contenus.php` (front controller) — filter pages to only show `about` + `licenses` - [x] Remove `Database::setBannerPath()`, `handleBannerUpload()`, `getThesisBannerPath()`
- [x] `templates/admin/contenus.php` — restored "Pages statiques" table - [x] Update `Database::deleteThesis()` / `bulkDeleteTheses()` — remove banner file cleanup
- [x] `contenus-edit.php` (front controller) — `about` slug loads contacts for unified edit - [x] `HomeController`: batch-load covers for all items, remove banner_path fallback
- [x] `templates/admin/contenus-edit.php``about_page` type: Markdown editor + contacts on one page - [x] `SearchController::handleSearch()`: batch-load covers, pass `$coverMap` to view
- [x] `templates/admin/apropos-groups-form.php` — reusable partial for contacts - [x] `SearchController::handleStudentPreview()`: load covers, pass `$coverMap` to partial
- [x] `Database.php` — simplified getAproposContent/saveAproposContent (contacts-only JSON) - [x] `TfeController::resolveOgImage()`: use cover file_type instead of banner_path
- [x] `storage/schema.sql` — removed credits, erg_url; only contacts remains - [x] `home.php`: use only `$coverMap` (no banner_path fallback)
- [x] `AboutController.php` — removed credits, sourceCode DB loading - [x] `search.php`: show cover thumbnail on result cards
- [x] `templates/public/about.php` — hardcoded source code URL, hardcoded credits HTML - [x] `student-preview.php`: use `$coverMap` instead of `banner_path`
- [x] `apropos.css` `.apropos-toc-source` styles - [x] Migration applied and file moved to `applied/`
## Duplicate TFE submission prevention (fixes)
- [x] `DuplicateThesisException` — typed exception carrying existing thesis metadata
- [x] `Database::findDuplicateThesis()` — year + author + normalised-title matching (exact, prefix, Levenshtein ≤10%)
- [x] `ThesisCreateController::submit()` — calls duplicate check before any DB write, throws `DuplicateThesisException`
- [x] `AppLogger::logDuplicate()` — dedicated log action (`status: duplicate`) for audit trail
- [x] `App::flash/consumeFlash` — extended to support `warning` type alongside `error`/`success`
- [x] `admin/actions/formulaire.php` — catches `DuplicateThesisException` separately; logs it; flashes HTML warning with link to existing thesis; repopulates form
- [x] `partage/index.php` — same catch block; plain-text warning (no admin link) surfaced on the student form via `flash-warning` banner; form repopulated
- [x] `toast.php` — renders `toast--warning` block
- [x] `admin.css``.toast--warning` style + link colour
- [x] `form.css``.flash-warning` style (partage form)
## Admin audit logging
- [x] `AdminLogger` class — JSON-lines to `/var/log/xamxam.log` (prod) or `storage/logs/admin.log` (dev), mirrors to `admin_audit_log` DB table
- [x] `admin_audit_log` DB table — created in schema + migrated
- [x] `share_links.is_archived` column — archive replaces delete; stats preserved
- [x] `ShareLink::archive()` — new method; `toggleActive` returns new state; `listActive()` / `listArchived()` split; `validateLink` blocks archived slugs
- [x] `actions/acces-etudiante.php` — delete→archive, all actions logged (create, toggle, set_password, archive)
- [x] `actions/publish.php` — publish/unpublish logged
- [x] `actions/delete.php` — delete / bulk-delete / delete-all logged
- [x] `actions/visibility.php` — visibility changes logged
- [x] `actions/export-csv.php` — CSV export logged
- [x] `actions/export-db.php` — DB export logged
- [x] `actions/edit.php` — TFE edit logged
- [x] `actions/formulaire.php` — TFE add from admin logged
- [x] `actions/tag.php` — rename/merge/delete logged
- [x] `actions/page.php` — static page edits logged
- [x] `actions/apropos.php` — à-propos edits logged
- [x] `actions/form-help.php` — form structure edits logged
- [x] `actions/access-request.php` — approve/reject logged
- [x] `actions/maintenance.php` — maintenance on/off logged
- [x] `actions/settings.php` — formulaire toggles, objet types, SMTP update logged
- [x] `actions/smtp-test.php` — SMTP test logged
- [x] `templates/admin/acces.php` — archive button, archived links collapsible section
- [x] `scripts/setup-server.sh` — provision `/var/log/xamxam.log` with correct ownership
## Multi-author support
- [x] `ThesisCreateController::validateAndSanitise()` — comma-split `auteurice`, sort alphabetically, build author entries array
- [x] `Database::createThesis()` — removed hardcoded `author_id` insert; authors linked via `setThesisAuthors()` instead
- [x] `ThesisEditController::save()` — authors sorted alphabetically before `setThesisAuthors()`
- [x] `Database::findDuplicateThesis()` — accepts `array` of author names, matches any shared author via `IN` + `DISTINCT`
- [x] File folder naming — slug generated from all authors alphabetically sorted (both create and edit)
- [x] `v_theses_full` GROUP_CONCAT — `ORDER BY a.name ASC` for deterministic alphabetical display
- [x] Migration `012_author_view_order.sql` — rebuilds view with alphabetical author ordering
## Fix remote 500s and broken TFE pages (post-deploy)
- [x] `migrations/pending/008_share_links_is_archived.sql``ALTER TABLE share_links ADD COLUMN is_archived` (missing on remote; breaks `acces.php`)
- [x] `migrations/pending/009_admin_audit_log.sql``CREATE TABLE admin_audit_log` (missing on remote)
- [x] `migrations/pending/010_smtp_notify_email.sql``ALTER TABLE smtp_settings ADD COLUMN notify_email` (missing on remote; breaks `parametres.php` via `SmtpRelay::getSettings()`)
- [x] `migrations/pending/011_thesis_files_sort_and_label.sql``ALTER TABLE thesis_files ADD COLUMN sort_order / display_label` (missing on remote; breaks every public TFE detail page)
- [x] `justfile` — added `deploy-migrate` recipe: SSHes to remote and runs `php migrations/run.php`
## Replace browser dialogs with `<dialog>` modals
- [x] `admin/index.php``alert()` (no selection) → `<dialog id="no-selection-dialog">`; `confirm()` bulk publish/unpublish → `<dialog id="bulk-confirm-dialog">`; `confirm()` bulk delete → `<dialog id="bulk-delete-dialog">`; `confirm()` single delete → `<dialog id="delete-thesis-dialog">`; inline `confirm()` on Dépublier button removed (no confirmation needed for reversible action)
- [x] `admin/tags.php``confirm()` merge → `<dialog id="merge-tag-dialog">`; `confirm()` delete → `<dialog id="delete-tag-dialog">`
- [x] `admin/acces-etudiante.php``confirm()` delete link → `<dialog id="delete-link-dialog">`
- [x] `admin/acces.php``confirm()` archive link → `<dialog id="archive-link-dialog">`
- [x] `admin/parametres.php``confirm()` enable maintenance → `<dialog id="enable-maintenance-dialog">`; `confirm()` delete all TFE → `<dialog id="delete-all-tfe-dialog">`; admin password `confirm()` kept with `TODO` comment
- [x] `admin/account.php` — admin password `confirm()` kept with `TODO` comment
- [x] `admin.css` — added `.admin-dialog--sm`, `.admin-dialog__alert`, `.admin-dialog__footer` styles
## Fix 403 on HTMX tab requests in parametres.php
- [x] `AdminAuth::requireLogin()` — now sets `$_SESSION[SESSION_KEY]` when accepting nginx Basic Auth credentials (was returning early without marking the session)
- [x] `AdminAuth::isAuthenticated()` — now falls back to `PHP_AUTH_PW` verification (same logic as `requireLogin`) so HTMX requests to `system-fragment.php` authenticate even before a session exists
## Duplicate warning display fixes
- [x] `toast-fragment.php` — 204 guard now also checks `warning`; warning was silently discarded before
- [x] `partage/index.php` — warning stored as plain text (no pre-escaping); `htmlspecialchars()` applied once at render; was double-encoded before
- [x] `partage/index.php``flash-warning` div gets `id` + `tabindex=-1`; inline JS scrolls and focuses it on load
- [x] `admin/footer.php``htmx:afterSettle` listener focuses `.toast--warning` after HTMX injects the toast fragment
## Sticky save/cancel buttons on edit page
- [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 column shift and data repair
- [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
- [x] Derive year from identifier when year column is empty
- [x] Auto-detect column-shifted CSV: when orientation/finality columns are empty but synopsis/context match known orientation/finality names, remap on import
- [x] Migration `013_fix_csv_column_shift.sql`: move orientation from synopsis→orientation_id, finality from context_note→finality_id for already-imported theses
- [x] Migration `013_fix_remarks_keywords.php`: move keywords from remarks→tags+thesis_tags for already-imported theses
## Support website-type TFE (URL instead of uploaded files)
- [x] Add `file_type = 'website'` support to `thesis_files` — URL stored in `file_path`, no filesystem upload (no schema change needed)
- [x] Admin add form: "Site web (URL)" field dynamically shown via HTMX when "Site web" format checked
- [x] Admin edit form: website URL field via HTMX toggle + recognize website (🌐 icon) in existing-files list
- [x] Student partage form: website URL field via HTMX toggle + HTMX script added
- [x] TFE detail page (`tfe.php`): render `website` type as iframe with sandbox in media section
- [x] `ThesisCreateController`: handle website URL in submit → `handleWebsiteUrl()` stores as thesis_files row
- [x] `ThesisEditController`: handle website URL in save → `handleWebsiteUrl()` replaces existing website row; delete-files skips unlink for URLs
- [x] Edit page: website rows deletable via same delete_files checkbox mechanism
- [x] File size 0 for website rows — hidden in edit list to avoid showing "0.00 MB"
- [x] HTMX fragment endpoint: `/admin/actions/format-website-fragment.php` + `/partage/format-website-fragment`
- [x] `checkbox-list.php` partial: optional `hxPost`/`hxTarget` for HTMX live update
- [x] Server-side initial render: pre-populate `#website-url-section` if "Site web" already checked
## 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
## Add spacing between form elements inside fieldsets
- [x] `common.css``fieldset > *:not(:last-child) { margin-bottom: var(--space-xs); }`
- [x] `admin.css``.param-form fieldset > * { margin-bottom: 0; }` to avoid double-spacing with flex gap
## 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
- [x] Replace old button definitions in admin.css, form.css, tfe.css, file-access.css, system.css with empty alias comments
- [x] Update all PHP templates to use new `.btn` classes (`btn btn--primary`, `btn btn--secondary`, `btn btn--danger`, etc.)
- [x] Update border-radius on pagination buttons to 10px for consistency
- [x] Exclude `storage/maintenance.flag` from rsync deploy and git
## Admin file export system
- [x] `ExportController`: add `getAllThesisFiles()`, `buildExportManifest()`, `createExportZip()` — gathers all thesis files, creates zip with files/ + manifest.json
- [x] `admin/actions/export-files.php` — thin dispatcher, streams zip, logs audit
- [x] `AdminLogger::logFilesExport()` — audit log entry for file exports
- [x] `templates/admin/index.php` — add "Exporter fichiers" button next to CSV export button
- [x] `Database::getAllThesisFilesForExport()` — query all thesis_files + identifier
- [x] `docs/export.md` — documentation en français pour administrateurs : fonctionnement, contenu du ZIP, procédure de restauration complète et partielle
## Validate & refactor TFE form fields (new spec)
- [x] Schema: add `exemplaire_baiu` and `exemplaire_erg` boolean columns to `theses`
- [x] Schema: add `jury_promoteur_ulb` boolean column to `thesis_supervisors`
- [x] Schema: persist `cc4r` checkbox in `theses` table
- [x] Schema: update `v_theses_full` view to include new columns + jury split (interne/externe/ULB)
- [x] Admin edit form: add `jury_points`, `remarks`, `exemplaire_baiu`, `exemplaire_erg` fields (Backoffice fieldset)
- [x] Admin edit form: add `promoteur_ulb` checkbox in jury fieldset
- [x] Admin edit form: reorder fields to match spec layout
- [x] Public TFE fiche: split lecteur·ice(s) into interne/externe
- [x] Public TFE fiche: add promoteur·ice ULB display
- [x] ThesisEditController: handle new fields in save()
- [x] ThesisCreateController: handle CC4r + is_ulb in jury methods
- [x] Database::updateThesis: include new columns (remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc4r)
- [x] Database::setThesisJury: include is_ulb column
- [x] Database::getThesisJury: include is_ulb in SELECT
- [x] StudentEmail: use new jury_lecteurs_internes/externes and jury_promoteurs_ulb columns
- [x] Recapitulatif: show promoteur·ice ULB and lecteur·ices interne/externe
- [x] Migration: `014_tfe_form_fields.sql` — ALTER + view rebuild
- [x] Fix `Call to undefined function old()` in admin edit page — define `old()` in `app/public/admin/edit.php` (was only in `add.php`)
- [x] Add Note contextuelle and Backoffice fieldsets to admin add form (matching edit form)
- [x] `Database::createThesis()` — add `context_note`, `remarks`, `jury_points`, `exemplaire_baiu`, `exemplaire_erg`, `cc4r` columns
- [x] `ThesisCreateController::validateAndSanitise()` — handle new admin-only fields
- [x] `ThesisCreateController::submit()` — pass new fields to `createThesis()`
- [x] Replace admin E-mail de confirmation fieldset with Contact interne in Backoffice section (add + edit)
- [x] Remove confirmation email sending from add/edit (admin never sent; student partage unchanged)
## Refactor form structure per spec (student vs admin)
- [x] Remove `jury_president` field from student-facing forms (edit keeps it as optional)
- [x] Jury: split into promoteur·ice interne, promoteur·ice ULB, lecteur·ice interne, lecteur·ice externe — each with +add button
- [x] AP filtering: student form hides PACS; admin form shows all APs
- [x] Language: add "autre" text field alongside predefined checkboxes
- [x] Duration: split into pages field + minutes field + annexes checkbox
- [x] Licence: removed from métadonnées fieldset, now only in degrés d'ouverture section
- [x] Degrés d'ouverture: généralités FIRST, then radio choice (Libre/Interne/Interdit), then licence dropdown + custom text + CC2r
- [x] CC4r → CC2r rename in form UI + `cc2r` POST name
- [x] Licence: custom licence text field added (`license_custom` DB column)
- [x] Généralités text editable via form help block (`fieldset_generalites`)
- [x] Libre option: hidden in student form when disabled in settings; always shown in admin
- [x] Contact: `contact_visible` field always present in TFE info; admin gets `contact_public` checkbox separately
- [x] Email confirmation: mandatory in student form, optional in admin
- [x] Fichiers: cover image hint updated to 4:3 ratio
- [x] All three form pages (admin add, admin edit, partage) updated
- [x] Controllers updated: `collectJuryMembers`, `validateAndSanitise`, `buildFileSizeInfo`, `license_custom`, `cc2r``cc4r` mapping
## Fix biome lint config + beforeunload-guard.js
- [x] `biome.json` — use `files.includes` negation patterns to exclude `htmx/overtype/sortable.min.js`
- [x] `justfile lint-biome` — lint entire `app/public/assets/js/` dir (no hardcoded file list)
- [x] `beforeunload-guard.js` — modernise: `var``const`/`let`, `function()`→arrow functions, `for` loop→`for…of`
## Fix admin-filters wrapping
- [x] `.admin-list-toolbar .admin-filters` — restore `flex-wrap: wrap`, add `min-width: 0`
- [x] Search input: `flex: 1 1 10rem; min-width: 10rem` so it grows but doesn't collapse
- [x] Selects: `flex: 1 1 7rem; min-width: 7rem` for graceful wrap
- [x] Buttons: `flex-shrink: 0` so they never compress
## Fix remote errors (2026-05-08)
- [x] `admin/index.php``fgetcsv()` enclosure `'\"'` (two chars) → `'"'` (single char); fatal on PHP 8.4
- [x] `AdminLogger::write()` — guard `error_log()` with `is_writable()` check; silently skips file logging when `/var/log/xamxam.log` is not writable (DB mirror still runs)
## Fix form field required states & missing fields per spec
- [x] Admin add: add `contact_public` checkbox (matching edit form)
- [x] Admin add + partage + admin edit: formats checkbox-list `$required = true`
- [x] All forms: jury promoteur·ice interne `required` attribute
- [x] All forms: jury lecteur·ice interne `required` attribute (at least one)
- [x] All forms: jury lecteur·ice externe `required` attribute (at least one)
- [x] All forms: licence select `$required = true`
- [x] Admin edit: add "E-mail de confirmation" fieldset
- [x] Partage: contact always visible (POST handler defaults `showContact` to true when no `contact_public` key present)
- [x] Partage: filter PACS from AP programs dropdown
- [x] Verify no duplicate asterisks on any field
- [x] Admin add: `contact_public` POST handling in ThesisCreateController for admin submissions
- [x] Server-side validation: formats required, jury members required, licence required (ThesisCreateController + ThesisEditController)
- [x] Autofocus mappings for new validation errors (format, jury, licence)

View File

@@ -0,0 +1,130 @@
<?php
/**
* Migration 016 — merge banners into covers
*
* 1. For every thesis that has a banner_path:
* a. Copy the file from storage/banners/<file> to storage/covers/<file>
* b. Insert a thesis_files row with file_type='cover'
* c. Clear theses.banner_path
* 2. Remove the now-empty storage/banners/ directory (best-effort).
*
* Safe to re-run: if a cover record already exists for a thesis, the banner
* migration for that thesis is skipped.
*/
defined('APP_ROOT') || define('APP_ROOT', dirname(__DIR__, 2));
defined('STORAGE_ROOT') || define('STORAGE_ROOT', APP_ROOT . '/storage');
$dbPath = APP_ROOT . '/storage/xamxam.db';
if (!file_exists($dbPath)) {
echo "ERROR: database not found at $dbPath\n";
exit(1);
}
$pdo = new PDO('sqlite:' . $dbPath);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA foreign_keys = ON');
$coverDir = STORAGE_ROOT . '/covers/';
$bannerDir = STORAGE_ROOT . '/banners/';
if (!is_dir($coverDir)) {
mkdir($coverDir, 0755, true);
echo "Created covers/ directory.\n";
}
// Fetch all theses with a non-null banner_path
$stmt = $pdo->query("SELECT id, banner_path FROM theses WHERE banner_path IS NOT NULL");
$rows = $stmt->fetchAll();
if (empty($rows)) {
echo "No banners to migrate.\n";
} else {
foreach ($rows as $row) {
$thesisId = (int)$row['id'];
$bannerPath = $row['banner_path']; // e.g. "banners/abc123.png"
// Skip if a cover record already exists for this thesis
$check = $pdo->prepare("SELECT id FROM thesis_files WHERE thesis_id = ? AND file_type = 'cover' LIMIT 1");
$check->execute([$thesisId]);
if ($check->fetch()) {
echo " Thesis $thesisId: cover record already exists — skipping banner migration.\n";
// Still clear banner_path so UI stays clean
$pdo->prepare("UPDATE theses SET banner_path = NULL WHERE id = ?")->execute([$thesisId]);
continue;
}
$srcAbs = STORAGE_ROOT . '/' . $bannerPath;
$filename = basename($bannerPath);
$dstAbs = $coverDir . $filename;
$dstRel = 'covers/' . $filename;
if (!file_exists($srcAbs)) {
echo " Thesis $thesisId: source file missing ($srcAbs) — inserting DB record with new path anyway, skipping file copy.\n";
} else {
if (!copy($srcAbs, $dstAbs)) {
echo " ERROR: could not copy $srcAbs$dstAbs — skipping thesis $thesisId.\n";
continue;
}
chmod($dstAbs, 0644);
echo " Thesis $thesisId: copied $bannerPath$dstRel\n";
}
// Determine MIME from extension
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$mime = match($ext) {
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
default => 'image/jpeg',
};
// Get file size
$size = file_exists($dstAbs) ? filesize($dstAbs) : 0;
// Insert cover record
$ins = $pdo->prepare(
"INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, sort_order)
VALUES (?, 'cover', ?, ?, ?, ?, 0)"
);
$ins->execute([$thesisId, $dstRel, $filename, $size, $mime]);
echo " Thesis $thesisId: inserted cover record → $dstRel\n";
// Clear banner_path
$pdo->prepare("UPDATE theses SET banner_path = NULL WHERE id = ?")->execute([$thesisId]);
echo " Thesis $thesisId: cleared banner_path.\n";
}
}
// Remove old banner files that were successfully copied
$remaining = glob($bannerDir . '*') ?: [];
$allClear = true;
foreach ($remaining as $f) {
$basename = basename($f);
if (file_exists($coverDir . $basename)) {
@unlink($f);
echo "Removed migrated banner file: banners/$basename\n";
} else {
echo "WARNING: banners/$basename has no corresponding cover — leaving in place.\n";
$allClear = false;
}
}
// Remove the now-empty banners/ directory (best-effort, ignoring .gitkeep)
if ($allClear && is_dir($bannerDir)) {
$leftovers = array_diff(scandir($bannerDir), ['.', '..', '.gitkeep']);
if (empty($leftovers)) {
// Remove .gitkeep if present, then the dir
$gitkeep = $bannerDir . '.gitkeep';
if (file_exists($gitkeep)) {
@unlink($gitkeep);
}
@rmdir($bannerDir);
echo "Removed banners/ directory.\n";
} else {
echo "WARNING: banners/ directory still has files after migration — leaving in place.\n";
}
}
echo "\nMigration 016 complete.\n";

View File

@@ -10,7 +10,7 @@
* - Parse and validate GET parameters (`page`, `year`) * - Parse and validate GET parameters (`page`, `year`)
* - Determine the display mode (default random-latest / year-filtered / paginated all) * - Determine the display mode (default random-latest / year-filtered / paginated all)
* - Run the appropriate Database queries * - Run the appropriate Database queries
* - Batch-load cover images for theses without a banner_path * - Batch-load cover images for displayed theses
* - Assemble OG / meta tag array * - Assemble OG / meta tag array
* - Return a flat array of view variables ready for template extraction * - Return a flat array of view variables ready for template extraction
* *
@@ -91,18 +91,11 @@ class HomeController
$totalItems = $this->db->countPublishedTheses(); $totalItems = $this->db->countPublishedTheses();
} }
// Batch-load cover images for theses that have no banner_path // Batch-load cover images for all displayed theses
if (!empty($itemsToLoad)) { if (!empty($itemsToLoad)) {
$needCover = array_column( $coverMap = $this->db->getCoverPathsForTheses(
array_filter( array_column($itemsToLoad, 'id')
$itemsToLoad,
static fn ($t) => empty($t['banner_path']),
),
'id',
); );
if (!empty($needCover)) {
$coverMap = $this->db->getCoverPathsForTheses($needCover);
}
} }
} catch (Exception $e) { } catch (Exception $e) {
error_log('HomeController: ' . $e->getMessage()); error_log('HomeController: ' . $e->getMessage());

View File

@@ -118,6 +118,7 @@ class SearchController
'results' => $results, 'results' => $results,
'validationError' => $validationError, 'validationError' => $validationError,
'baseParams' => $baseParams, 'baseParams' => $baseParams,
'coverMap' => $coverMap,
// Filter dropdowns // Filter dropdowns
'years' => $years, 'years' => $years,
@@ -225,6 +226,8 @@ class SearchController
exit(); exit();
} }
$coverMap = $this->db->getCoverPathsForTheses(array_column($theses, 'id'));
header('Cache-Control: public, max-age=300'); header('Cache-Control: public, max-age=300');
include APP_ROOT . '/templates/partials/student-preview.php'; include APP_ROOT . '/templates/partials/student-preview.php';
exit(); exit();

View File

@@ -9,7 +9,7 @@
* Responsibilities: * Responsibilities:
* - Validate the `id` GET parameter and load the thesis record * - Validate the `id` GET parameter and load the thesis record
* - Enforce publication visibility (redirect to index on 404) * - Enforce publication visibility (redirect to index on 404)
* - Resolve the OG image (banner → first image file) * - Resolve the OG image (cover file → first image file)
* - Build the complete OG / Twitter Card tag array * - Build the complete OG / Twitter Card tag array
* - Assemble the meta description from the synopsis * - Assemble the meta description from the synopsis
* - Collect WebVTT caption file paths for video pairing * - Collect WebVTT caption file paths for video pairing
@@ -155,16 +155,20 @@ class TfeController
} }
/** /**
* Resolve the OG image URL: banner_path → first image file → empty string. * Resolve the OG image URL: cover file → first image file → empty string.
* *
* @param array<int, array<string, mixed>> $files * @param array<int, array<string, mixed>> $files
*/ */
private function resolveOgImage(array $files, ?string $bannerPath): string private function resolveOgImage(array $files): string
{ {
if (!empty($bannerPath)) { // Prefer the dedicated cover
return self::BASE_URL . '/media.php?path=' . rawurlencode($bannerPath); foreach ($files as $file) {
if (($file['file_type'] ?? '') === 'cover') {
return self::BASE_URL . '/media.php?path=' . rawurlencode($file['file_path']);
}
} }
// Fall back to first image file
foreach ($files as $file) { foreach ($files as $file) {
$ext = strtolower(pathinfo($file['file_path'] ?? '', PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($file['file_path'] ?? '', PATHINFO_EXTENSION));
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) { if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) {
@@ -183,7 +187,7 @@ class TfeController
*/ */
private function buildOgTags(array $data, int $thesisId, string $metaDescription): array private function buildOgTags(array $data, int $thesisId, string $metaDescription): array
{ {
$ogImage = $this->resolveOgImage($data['files'] ?? [], $data['banner_path'] ?? null); $ogImage = $this->resolveOgImage($data['files'] ?? []);
$title = $data['title'] . (!empty($data['authors']) ? ' ' . $data['authors'] : ''); $title = $data['title'] . (!empty($data['authors']) ? ' ' . $data['authors'] : '');
$imageAlt = $data['title'] . (!empty($data['authors']) ? ' par ' . $data['authors'] : ''); $imageAlt = $data['title'] . (!empty($data['authors']) ? ' par ' . $data['authors'] : '');

View File

@@ -132,7 +132,7 @@ class ThesisCreateController
* 3. INSERT thesis row + link author (inside transaction) * 3. INSERT thesis row + link author (inside transaction)
* 4. Link jury, languages, formats, tags (inside transaction) * 4. Link jury, languages, formats, tags (inside transaction)
* 5. COMMIT * 5. COMMIT
* 6. Handle file uploads: cover, banner, thesis files (outside transaction) * 6. Handle file uploads: cover, thesis files (outside transaction)
* *
* @param array $post Sanitised $_POST array. * @param array $post Sanitised $_POST array.
* @param array $files $_FILES array. * @param array $files $_FILES array.
@@ -213,7 +213,6 @@ class ThesisCreateController
// ── 5. File uploads (outside transaction — filesystem ops) ──────────── // ── 5. File uploads (outside transaction — filesystem ops) ────────────
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null); $this->handleCoverUpload($thesisId, $files['couverture'] ?? null);
$this->db->handleBannerUpload($thesisId, $files['banner'] ?? null);
$this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null, $authorSlug, $post); $this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null, $authorSlug, $post);
// ── 6. Website URL — stored as thesis_files row ────────────────────── // ── 6. Website URL — stored as thesis_files row ──────────────────────

View File

@@ -320,20 +320,6 @@ class ThesisEditController
throw $e; throw $e;
} }
// ── Banner (outside transaction — filesystem op) ──────────────────────
if (isset($post['remove_banner'])) {
$currentBannerPath = $this->db->getThesisBannerPath($thesisId);
if ($currentBannerPath && defined('STORAGE_ROOT')) {
$absPath = STORAGE_ROOT . '/' . $currentBannerPath;
if (file_exists($absPath)) {
unlink($absPath);
}
}
$this->db->setBannerPath($thesisId, null);
} else {
$this->db->handleBannerUpload($thesisId, $files['banner'] ?? null);
}
// ── Cover image (outside transaction — filesystem op) ───────────────── // ── Cover image (outside transaction — filesystem op) ─────────────────
if (isset($post['remove_cover'])) { if (isset($post['remove_cover'])) {
$allFiles = $this->db->getThesisFiles($thesisId); $allFiles = $this->db->getThesisFiles($thesisId);

View File

@@ -1551,79 +1551,9 @@ class Database
} }
// ======================================================================== // ========================================================================
// BANNER METHODS // COVER METHODS (formerly also BANNER METHODS — banners merged into covers)
// ======================================================================== // ========================================================================
/**
* Set (or clear) the banner_path for a thesis.
*/
public function setBannerPath(int $thesisId, ?string $path): void
{
$stmt = $this->pdo->prepare(
'UPDATE theses SET banner_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
);
$stmt->execute([$path, $thesisId]);
}
/**
* Process a banner image upload for a thesis.
*
* Validates MIME type, extension, and file size, then saves the file to the
* banners/ directory under STORAGE_ROOT and calls setBannerPath().
*
* Returns the relative path (e.g. "banners/abc123.jpg") on success,
* or null if the file array is absent, has an error, fails validation,
* or cannot be moved.
*
* @param int $thesisId Target thesis ID
* @param array|null $uploadedFile Entry from $_FILES (e.g. $_FILES['banner'])
* @return string|null Relative path stored in the DB, or null
*/
public function handleBannerUpload(int $thesisId, ?array $uploadedFile): ?string
{
if (!$uploadedFile || ($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return null;
}
$allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
$allowedExts = ['jpg', 'jpeg', 'png', 'webp'];
$maxBytes = 5 * 1024 * 1024; // 5 MB
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($uploadedFile['tmp_name']);
$ext = strtolower(pathinfo($uploadedFile['name'], PATHINFO_EXTENSION));
if (!in_array($mimeType, $allowedMimes, true) ||
!in_array($ext, $allowedExts, true) ||
$uploadedFile['size'] > $maxBytes) {
error_log('handleBannerUpload: rejected ' . $uploadedFile['name'] . " ($mimeType, {$uploadedFile['size']} bytes)");
return null;
}
$bannerDir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/banners/' : null;
if (!$bannerDir) {
error_log('handleBannerUpload: STORAGE_ROOT not defined');
return null;
}
if (!file_exists($bannerDir)) {
mkdir($bannerDir, 0755, true);
}
$safeName = bin2hex(random_bytes(16)) . '.' . $ext;
$targetPath = $bannerDir . $safeName;
if (!move_uploaded_file($uploadedFile['tmp_name'], $targetPath)) {
error_log('handleBannerUpload: move_uploaded_file failed for ' . $uploadedFile['name']);
return null;
}
chmod($targetPath, 0644);
$relativePath = 'banners/' . $safeName;
$this->setBannerPath($thesisId, $relativePath);
error_log("handleBannerUpload: saved $relativePath");
return $relativePath;
}
// ======================================================================== // ========================================================================
// ENCAPSULATED QUERY HELPERS // ENCAPSULATED QUERY HELPERS
// ======================================================================== // ========================================================================
@@ -1658,20 +1588,6 @@ class Database
return $row !== false ? $row : null; return $row !== false ? $row : null;
} }
/**
* Return the banner_path for a thesis, or null.
* Used when we need just the banner path without the full view expansion.
*/
public function getThesisBannerPath(int $thesisId): ?string
{
$stmt = $this->pdo->prepare(
'SELECT banner_path FROM theses WHERE id = ? LIMIT 1'
);
$stmt->execute([$thesisId]);
$val = $stmt->fetchColumn();
return ($val !== false && $val !== null) ? (string)$val : null;
}
/** /**
* Batch-load cover file paths for a set of thesis IDs. * Batch-load cover file paths for a set of thesis IDs.
* Returns [thesis_id => file_path] for IDs that have a cover in thesis_files. * Returns [thesis_id => file_path] for IDs that have a cover in thesis_files.
@@ -1891,19 +1807,10 @@ class Database
/** /**
* Delete a single thesis and all its related data (cascade via FK). * Delete a single thesis and all its related data (cascade via FK).
* Also removes the banner file from disk if present. * Removes thesis files from disk (covers are stored in thesis_files and handled here).
*/ */
public function deleteThesis(int $thesisId): void public function deleteThesis(int $thesisId): void
{ {
// Clean up banner file
$bannerPath = $this->getThesisBannerPath($thesisId);
if ($bannerPath !== null) {
$fullPath = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/' . $bannerPath : null;
if ($fullPath && file_exists($fullPath)) {
@unlink($fullPath);
}
}
// Clean up thesis files from disk // Clean up thesis files from disk
$files = $this->getThesisFiles($thesisId); $files = $this->getThesisFiles($thesisId);
foreach ($files as $file) { foreach ($files as $file) {
@@ -1928,13 +1835,6 @@ class Database
// Clean up files for each thesis // Clean up files for each thesis
foreach ($thesisIds as $id) { foreach ($thesisIds as $id) {
$bannerPath = $this->getThesisBannerPath($id);
if ($bannerPath !== null) {
$fullPath = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/' . $bannerPath : null;
if ($fullPath && file_exists($fullPath)) {
@unlink($fullPath);
}
}
$files = $this->getThesisFiles($id); $files = $this->getThesisFiles($id);
foreach ($files as $file) { foreach ($files as $file) {
if (!empty($file['file_path']) && file_exists($file['file_path'])) { if (!empty($file['file_path']) && file_exists($file['file_path'])) {
@@ -2058,9 +1958,9 @@ class Database
return null; return null;
} }
$allowedMimes = ['image/jpeg', 'image/png']; $allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
$allowedExts = ['jpg', 'jpeg', 'png']; $allowedExts = ['jpg', 'jpeg', 'png', 'webp'];
$maxBytes = 10 * 1024 * 1024; // 10 MB $maxBytes = 20 * 1024 * 1024; // 20 MB
$finfo = new finfo(FILEINFO_MIME_TYPE); $finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($upload['tmp_name']); $mimeType = $finfo->file($upload['tmp_name']);

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

View File

@@ -83,9 +83,8 @@
// Files: edit mode // Files: edit mode
$filesMode = 'edit'; $filesMode = 'edit';
$currentCover = $currentCover ?? null; $currentCover = $currentCover ?? null;
$currentFiles = $currentFiles ?? []; $currentFiles = $currentFiles ?? [];
$currentBannerPath = $thesis['banner_path'] ?? null;
$currentContextNote = $currentContextNote ?? null; $currentContextNote = $currentContextNote ?? null;
// Website URL from existing files // Website URL from existing files

View File

@@ -3,7 +3,7 @@
* Shared partial — "Fichiers" fieldset (add / student submission mode). * Shared partial — "Fichiers" fieldset (add / student submission mode).
* *
* Order per spec: * Order per spec:
* 1. Image de bannière (optionnel) * 1. Image de couverture (optionnel)
* 2. Note d'intention (obligatoire) * 2. Note d'intention (obligatoire)
* 3. TFE (obligatoire) * 3. TFE (obligatoire)
* 4. Annexes éventuelles (optionnel) * 4. Annexes éventuelles (optionnel)
@@ -15,10 +15,10 @@
<legend>Fichiers</legend> <legend>Fichiers</legend>
<?php <?php
$name = 'banner'; $name = 'couverture';
$label = 'Image de bannière (optionnel) :'; $label = 'Image de couverture (optionnel) :';
$accept = 'image/jpeg,image/png,image/webp'; $accept = 'image/jpeg,image/png,image/webp';
$hint = 'JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 20 MB.'; $hint = 'JPG, PNG ou WEBP. Format 4:3 recommandé. Max 20 MB.';
include APP_ROOT . '/templates/partials/form/file-field.php'; include APP_ROOT . '/templates/partials/form/file-field.php';
?> ?>

View File

@@ -31,7 +31,6 @@
* bool $showContact — Contact checkbox fieldset * bool $showContact — Contact checkbox fieldset
* bool $showCoverPreview — cover image preview + remove checkbox * bool $showCoverPreview — cover image preview + remove checkbox
* bool $showExistingFiles — existing thesis files list (sortable, deletable) * bool $showExistingFiles — existing thesis files list (sortable, deletable)
* bool $showBannerPreview — banner image preview + remove checkbox
* bool $showContextNote — Note contextuelle fieldset * bool $showContextNote — Note contextuelle fieldset
* bool $showBackoffice — Backoffice fieldset (jury_points, remarks, contact_interne, exemplaires) * bool $showBackoffice — Backoffice fieldset (jury_points, remarks, contact_interne, exemplaires)
* bool $showEmailConfirmation — E-mail de confirmation fieldset * bool $showEmailConfirmation — E-mail de confirmation fieldset
@@ -44,7 +43,6 @@
* string $filesMode — 'add' | 'edit' (determines which file inputs to show) * string $filesMode — 'add' | 'edit' (determines which file inputs to show)
* ?string $currentCover — existing cover file info for edit mode * ?string $currentCover — existing cover file info for edit mode
* array $currentFiles — existing thesis files for edit mode * array $currentFiles — existing thesis files for edit mode
* ?string $currentBannerPath — existing banner path for edit mode
* ?string $currentContextNote — existing context note for edit mode * ?string $currentContextNote — existing context note for edit mode
* array $currentRaw — raw thesis row for edit mode * array $currentRaw — raw thesis row for edit mode
* ?string $currentAuthorShowContact — author show_contact flag for edit mode * ?string $currentAuthorShowContact — author show_contact flag for edit mode
@@ -84,7 +82,7 @@ $showFlash = $showFlash ?? false;
$showContact = $showContact ?? false; $showContact = $showContact ?? false;
$showCoverPreview = $showCoverPreview ?? false; $showCoverPreview = $showCoverPreview ?? false;
$showExistingFiles = $showExistingFiles ?? false; $showExistingFiles = $showExistingFiles ?? false;
$showBannerPreview = $showBannerPreview ?? false; $showBannerPreview = false; // Banners merged into covers — field removed
$showContextNote = $showContextNote ?? false; $showContextNote = $showContextNote ?? false;
$showBackoffice = $showBackoffice ?? false; $showBackoffice = $showBackoffice ?? false;
$showEmailConfirmation = $showEmailConfirmation ?? false; $showEmailConfirmation = $showEmailConfirmation ?? false;
@@ -249,11 +247,11 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
</label> </label>
</div> </div>
<?php endif; ?> <?php endif; ?>
<input type="file" id="couverture" name="couverture" accept="image/jpeg,image/png" data-preview="fp-couverture"> <input type="file" id="couverture" name="couverture" accept="image/jpeg,image/png,image/webp" data-preview="fp-couverture">
<div id="fp-couverture" class="file-preview-list" aria-live="polite"></div> <div id="fp-couverture" class="file-preview-list" aria-live="polite"></div>
<small><?= empty($currentCover) <small><?= empty($currentCover)
? "JPG, PNG. Format 4:3 recommandé. Max 20 MB." ? "JPG, PNG ou WEBP. Format 4:3 recommandé. Max 20 MB."
: "Laisser vide pour conserver la couverture actuelle. JPG, PNG. Max 20 MB." ?></small> : "Laisser vide pour conserver la couverture actuelle. JPG, PNG ou WEBP. Max 20 MB." ?></small>
</div> </div>
</div> </div>
@@ -377,27 +375,7 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
</div> </div>
</div> </div>
<!-- Banner image -->
<div class="admin-form-group">
<label>Image bannière (accueil) :</label>
<div class="admin-file-input">
<?php if (!empty($currentBannerPath)): ?>
<div class="admin-banner-preview">
<img src="/media.php?path=<?= urlencode(
$currentBannerPath,
) ?>" alt="Bannière actuelle">
<label class="admin-checkbox-label">
<input type="checkbox" name="remove_banner" value="1"> Supprimer la bannière
</label>
</div>
<?php endif; ?>
<input type="file" name="banner" id="banner" accept="image/jpeg,image/png,image/webp" data-preview="fp-banner">
<div id="fp-banner" class="file-preview-list" aria-live="polite"></div>
<small><?= empty($currentBannerPath)
? "JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 20 MB."
: "Laisser vide pour conserver la bannière actuelle. JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 20 MB." ?></small>
</div>
</div>
</fieldset> </fieldset>
<?php else: ?> <?php else: ?>
<?php <?php

View File

@@ -3,8 +3,9 @@
* Partial: student popover preview card(s). * Partial: student popover preview card(s).
* *
* Expected variables: * Expected variables:
* $theses array rows from Database::getThesesByAuthorName() * $theses array rows from Database::getThesesByAuthorName()
* $name string student name * $name string student name
* $coverMap array<int,string> thesis_id => cover file_path
*/ */
foreach ($theses as $t): foreach ($theses as $t):
@@ -20,8 +21,9 @@ foreach ($theses as $t):
]); ]);
?> ?>
<a href="/tfe?id=<?= (int)$t['id'] ?>" class="student-card"> <a href="/tfe?id=<?= (int)$t['id'] ?>" class="student-card">
<?php if (!empty($t['banner_path'])): ?> <?php $cover = $coverMap[$t['id']] ?? null; ?>
<div class="student-card__banner" style="background-image:url('<?= htmlspecialchars($t['banner_path']) ?>')"></div> <?php if ($cover): ?>
<div class="student-card__banner" style="background-image:url('/media?path=<?= urlencode($cover) ?>')"></div>
<?php else: ?> <?php else: ?>
<div class="student-card__banner student-card__banner--gradient"> <div class="student-card__banner student-card__banner--gradient">
<span class="student-card__gradient-author"><?= htmlspecialchars($t['authors'] ?? '') ?></span> <span class="student-card__gradient-author"><?= htmlspecialchars($t['authors'] ?? '') ?></span>

View File

@@ -14,13 +14,7 @@
<li class="card"> <li class="card">
<a href="/tfe?id=<?= (int)$item["id"] ?>"> <a href="/tfe?id=<?= (int)$item["id"] ?>">
<?php <?php
$thumb = null; $thumb = $coverMap[$item['id']] ?? null;
if (!empty($item['banner_path'])) {
$thumb = $item['banner_path'];
}
if (!$thumb && isset($coverMap[$item['id']])) {
$thumb = $coverMap[$item['id']];
}
?> ?>
<?php if ($thumb): ?> <?php if ($thumb): ?>
<figure> <figure>

View File

@@ -51,7 +51,15 @@
<?php if (!empty($results)): ?> <?php if (!empty($results)): ?>
<ul class="results-grid"> <ul class="results-grid">
<?php foreach ($results as $item): ?> <?php foreach ($results as $item): ?>
<li><a href="/tfe?id=<?= (int)$item['id'] ?>" class="result-card"> <?php $thumb = $coverMap[$item['id']] ?? null; ?>
<li><a href="/tfe?id=<?= (int)$item['id'] ?>" class="result-card<?= $thumb ? ' result-card--has-cover' : '' ?>">
<?php if ($thumb): ?>
<figure class="result-card__cover">
<img src="/media?path=<?= urlencode($thumb) ?>"
alt="Couverture — <?= htmlspecialchars($item['title']) ?>"
loading="lazy">
</figure>
<?php endif; ?>
<span class="result-card__authors"><?= htmlspecialchars($item['authors'] ?? '') ?></span> <span class="result-card__authors"><?= htmlspecialchars($item['authors'] ?? '') ?></span>
<span class="result-card__title"><?= htmlspecialchars($item['title']) ?></span> <span class="result-card__title"><?= htmlspecialchars($item['title']) ?></span>
<small class="result-card__meta"><?= htmlspecialchars($item['year']) ?><?php if (!empty($item['orientation'])): ?> · <?= htmlspecialchars($item['orientation']) ?><?php endif; ?></small> <small class="result-card__meta"><?= htmlspecialchars($item['year']) ?><?php if (!empty($item['orientation'])): ?> · <?= htmlspecialchars($item['orientation']) ?><?php endif; ?></small>