diff --git a/TODO.md b/TODO.md index 6426790..bb779ed 100644 --- a/TODO.md +++ b/TODO.md @@ -1,226 +1,21 @@ # XAMXAM TODO -## Extract shared TFE form partial (single source of truth) -- [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 +## Completed -## Fix password-protected share links — form never loads after password entry -- [x] `partage/index.php` — main GET handler: check `$_SESSION['share_verified_' . $slug]` before showing password gate; skip to form if already verified -- [x] `partage/index.php` — add `error_log()` calls throughout password flow (gate entry, hash state, verification result, session check) for debugging - -## Merge apropos editables into À propos page + remove charte + source code URL -- [x] `actions/apropos.php` — only `contacts`; removed credits, erg_url -- [x] `actions/page.php` — remove `charte` from allowed slugs -- [x] `contenus.php` (front controller) — filter pages to only show `about` + `licenses` -- [x] `templates/admin/contenus.php` — restored "Pages statiques" table -- [x] `contenus-edit.php` (front controller) — `about` slug loads contacts for unified edit -- [x] `templates/admin/contenus-edit.php` — `about_page` type: Markdown editor + contacts on one page -- [x] `templates/admin/apropos-groups-form.php` — reusable partial for contacts -- [x] `Database.php` — simplified getAproposContent/saveAproposContent (contacts-only JSON) -- [x] `storage/schema.sql` — removed credits, erg_url; only contacts remains -- [x] `AboutController.php` — removed credits, sourceCode DB loading -- [x] `templates/public/about.php` — hardcoded source code URL, hardcoded credits HTML -- [x] `apropos.css` — `.apropos-toc-source` styles - -## 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 `` modals -- [x] `admin/index.php` — `alert()` (no selection) → ``; `confirm()` bulk publish/unpublish → ``; `confirm()` bulk delete → ``; `confirm()` single delete → ``; inline `confirm()` on Dépublier button removed (no confirmation needed for reversible action) -- [x] `admin/tags.php` — `confirm()` merge → ``; `confirm()` delete → `` -- [x] `admin/acces-etudiante.php` — `confirm()` delete link → `` -- [x] `admin/acces.php` — `confirm()` archive link → `` -- [x] `admin/parametres.php` — `confirm()` enable maintenance → ``; `confirm()` delete all TFE → ``; 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 `

` -- [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) +- [x] Merge banner images into cover images + - [x] Migration 016: copy `storage/banners/*` → `storage/covers/`, insert `thesis_files` cover records, clear `banner_path`, remove banners dir + - [x] Remove banner fieldset from edit form (`form.php`) + - [x] Remove banner fieldset from student submission form (`fieldset-files.php`: rename to couverture) + - [x] Update `ThesisEditController::save()` — remove banner upload/removal logic + - [x] Update `ThesisCreateController::submit()` — remove `handleBannerUpload` call + - [x] Update `Database::handleCoverUpload()` — add webp support, raise limit to 20 MB + - [x] Remove `Database::setBannerPath()`, `handleBannerUpload()`, `getThesisBannerPath()` + - [x] Update `Database::deleteThesis()` / `bulkDeleteTheses()` — remove banner file cleanup + - [x] `HomeController`: batch-load covers for all items, remove banner_path fallback + - [x] `SearchController::handleSearch()`: batch-load covers, pass `$coverMap` to view + - [x] `SearchController::handleStudentPreview()`: load covers, pass `$coverMap` to partial + - [x] `TfeController::resolveOgImage()`: use cover file_type instead of banner_path + - [x] `home.php`: use only `$coverMap` (no banner_path fallback) + - [x] `search.php`: show cover thumbnail on result cards + - [x] `student-preview.php`: use `$coverMap` instead of `banner_path` + - [x] Migration applied and file moved to `applied/` diff --git a/app/migrations/applied/016_merge_banners_into_covers.php b/app/migrations/applied/016_merge_banners_into_covers.php new file mode 100644 index 0000000..5f9f9b5 --- /dev/null +++ b/app/migrations/applied/016_merge_banners_into_covers.php @@ -0,0 +1,130 @@ + to storage/covers/ + * 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"; diff --git a/app/src/Controllers/HomeController.php b/app/src/Controllers/HomeController.php index b11a740..34a0130 100644 --- a/app/src/Controllers/HomeController.php +++ b/app/src/Controllers/HomeController.php @@ -10,7 +10,7 @@ * - Parse and validate GET parameters (`page`, `year`) * - Determine the display mode (default random-latest / year-filtered / paginated all) * - 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 * - Return a flat array of view variables ready for template extraction * @@ -91,18 +91,11 @@ class HomeController $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)) { - $needCover = array_column( - array_filter( - $itemsToLoad, - static fn ($t) => empty($t['banner_path']), - ), - 'id', + $coverMap = $this->db->getCoverPathsForTheses( + array_column($itemsToLoad, 'id') ); - if (!empty($needCover)) { - $coverMap = $this->db->getCoverPathsForTheses($needCover); - } } } catch (Exception $e) { error_log('HomeController: ' . $e->getMessage()); diff --git a/app/src/Controllers/SearchController.php b/app/src/Controllers/SearchController.php index ba91d30..daf2b5c 100644 --- a/app/src/Controllers/SearchController.php +++ b/app/src/Controllers/SearchController.php @@ -118,6 +118,7 @@ class SearchController 'results' => $results, 'validationError' => $validationError, 'baseParams' => $baseParams, + 'coverMap' => $coverMap, // Filter dropdowns 'years' => $years, @@ -225,6 +226,8 @@ class SearchController exit(); } + $coverMap = $this->db->getCoverPathsForTheses(array_column($theses, 'id')); + header('Cache-Control: public, max-age=300'); include APP_ROOT . '/templates/partials/student-preview.php'; exit(); diff --git a/app/src/Controllers/TfeController.php b/app/src/Controllers/TfeController.php index a5e6a39..95256f9 100644 --- a/app/src/Controllers/TfeController.php +++ b/app/src/Controllers/TfeController.php @@ -9,7 +9,7 @@ * Responsibilities: * - Validate the `id` GET parameter and load the thesis record * - 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 * - Assemble the meta description from the synopsis * - 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> $files */ - private function resolveOgImage(array $files, ?string $bannerPath): string + private function resolveOgImage(array $files): string { - if (!empty($bannerPath)) { - return self::BASE_URL . '/media.php?path=' . rawurlencode($bannerPath); + // Prefer the dedicated cover + 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) { $ext = strtolower(pathinfo($file['file_path'] ?? '', PATHINFO_EXTENSION)); 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 { - $ogImage = $this->resolveOgImage($data['files'] ?? [], $data['banner_path'] ?? null); + $ogImage = $this->resolveOgImage($data['files'] ?? []); $title = $data['title'] . (!empty($data['authors']) ? ' – ' . $data['authors'] : ''); $imageAlt = $data['title'] . (!empty($data['authors']) ? ' par ' . $data['authors'] : ''); diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php index 06fe049..52fb860 100644 --- a/app/src/Controllers/ThesisCreateController.php +++ b/app/src/Controllers/ThesisCreateController.php @@ -132,7 +132,7 @@ class ThesisCreateController * 3. INSERT thesis row + link author (inside transaction) * 4. Link jury, languages, formats, tags (inside transaction) * 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 $files $_FILES array. @@ -213,7 +213,6 @@ class ThesisCreateController // ── 5. File uploads (outside transaction — filesystem ops) ──────────── $this->handleCoverUpload($thesisId, $files['couverture'] ?? null); - $this->db->handleBannerUpload($thesisId, $files['banner'] ?? null); $this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null, $authorSlug, $post); // ── 6. Website URL — stored as thesis_files row ────────────────────── diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 2e87940..83f503c 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -320,20 +320,6 @@ class ThesisEditController 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) ───────────────── if (isset($post['remove_cover'])) { $allFiles = $this->db->getThesisFiles($thesisId); diff --git a/app/src/Database.php b/app/src/Database.php index 71e46ab..f44a99d 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -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 // ======================================================================== @@ -1658,20 +1588,6 @@ class Database 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. * 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). - * 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 { - // 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 $files = $this->getThesisFiles($thesisId); foreach ($files as $file) { @@ -1928,13 +1835,6 @@ class Database // Clean up files for each thesis 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); foreach ($files as $file) { if (!empty($file['file_path']) && file_exists($file['file_path'])) { @@ -2058,9 +1958,9 @@ class Database return null; } - $allowedMimes = ['image/jpeg', 'image/png']; - $allowedExts = ['jpg', 'jpeg', 'png']; - $maxBytes = 10 * 1024 * 1024; // 10 MB + $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']; + $allowedExts = ['jpg', 'jpeg', 'png', 'webp']; + $maxBytes = 20 * 1024 * 1024; // 20 MB $finfo = new finfo(FILEINFO_MIME_TYPE); $mimeType = $finfo->file($upload['tmp_name']); diff --git a/app/storage/covers/f2f1ef60698383a1e6a93dc7719dc5c3.png b/app/storage/covers/f2f1ef60698383a1e6a93dc7719dc5c3.png new file mode 100644 index 0000000..a0824bd Binary files /dev/null and b/app/storage/covers/f2f1ef60698383a1e6a93dc7719dc5c3.png differ diff --git a/app/templates/admin/edit.php b/app/templates/admin/edit.php index 110426d..733b3ca 100644 --- a/app/templates/admin/edit.php +++ b/app/templates/admin/edit.php @@ -83,9 +83,8 @@ // Files: edit mode $filesMode = 'edit'; - $currentCover = $currentCover ?? null; - $currentFiles = $currentFiles ?? []; - $currentBannerPath = $thesis['banner_path'] ?? null; + $currentCover = $currentCover ?? null; + $currentFiles = $currentFiles ?? []; $currentContextNote = $currentContextNote ?? null; // Website URL from existing files diff --git a/app/templates/partials/form/fieldset-files.php b/app/templates/partials/form/fieldset-files.php index 3ce9df5..fd1a208 100644 --- a/app/templates/partials/form/fieldset-files.php +++ b/app/templates/partials/form/fieldset-files.php @@ -3,7 +3,7 @@ * Shared partial — "Fichiers" fieldset (add / student submission mode). * * Order per spec: - * 1. Image de bannière (optionnel) + * 1. Image de couverture (optionnel) * 2. Note d'intention (obligatoire) * 3. TFE (obligatoire) * 4. Annexes éventuelles (optionnel) @@ -15,10 +15,10 @@ Fichiers diff --git a/app/templates/partials/form/form.php b/app/templates/partials/form/form.php index 8419d4f..83bdb8c 100644 --- a/app/templates/partials/form/form.php +++ b/app/templates/partials/form/form.php @@ -31,7 +31,6 @@ * bool $showContact — Contact checkbox fieldset * bool $showCoverPreview — cover image preview + remove checkbox * bool $showExistingFiles — existing thesis files list (sortable, deletable) - * bool $showBannerPreview — banner image preview + remove checkbox * bool $showContextNote — Note contextuelle fieldset * bool $showBackoffice — Backoffice fieldset (jury_points, remarks, contact_interne, exemplaires) * bool $showEmailConfirmation — E-mail de confirmation fieldset @@ -44,7 +43,6 @@ * string $filesMode — 'add' | 'edit' (determines which file inputs to show) * ?string $currentCover — existing cover file info 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 * array $currentRaw — raw thesis row for edit mode * ?string $currentAuthorShowContact — author show_contact flag for edit mode @@ -84,7 +82,7 @@ $showFlash = $showFlash ?? false; $showContact = $showContact ?? false; $showCoverPreview = $showCoverPreview ?? false; $showExistingFiles = $showExistingFiles ?? false; -$showBannerPreview = $showBannerPreview ?? false; +$showBannerPreview = false; // Banners merged into covers — field removed $showContextNote = $showContextNote ?? false; $showBackoffice = $showBackoffice ?? false; $showEmailConfirmation = $showEmailConfirmation ?? false; @@ -249,11 +247,11 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? []; - +
+ ? "JPG, PNG ou WEBP. Format 4:3 recommandé. Max 20 MB." + : "Laisser vide pour conserver la couverture actuelle. JPG, PNG ou WEBP. Max 20 MB." ?> @@ -377,27 +375,7 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? []; - -
- -
- -
- Bannière actuelle - -
- - -
- -
-
+ thesis_id => cover file_path */ foreach ($theses as $t): @@ -20,8 +21,9 @@ foreach ($theses as $t): ]); ?> - -
+ + +