mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
merge banners into covers: remove banner field, migrate files, add covers to search/home/repertoire cards
This commit is contained in:
241
TODO.md
241
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 `<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)
|
||||
- [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/`
|
||||
|
||||
130
app/migrations/applied/016_merge_banners_into_covers.php
Normal file
130
app/migrations/applied/016_merge_banners_into_covers.php
Normal 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";
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<int, array<string, mixed>> $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'] : '');
|
||||
|
||||
|
||||
@@ -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 ──────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
BIN
app/storage/covers/f2f1ef60698383a1e6a93dc7719dc5c3.png
Normal file
BIN
app/storage/covers/f2f1ef60698383a1e6a93dc7719dc5c3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
@@ -85,7 +85,6 @@
|
||||
$filesMode = 'edit';
|
||||
$currentCover = $currentCover ?? null;
|
||||
$currentFiles = $currentFiles ?? [];
|
||||
$currentBannerPath = $thesis['banner_path'] ?? null;
|
||||
$currentContextNote = $currentContextNote ?? null;
|
||||
|
||||
// Website URL from existing files
|
||||
|
||||
@@ -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 @@
|
||||
<legend>Fichiers</legend>
|
||||
|
||||
<?php
|
||||
$name = 'banner';
|
||||
$label = 'Image de bannière (optionnel) :';
|
||||
$name = 'couverture';
|
||||
$label = 'Image de couverture (optionnel) :';
|
||||
$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';
|
||||
?>
|
||||
|
||||
|
||||
@@ -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 ?? [];
|
||||
</label>
|
||||
</div>
|
||||
<?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>
|
||||
<small><?= empty($currentCover)
|
||||
? "JPG, PNG. Format 4:3 recommandé. Max 20 MB."
|
||||
: "Laisser vide pour conserver la couverture actuelle. JPG, PNG. Max 20 MB." ?></small>
|
||||
? "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." ?></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -377,27 +375,7 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
|
||||
</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>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Expected variables:
|
||||
* $theses array rows from Database::getThesesByAuthorName()
|
||||
* $name string student name
|
||||
* $coverMap array<int,string> thesis_id => cover file_path
|
||||
*/
|
||||
|
||||
foreach ($theses as $t):
|
||||
@@ -20,8 +21,9 @@ foreach ($theses as $t):
|
||||
]);
|
||||
?>
|
||||
<a href="/tfe?id=<?= (int)$t['id'] ?>" class="student-card">
|
||||
<?php if (!empty($t['banner_path'])): ?>
|
||||
<div class="student-card__banner" style="background-image:url('<?= htmlspecialchars($t['banner_path']) ?>')"></div>
|
||||
<?php $cover = $coverMap[$t['id']] ?? null; ?>
|
||||
<?php if ($cover): ?>
|
||||
<div class="student-card__banner" style="background-image:url('/media?path=<?= urlencode($cover) ?>')"></div>
|
||||
<?php else: ?>
|
||||
<div class="student-card__banner student-card__banner--gradient">
|
||||
<span class="student-card__gradient-author"><?= htmlspecialchars($t['authors'] ?? '') ?></span>
|
||||
|
||||
@@ -14,13 +14,7 @@
|
||||
<li class="card">
|
||||
<a href="/tfe?id=<?= (int)$item["id"] ?>">
|
||||
<?php
|
||||
$thumb = null;
|
||||
if (!empty($item['banner_path'])) {
|
||||
$thumb = $item['banner_path'];
|
||||
}
|
||||
if (!$thumb && isset($coverMap[$item['id']])) {
|
||||
$thumb = $coverMap[$item['id']];
|
||||
}
|
||||
$thumb = $coverMap[$item['id']] ?? null;
|
||||
?>
|
||||
<?php if ($thumb): ?>
|
||||
<figure>
|
||||
|
||||
@@ -51,7 +51,15 @@
|
||||
<?php if (!empty($results)): ?>
|
||||
<ul class="results-grid">
|
||||
<?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__title"><?= htmlspecialchars($item['title']) ?></span>
|
||||
<small class="result-card__meta"><?= htmlspecialchars($item['year']) ?><?php if (!empty($item['orientation'])): ?> · <?= htmlspecialchars($item['orientation']) ?><?php endif; ?></small>
|
||||
|
||||
Reference in New Issue
Block a user