Commit Graph

61 Commits

Author SHA1 Message Date
Pontoporeia
ff8e33727d admin: semantic HTML pass — checkbox fieldset, landmarks, dl/dt, autocomplete, inline styles
checkbox-list.php partial:
- Replace outer <div>/<label> with <div>/<span class="admin-row-label"> + inner
  <fieldset class="admin-checkbox-group"><legend class="sr-only"> to satisfy
  WCAG 1.3.1 (group label for multi-checkbox rows without duplicating visible text)
- Replace <div class="admin-checkbox-list"> with <ul>; each checkbox wrapped in <li>

admin.css:
- Drop .admin-checkbox-list; add .admin-body fieldset.admin-checkbox-group rules
  (border/padding reset so it doesn't inherit jury-fieldset box styling)
- Extend form-row label rule to span.admin-row-label
- .admin-inline-form + .admin-inline-form { margin-top:.35rem } replaces inline style
- .admin-input--inline / .admin-select--inline get width:160px (was inline style)
- .admin-tags-count + table th sizing via :has() replaces th inline styles

login.php: wrap content in <main id="main-content"> (missing landmark)

account.php:
- <div class="admin-account-status"> → <dl>; __label <span> → <dt>
- <div class="admin-danger-zone__description"> → <p>

index.php: <div class="admin-maintenance-bar"> → <aside role="status" aria-label="Statut du site">

add.php / edit.php: autocomplete="name" on author field, autocomplete="email" on
contact field (WCAG 1.3.5 / input purpose)

tags.php: all inline style= attributes removed (width, text-align, margin-top,
display:inline); all moved to CSS classes
2026-04-06 15:33:08 +02:00
Pontoporeia
f18e3381ea admin.css: rewrite from scratch using only variables.css tokens
The file had accumulated severe corruption in its lower half (garbled
selector text, variable names spliced into property values, orphaned
declarations, broken nesting) alongside hardcoded hex colours throughout.

Rewrote the entire file cleanly:
- Every colour is now a var() referencing a token defined in variables.css:
  --accent-primary/secondary/foreground, --accent-blue/green/yellow/red,
  --bg-secondary/tertiary, --border-primary, --text-primary/secondary/tertiary,
  --error, --warning, --success, --accent-muted.
- Zero raw hex values remain in admin.css.
- Removed the corrupted/dead CSS from the bottom half and reconstructed
  all selectors from what the templates actually use (audited via grep).
- Fixed structural issues: broken border shorthand, nested rules that
  were not valid CSS, orphaned declaration blocks.
- New/restored rules: .admin-maintenance-bar (was corrupted),
  .status-access variants (was corrupted), .admin-section-title--danger,
  .admin-danger-zone, .admin-account-status (all reconstructed cleanly).
- .admin-btn--warning and .admin-btn--danger now use var(--accent-yellow)
  and var(--accent-red) instead of hardcoded dark hex values.
- .admin-btn-remove hover now uses var(--error) instead of #e55.
- .admin-btn-unpublish now uses var(--bg-secondary)/var(--text-tertiary)
  instead of hardcoded grey hex values.
- select option background colours removed (browser chrome, not styleable
  cross-platform).

Templates: replace 4 inline var(--admin-text-muted) with var(--text-secondary)
in index.php, thanks.php, import.php.
2026-04-06 15:32:41 +02:00
Pontoporeia
871e919efa system.css: use only variables.css tokens, remove undefined custom properties
Replace the two undefined variables that had crept in:
- var(--admin-border) → #555  (in .log-output border)
- var(--admin-text-muted) → #969696  (inline style on log toolbar label,
  in both system.php and system-fragment.php)

Revert the incorrect intermediate attempt that mapped dark-UI hex values
to light-theme tokens (--bg-primary: #fff, --border-primary: #ddd, etc.)
and also revert the .admin-body override block that was added to
variables.css — variables.css is shared and must not have per-component
overrides.

All remaining var() calls in system.css now reference tokens that exist
in variables.css:
  --accent-primary, --accent-green, --error, --warning, --success,
  --text-tertiary
The dark surface colours (#1a1a1a, #242424, #0d0d0d, #555, #969696, etc.)
stay as literal hex values, consistent with how admin.css handles them.
2026-04-06 15:32:41 +02:00
Pontoporeia
b981223ff4 admin/system: fetch()-based tab switching, no full-page reload
Add system-fragment.php — a thin authenticated endpoint that returns only
the tab-panel HTML (toolbar + meta + log/nginx-config output) for a given
?tab=&n= combination. No page shell, no status section, no DB queries.

system.php changes:
- Tab <a> elements gain data-tab= attributes used by JS to identify the
  target without parsing hrefs.
- Tab panel content wrapped in <div id=sys-tab-panel data-tab= data-n=>
  which JS uses as both the swap target and its own state store.
- JS rewritten: tab clicks and lines-select changes call loadPanel()
  which fetch()es system-fragment.php, swaps innerHTML, updates active
  tab ARIA attributes, and pushes state via history.pushState.
- Browser back/forward handled via popstate listener.
- bindPanelControls() re-wires the lines-select and copy-to-clipboard
  button after every innerHTML swap (event delegation not feasible here
  because log-output is replaced wholesale).
- fetch() failure falls back to window.location.href (full page load).
- Tabs without JS still work: <a href> links go to system.php?tab=…
  as before.

system-fragment.php:
- Requires AdminAuth::isAuthenticated(); returns 403 on failure.
- Validates tab and n params against the same whitelist as system.php.
- All helper functions namespaced with frag_ prefix to avoid redeclaration
  if PHP ever includes both files in the same process.
- Renders identical HTML to the corresponding section in system.php.

system.css:
- #sys-tab-panel gets min-height:8rem and position:relative to prevent
  layout jump during fetch.
- .sys-panel-loading: opacity 0.4 + pointer-events:none + subtle
  diagonal-stripe ::after overlay with shimmer animation.
2026-04-06 15:32:41 +02:00
Pontoporeia
c86781b9be admin/system: move status panel above tabs, add collapse toggle
Status (services, PHP env, disk) is now always visible above the log/config
tab bar rather than being one of the tab targets:

- Status section rendered unconditionally above <nav class="sys-tabs">.
- Services grid, PHP info grid and disk bar grouped inside a collapsible
  <section> with a header row containing the cache-freshness badge and a
  toggle button (▲ Réduire / ▼ Développer).
- Collapse state persisted in localStorage so the preference survives
  page reloads (e.g. when switching log tabs).
- Tab bar now only contains the three log tabs + nginx config; the 'Statut'
  tab is removed. Legacy ?tab=status URLs fall through to nginx_access.
- PHP/disk sub-sections laid out in a 2-col grid inside the status panel;
  responsive single-col below 700px.
- system.css: new .sys-status-section / .sys-status-header /
  .sys-status-toggle / .sys-status-meta rules added.
- aria-current="page" added to active tab links.
- todo/03-system-cache.md: all items marked done; notes added explaining
  why log caching was deliberately omitted.
2026-04-06 15:32:41 +02:00
Pontoporeia
e1ce900113 a11y: WCAG 2.5.5 target sizes + 2.5.3 label-in-name fixes
Increase touch/click target sizes to meet WCAG 2.5.5 (minimum 44×44px
for navigation, 32px for admin UI controls):

- main.css / search.css: pagination buttons 2rem → min-height/min-width
  2.75rem (44px). Changed display to inline-flex for proper centering.
- admin.css: .admin-btn-sm gains min-height: 2rem (32px) and switches
  to inline-flex so the constraint is respected.
- admin.css: .admin-btn-remove (jury ✕ buttons) gains min-height: 2rem
  and inline-flex display + explicit cursor:pointer.

WCAG 2.5.3 label-in-name — jury remove buttons already had aria-label;
wrap the visible ✕ glyph in <span aria-hidden='true'> so screen readers
hear only the aria-label, not the symbol:

- templates/partials/form/jury-fieldset.php: all three ✕ occurrences
  (static PHP blocks + JS-generated innerHTML string) wrapped.

WCAG 4.1.2 / semantic HTML:
- admin/index.php: add role='toolbar' aria-label='Actions groupées' to
  the bulk-actions bar.
2026-04-06 15:32:41 +02:00
Pontoporeia
ba7814c6dc feat: system page caching via SystemCache + system_cache SQLite table
Add a TTL-based cache for the expensive checks on the admin system page,
eliminating repeated systemctl subprocess calls (~4×~100ms), curl self-pings
(~200-500ms), disk_*_space() and PHP ini reads on every page load.

Changes:
- storage/migrations/007_system_cache.sql: new migration creating the
  system_cache table (key TEXT PK, value TEXT, updated_at INTEGER)
- storage/schema.sql: system_cache table added before pages table
- Applied migration to live storage/posterg.db
- src/SystemCache.php: new class with get/set/isStale/ageSeconds/invalidate;
  uses SQLite INSERT … ON CONFLICT upsert; no external dependencies
- src/Database.php: added getDatabasePath(): string accessor
- public/admin/system.php:
  - Bootstrap SystemCache at request start using the existing DB PDO handle
  - system_status: cached with 2-min TTL (systemctl + curl checks)
  - php_info: cached with 1-hour TTL (PHP ini values are runtime-constant)
  - disk_info: cached with 5-min TTL (total/free/used/pct tuple)
  - Logs section: unchanged — always reads live log tail per active tab
  - ?refresh=1 GET param invalidates all three cache keys before rendering
  - Status panel heading shows cache badge: ' Cache — il y a Xs' (hit)
    or '⟳ Actualisé' (miss/fresh), styled via new .sys-cache-badge rules
- public/assets/css/system.css: .sys-cache-badge / --hit / --miss styles
2026-04-02 13:04:00 +02:00
Pontoporeia
592b1183db Unify flash messages: replace all legacy session key writes with App::flash()
All admin action files (account, tag, page, edit, visibility, maintenance,
publish, formulaire) now call App::flash('error'|'success', ...) instead of
writing to raw per-page session keys ($_SESSION['error'], 'admin_error',
'edit_error', 'admin_success', 'edit_success', 'form_error').

All admin display pages (add, edit, account, tags, pages, index) now include
templates/partials/flash-messages.php instead of manually reading and
unsetting the legacy session keys and inlining their own alert HTML.

App::consumeFlash() already drained all legacy key variants as a safety net,
so the partial works correctly whether called from pages that were already
migrated or any remaining stragglers. No behaviour change for end users.
2026-04-02 12:57:36 +02:00
Pontoporeia
77bfd2f8e3 Extract status-badge.php partial; replace inline badge markup in index.php and account.php
Add templates/partials/status-badge.php — a single reusable partial that
renders the <span class="status-badge …"> element for three badge types:

  'publish'  — Publié / En attente derived from a boolean is_published value
  'access'   — access-type label (Libre / Interne / Interdit) with slug-based
               CSS modifier class and appropriate symbol (○ ◑ ●)
  'ok'       — generic green/yellow boolean badge with caller-supplied labels
               (used for 'Active'/'Non configurée' and 'Présent'/'Absent' in
               account.php)

All three variants emit aria-label with a context prefix and wrap the
decorative symbol in aria-hidden="true" — behaviour identical to the
inline code they replace.

Callers set $badgeType + $badgeValue (+ optional $badgeOkLabel /
$badgeWarnLabel / $badgeContext) before the include; the partial unsets
all working variables after rendering so they do not bleed into the
including scope.

Files changed:
  templates/partials/status-badge.php  — new partial
  public/admin/index.php               — table status column now uses partial
                                         (removes 15 lines of inline if/else/php)
  public/admin/account.php             — two credential status rows now use partial
                                         (removes 8 lines of inline if/else)
2026-04-02 12:50:46 +02:00
Pontoporeia
2143869b1e Add admin form field partials and apply to add/edit forms
Four reusable PHP partials extracted to templates/partials/form/:

- text-field.php  — single-line input (text/number/url); wraps input+hint in div,
                    skips the inner wrapper when no hint is present. Supports $type,
                    $placeholder, $required, $attrs, $hint, $id overrides.
- select-field.php — <select> with leading empty option; matches $selected against
                    option id OR option name string (handles view-sourced data where
                    orientation/ap/finality come back as name strings, not FK ids).
- checkbox-list.php — checkbox group (languages, formats); renders .admin-checkbox-list
                    with typed-string comparison so int ids from DB match string values.
- file-field.php  — file input with accept/multiple/hint; appends [] to name when
                    $multiple is true.

Both add.php and edit.php rewritten to use the partials:
- ~15 repeated text-field divs collapsed to single-line include calls
- ~6 repeated select divs collapsed to single-line include calls
- 4 checkbox-list blocks collapsed to 2 calls each
- 3 file input blocks collapsed to single-line include calls
- Textarea fields (synopsis, context_note) kept inline — no partial for <textarea>
- Banner preview block in edit.php kept inline — conditional UI not generalised

Line count: add.php 251→93 (-158), edit.php 289→171 (-118)
2026-04-02 12:48:04 +02:00
Pontoporeia
c8a3cc0ff2 css: replace admin-form-row/admin-label/admin-input/select/textarea classes with semantic selectors
Remove five presentational classes from admin forms and replace with
structural CSS selectors scoped to .admin-form:

- .admin-form-row  → .admin-form > div:not(.admin-submit-wrap)
  Grid layout (260px label col + 1fr input col) applied directly to div
  children of the form; submit-wrap div excluded via :not().

- .admin-label     → .admin-form > div:not(.admin-submit-wrap) > label
  Scoped to the direct label child of each form row div; does not bleed
  into nested checkbox labels inside .admin-checkbox-list.

- .admin-input / .admin-select / .admin-textarea
  → .admin-form input:not([type=checkbox|radio|file|hidden|submit])
  → .admin-form select
  → .admin-form textarea
  Also extended to .admin-inline-form input/select (tags page) so the
  tags table inputs retain identical base styling and focus colour.

Templates updated: add.php, edit.php, login.php, account.php,
pages-edit.php, import.php, tags.php,
templates/partials/form/jury-fieldset.php — all class= attributes for
the five removed classes stripped.

import.php: added 'admin-form' class alongside 'admin-import-area' so
its single file-input row gets the grid row treatment; submit div was
already using admin-submit-wrap so it is correctly excluded.

No visual change — selectors target the same elements as before.
2026-04-02 12:42:49 +02:00
Pontoporeia
e9e012376d Replace .admin-alert BEM classes with semantic role/data-type attributes
- admin.css: replace .admin-alert / .admin-alert--error / .admin-alert--success
  selectors with [role="alert"][data-type="error"] and [role="status"][data-type="success"]
- All 10 admin templates updated: <div class="admin-alert admin-alert--{type}">
  becomes <p role="alert|status" data-type="error|success"> (or <div> for the
  import.php multi-item list that contains a <ul>)
- flash-messages.php partial updated to match
- WCAG benefit: role="alert" is an ARIA live region — errors are announced
  immediately by screen readers without focus movement (fixes WCAG 3.3.1, 4.1.2)
- role="status" (polite live region) used for success messages — announced
  without interrupting the user
- Removes two BEM modifier classes; CSS now targets element semantics directly
2026-04-02 12:35:23 +02:00
Pontoporeia
10b07393fe Extract jury-fieldset.php partial; deduplicate jury section from add.php and edit.php
The jury composition fieldset (président·e, promoteur·ice + external checkbox, dynamic
lecteur·ices list with JS add/remove) was copy-pasted verbatim between the two longest
admin forms.

- Created templates/partials/form/jury-fieldset.php
  - Consumes $juryPresident, $juryPromoteur, $juryPromoteurExt, $juryLecteurs[]
  - Handles both add-mode (falls back to old()/wasSelected() flash helpers) and
    edit-mode (pre-populates from DB-loaded variables)
  - $juryIdx initialised from max(count($juryLecteurs), 1) — correct for both modes
- add.php: 311 → 251 lines (-60); entire fieldset + <script> replaced with one require
- edit.php: 359 → 289 lines (-70); PHP variable extraction kept inline before require
2026-04-02 12:26:44 +02:00
Pontoporeia
0ab08f3aa0 admin.css: replace .admin-main, .admin-page-title, .admin-table, .admin-fieldset with semantic selectors
Replace four presentational class names in admin.css with structural selectors
that target native HTML elements already present in every admin template:

  .admin-main           → .admin-body main
  .admin-page-title     → .admin-body main > h1
  .admin-table          → .admin-body table
  .admin-fieldset       → .admin-body fieldset
  .admin-fieldset-legend → .admin-body legend

Also migrate the .admin-main > section / h2 / dl / dt / dd block to
.admin-body main > section so the thanks-page section styles survive.

Add .admin-body main > table { margin-top: 1.5rem } to absorb the inline
style="margin-top:1.5rem" that was on tags.php's <table class="admin-table">.

All 10 affected admin templates updated (add, edit, account, index, import,
pages, pages-edit, tags, system, thanks) — class attributes removed where
the element alone is now the selector.  Zero visual changes.
2026-04-02 12:16:59 +02:00
Pontoporeia
cb1ced535b Replace .admin-hint / .admin-field-hint with .admin-body form small
- admin.css: remove .admin-hint and .admin-field-hint class rules; add
  .admin-body form small with the same font-size/color/margin properties
  plus display:block so it stacks below sibling inputs; stub comment left
  where .admin-field-hint was to document the change
- add.php: 5× <p class="admin-hint"> → <small>
- edit.php: 3× <p class="admin-hint"> → <small>
- import.php: <div class="admin-hint"> → <small> (block hint below CSV input)
- pages-edit.php: class="admin-hint" removed from already-correct <small>
- account.php: <p class="admin-field-hint"> → <small>

Hint text is now styled purely via the semantic element selector; no class
required on any hint element in admin templates.
2026-04-01 17:31:11 +02:00
Pontoporeia
f208423e8d Extract system.php inline <style> and <script> to system.css / $extraJsInline
- Create public/assets/css/system.css with all 280 lines of CSS that were
  inline in system.php: tab bar, status cards, PHP info grid, disk bar,
  log viewer, nginx config viewer, and syntax-highlight classes.
- Disk bar dynamic values (width %, colour) moved from PHP-interpolated CSS
  rules to CSS custom properties (--disk-pct, --disk-color) set on the
  element via an inline style attribute; static .disk-bar rule in system.css
  consumes them via var().
- system.php JS block (tab-select auto-nav + copy-to-clipboard) moved to
  $extraJsInline heredoc; footer.php emits it before </body> — keeps it
  out of the document <head> and removes the bare <script> after </main>.
- system.php now sets $extraCss = ['/assets/css/system.css'] so head.php
  emits a proper <link> in <head>, consistent with all other admin pages.
- No behaviour change; system.php is now zero inline CSS/JS.
2026-04-01 17:24:36 +02:00
Pontoporeia
77576e966c Remove inline styles from admin templates; extract to admin.css utility classes
- login.php: removed style= on .admin-form-row and .admin-label (already covered
  by .admin-login-box scoped rules); extracted submit-wrap spacing and full-width
  button to .admin-login-box .admin-submit-wrap and .admin-login-box .admin-btn
- account.php: style="margin-top:3rem" on danger-zone heading moved to
  .admin-section-title--danger modifier; <span style="color:..."> replaced with
  <small> element styled via .admin-danger-zone__description small
- add.php / edit.php / pages-edit.php: all style="align-items:start" removed from
  .admin-form-row (redundant — already the CSS default at line 116 of admin.css);
  banner preview inline styles extracted to .admin-banner-preview / .admin-banner-preview img;
  add-jury button margin extracted to .admin-add-jury-btn; cancel links use .admin-cancel-link

Zero inline style= attributes remain in login, account, add, edit, pages-edit.
2026-04-01 16:55:29 +02:00
Pontoporeia
573747303f admin: semantic HTML improvements — dl stats, section cards, th scope
- admin/index.php: replace <div class="admin-stats"> with <dl>; inner
  <div class="admin-stat__number"> → <dd>, <div class="admin-stat__label"> → <dt>;
  use CSS order to keep number visually first; add scope="col" to all 9 <th> cells

- admin/thanks.php: replace all four <div class="admin-thesis-info"> wrappers
  with <section> elements; remove the class entirely; add scope="col" to
  the files table <th> cells

- admin/tags.php: add scope="col" to all 3 <th> cells

- admin/pages.php: add scope="col" to all 4 <th> cells

- admin.css: rename .admin-thesis-info selectors to .admin-main > section
  (element + context selector — no class needed); add display:flex +
  flex-direction:column to .admin-stat so CSS order property works correctly

Addresses TODO items: section X (admin-stats dl, th scope), XI (tags th scope),
XII (admin-thesis-info → section), XIII (pages.php th scope)
2026-04-01 16:50:53 +02:00
Pontoporeia
780b1b2a13 merge head/nav templates into unified head.php + header.php; semantic CSS for nav 2026-04-01 15:55:12 +02:00
Pontoporeia
3a42838cec consolidate admin/public templates: common.css base in admin, nav partial, remove duplicate CSS 2026-04-01 15:55:12 +02:00
Pontoporeia
877e322568 fix(import): set is_published=1 and map access_type_id on CSV import
Imported theses were invisible on the public site because:
1. is_published defaulted to 0 (schema default) — the INSERT never
   set it, so all imported rows stayed unpublished and were filtered
   out by v_theses_public (WHERE is_published = 1) and every public
   DB method.
2. The access column (CSV col 16 'Autorisation') was read into $access
   but never written to access_type_id — silently dropped.

Fix: INSERT now includes is_published = 1 and access_type_id (resolved
from access_types.name via ucfirst/strtolower normalisation, defaulting
to 1/Libre when the CSV cell is empty or unrecognised).
2026-04-01 15:55:12 +02:00
Pontoporeia
af06e09caa fix(import): skip rows with duplicate identifier instead of crashing 2026-04-01 15:55:12 +02:00
Pontoporeia
e5d0598208 fix: correct require_once path depth in admin action files 2026-04-01 15:55:12 +02:00
Pontoporeia
94f3fb6736 feat(admin): nav logo links back to public site; all nav links right-aligned
templates/admin/head.php:
  - admin-nav__logo now href="/" with target="_blank" rel="noopener noreferrer"
  - Left arrow prefix (← via &#8592;, aria-hidden) signals leaving admin
  - sr-only suffix "(site public, nouvel onglet)" for screen readers

public/admin/login.php:
  - Same treatment on the standalone login nav (was a bare <span>)

public/assets/css/admin.css:
  - admin-nav__list: flex:1 removed; margin-left:auto added
    → entire link list now right-justified inside the nav bar,
      mirroring the layout of the public site header
  - .admin-nav__logout { margin-left:auto } removed (no longer needed;
    logout is just the last item in a right-aligned list)
2026-04-01 15:55:12 +02:00
Pontoporeia
77cc3caa0a fix(a11y): status badges no longer colour-only; fix aria on ✕ buttons (WCAG 1.4.1, 2.5.3)
admin/index.php — status badges (WCAG 1.4.1 Use of Colour):
  - Published badge: prefix ● symbol (aria-hidden) + aria-label="Statut : Publié"
  - Pending badge:   prefix ◌ symbol (aria-hidden) + aria-label="Statut : En attente"
  - Access badges (Libre/Interne/Interdit): prefix ○/◑/● symbol per type (aria-hidden)
    + aria-label="Accès : [type]"; symbol chosen from a PHP map keyed on the slug
  Each badge now communicates its state through shape AND colour, not colour alone.

admin/index.php — ✕ Réinitialiser link (WCAG 2.5.3 / 1.1.1):
  - ✕ wrapped in <span aria-hidden="true"> so the decorative symbol is skipped by
    screen readers; accessible name remains "Réinitialiser"

admin/add.php + admin/edit.php — jury remove buttons (WCAG 2.5.3):
  - All four ✕ remove buttons (2 static template rows + 2 JS-generated innerHTML strings)
    given aria-label="Supprimer ce lecteur"; the bare ✕ Unicode character has no
    speech equivalent so the aria-label replaces rather than supplements the label
2026-04-01 15:55:12 +02:00
Pontoporeia
338782947c chore: vendor all CDN assets locally; reorganise assets into css/ and js/
All third-party assets are now self-hosted — zero external requests at runtime.

CSS (assets/css/):
  - modern-normalize.min.css  (was assets/)
  - common.css, admin.css, main.css, search.css, tfe.css, apropos.css  (was assets/)
  - easymde.min.css 2.20.0  (was cdn.jsdelivr.net)
  - font-awesome.min.css 4.7.0  (was maxcdn.bootstrapcdn.com; injected at runtime by EasyMDE)

JS (assets/js/):
  - easymde.min.js 2.20.0  (was cdn.jsdelivr.net)

Fonts (assets/fonts/fontawesome/):
  - fontawesome-webfont.{eot,woff2,woff,ttf,svg}, FontAwesome.otf 4.7.0

Path fixes:
  - common.css @font-face: ./fonts/ -> ../fonts/ (one level deeper)
  - font-awesome.min.css @font-face: ../fonts/ -> ../fonts/fontawesome/ (dedicated subdir)
  - pages-edit.php: autoDownloadFontAwesome:false added to EasyMDE init to
    suppress the runtime CDN injection that was still present inside easymde.min.js

Reference updates (all now absolute /assets/css/* or /assets/js/*):
  - templates/public/head.php: modern-normalize + common
  - templates/admin/head.php: modern-normalize + admin
  - public/admin/login.php: modern-normalize + admin (standalone head)
  - public/index.php, tfe.php, search.php, apropos.php, licence.php: extraCss paths
  - public/admin/pages-edit.php: extraCss + extraJs (font-awesome, easymde CSS/JS)

Nginx static-file location already covers .css/.js/.woff/.woff2/.ttf/.otf with
30-day cache headers — no nginx config change needed.
2026-03-31 15:44:48 +02:00
Pontoporeia
986945a347 fix(a11y): move pages-edit EasyMDE scripts to head/footer, add h1 to home, fix stale TODO items
- pages-edit.php: EasyMDE CDN JS URL moved to $extraJs (rendered by footer.php before </body>);
  inline EasyMDE init block moved to $extraJsInline, emitted by footer.php via new
  `<?php if (!empty($extraJsInline))` guard - fixes invalid <script> floating in <body> (WCAG 4.1.1)
- pages-edit.php: add <small> keyboard-trap hint below the editor textarea:
  'Appuyez sur Échap pour quitter l'éditeur au clavier.' (WCAG 2.1.2)
- templates/admin/footer.php: extend to support $extraJsInline (raw inline script string)
- index.php: add <h1 class="sr-only">Mémoires de l'ERG</h1> inside <main> so the page has
  a document heading (WCAG 2.4.6; h2 columns in search.php already had a sr-only h1)
- TODO.md: mark completed items as [x]: skip links (2.4.1), focus-visible / outline:none
  removal (2.4.7), search.php h1 + index.php h1 (2.4.6), pages-edit.php invalid HTML (4.1.1),
  EasyMDE keyboard trap hint (2.1.2)
2026-03-31 15:28:47 +02:00
Pontoporeia
5c00886db6 fix fgetcsv deprecation and apply pending DB migrations 2026-03-28 19:13:52 +01:00
Pontoporeia
4f5ff5a22c refactor: extract edit.php POST handler to actions/edit.php
edit.php was a 530-line file mixing form display, POST handling, file
uploads, and reference-data loading. This refactor splits it along the
same action-file pattern already used by formulaire.php, tag.php, and
page.php.

Changes:
- public/admin/actions/edit.php (new): standalone POST handler; auth
  guard, CSRF check, transaction, redirect with session flash messages
- public/admin/edit.php: display-only; reads edit_success/edit_error
  flash keys from session; form action points to actions/edit.php via
  a hidden thesis_id field instead of a query-string self-post
- src/Database.php: four new methods to remove all raw PDO from both
  files:
    - updateThesis(int, array): void  — UPDATE theses core fields
    - setThesisAuthors(int, array): void  — delete-then-reinsert authors
    - getThesisLanguageIds(int): array — SELECT language_id for form
    - getThesisFormatIds(int): array   — SELECT format_id for form
2026-03-28 18:08:23 +01:00
Pontoporeia
f20aab5f66 css: deduplicate html/body reset; fix pages-edit.php invalid HTML
Move the repeated 'html, body { margin:0; padding:0; height:100% }' block from
main.css, search.css, tfe.css, and apropos.css into the single canonical location
in common.css. All four public page stylesheets already load common.css first, so
the rule applies identically — no visual change.

Fix pages-edit.php invalid HTML: the EasyMDE <link rel=stylesheet> was placed
inside <body> (after head.php was already closed), which is invalid. Add an
$extraCss hook to templates/admin/head.php so pages can inject <link> tags into
<head> via an array variable, matching the pattern already used by the public
templates/public/head.php. Also add a symmetric $extraJs hook to
templates/admin/footer.php for future use. pages-edit.php now sets
$extraCss = ['easymde.min.css'] before requiring head.php; the EasyMDE JS
<script> and its inline init remain in <body> in the correct load order.
2026-03-28 17:00:57 +01:00
Pontoporeia
61ac3c002d refactor: encapsulate thesis creation SQL in Database::createThesis()
Move the raw identifier-generation query and the INSERT INTO theses /
INSERT INTO thesis_authors statements out of formulaire.php into two new
Database methods:

  generateThesisIdentifier(int $year): string
    – counts existing theses for the year inside the open transaction so
      concurrent workers cannot produce duplicate YYYY-NNN identifiers.

  createThesis(array $data): int
    – generates the identifier, INSERTs the thesis row, links the author
      via thesis_authors (author_order=1), returns the new thesis ID.

  getThesisIdentifier(int $id): string
    – fetches the stored identifier for a thesis ID; used by formulaire.php
      to reconstruct the upload path (storage/theses/YYYY/YYYY-NNN/).

formulaire.php now calls $db->createThesis([…]) + $db->getThesisIdentifier()
and no longer holds any raw PDO queries for the core thesis insert.
The $pdo local variable (previously $db->getPDO()) is removed entirely.

All four test suites (Unit, RateLimit, Integration, Security) pass.
2026-03-28 13:52:43 +01:00
Pontoporeia
e126e1a3b0 refactor: use encapsulated Database methods in formulaire.php and edit.php 2026-03-28 13:49:51 +01:00
Pontoporeia
1181cfa88b encapsulate raw PDO queries leaking from callers into Database.php methods
- Add getThesisAccessTypeId(int $id): ?int — replaces raw SELECT in tfe.php
- Add getCoverPathsForTheses(array $ids): array — replaces raw SELECT/IN query in index.php
- Add getFileVisibility(string $path): ?int — replaces raw join query in media.php
- Add getThesisBannerPath(int $id): ?string — replaces unparameterised SQL injection in
  edit.php (SELECT banner_path FROM theses WHERE id = $thesisId was interpolating $thesisId
  directly into the query string; now parameterised via prepared statement)
- Add getThesisRawFields(int $id): ?array — replaces raw SELECT license_id/access_type_id/
  context_note in edit.php
- Add getThesisCount(): int — replaces raw SELECT COUNT(*) in system.php

Callers updated: public/tfe.php, public/index.php, public/media.php,
public/admin/edit.php, public/admin/system.php
2026-03-28 13:32:34 +01:00
Pontoporeia
20e5f71634 Fix two backend correctness issues
- Wrap setThesisJury() in a transaction: the method did a DELETE then multiple
  INSERTs with no atomicity guarantee. A partial failure (e.g. findOrCreateSupervisor
  throwing) would leave the jury table with orphaned rows. The fix uses
  pdo->inTransaction() to avoid nesting when called from within an outer transaction,
  and performs beginTransaction/commit/rollBack otherwise.

- Replace raw PDO query in admin/thanks.php with db->getThesisFiles(): the file
  listing after TFE submission was manually preparing a SELECT on thesis_files
  instead of calling the existing Database::getThesisFiles() method. Removes the
  getPDO() call entirely from that file.
2026-03-28 13:28:24 +01:00
Pontoporeia
69e161ada3 fix(admin): stats bar always shows whole-DB counts, not filtered counts
admin/index.php showed "TFE total / Publiés / En attente" by running
array_filter() over the already-filtered $theses array returned by
getThesesList(). When any search or year filter was active the three
numbers reflected only the matching subset, making the stats misleading
(e.g. searching for a single student would show "1 total, 0 publiés").

Add Database::getThesesStats(): array — a single SQL aggregation query:
  SELECT COUNT(*), SUM(is_published), SUM(NOT is_published) FROM theses

This runs against the raw theses table with no filters, so the counters
always display the true whole-database figures regardless of what filter
the admin has active. admin/index.php now calls getThesesStats() and
reads $stats['total'], $stats['published'], $stats['pending'] instead
of the array_filter expressions.
2026-03-28 11:42:44 +01:00
Pontoporeia
2e277b104e refactor(Database): remove dead CRUD helpers and alias proliferation
Remove 5 unused ID-lookup helpers (getOrientationId, getAPProgramId,
getFinalityId, getLanguageId, getFormatId) — forms have always passed
FK ids directly from <select> elements; these methods were never called
outside import.php, which now uses inline PDO queries instead.

Collapse 13 alias methods down to the single canonical name for each:
  getAllOrientations, getAllAPPrograms, getAllFinalityTypes,
  getAllFormatTypes, getAllLanguages, getAllLicenseTypes,
  getUsedTags, findOrCreateTag

The short-name variants (getOrientations, getApPrograms, etc.) and
compat aliases (getUsedKeywords, findOrCreateKeyword, getAllLicenseTypes
delegating to getLicenseTypes) are deleted. All call-sites updated:
  - public/search.php: getOrientations→getAllOrientations, etc.
  - public/admin/import.php: findOrCreateKeyword→findOrCreateTag,
    thesis_keywords→thesis_tags, keyword_id→tag_id (fixes stale table
    reference from pre-migration-001 that bypassed the M2M rename)
  - tests/Unit/DatabaseTest.php: remove alias smoke-test (test 7)

Database.php: 948 → 848 lines (-100).
2026-03-28 11:35:23 +01:00
Pontoporeia
b0632b4772 fix(formulaire): remove htmlspecialchars from sanitize_string + delete dead $problematique
HTML-escaping at write time stores &amp;, &lt; etc. in the DB, corrupting full-text
search, tag matching, exports, and any non-HTML consumer. PDO parameterised queries
already prevent SQL injection; templates call htmlspecialchars() on output.

sanitize_string() now does strip_tags(trim()) only — matching the pattern already
used by edit.php which never had this bug.

Also deleted the dead $problematique variable (read from POST[problématique] but
never passed to any INSERT or used anywhere in the codebase).
2026-03-27 23:16:12 +01:00
Pontoporeia
42af4644c5 perf+a11y: WAL mode for SQLite, skip links, :focus-visible, .sr-only
SQLite performance (Database::__construct):
- PRAGMA journal_mode = WAL: eliminates full-DB read locks on write, safe
  for concurrent PHP-FPM workers
- PRAGMA synchronous = NORMAL: durable on commit without full fsync per write
- PRAGMA cache_size = -8000: ~8 MB page cache per connection

Accessibility foundation (WCAG 2.1 AA):
- common.css: add .sr-only utility, .skip-link (hidden until focused),
  global :focus-visible (2px purple outline, 2px offset),
  prefers-reduced-motion guard; remove bare outline:none from
  .site-search__input
- admin.css: same :focus-visible, skip-link, and motion guard scoped to
  admin purple; remove outline:none from .admin-input/.admin-select/
  .admin-textarea and .admin-filters select (both had :focus border rules
  already, so focus is still visually communicated)
- search.css: remove outline:none from .search-filter-select (already has
  :focus border-color rule)
- All 5 public pages (index, search, tfe, apropos, licence): add
  <a href="#main-content" class="skip-link"> as first child of <body>;
  add id="main-content" to <main>
- templates/admin/head.php: same skip link; aria-label="Navigation admin"
  on <nav>; id="main-content" on all 10 admin <main> elements

All 4 test suites pass (unit, integration, security, rate-limit).
2026-03-27 13:45:01 +01:00
Pontoporeia
e4be230a04 admin/system: add nginx config viewer tab
Add a 'nginx — config' tab to the Système admin page (system.php).

- Reads /etc/nginx/sites-available/posterg (live deployed config) first;
  falls back to nginx/posterg.conf (local reference copy) when the live
  path is inaccessible (e.g. in dev, or wrong permissions).
- Displays a colour-coded badge: green '● Config déployée' for live,
  amber '⚠ Référence locale' for the fallback.
- Renders the full config in the shared .log-output code block with
  line numbers (data-n gutter via CSS ::before) and lightweight nginx
  syntax colouring (comments grey, block keywords purple, directives blue).
- Reuses the existing copy-to-clipboard button.
- Tab routing: activeTab validation extended to accept 'nginx_config';
  log pre-loading guards skip when activeTab is 'nginx_config'.
- No remote execution: read-only, zero new attack surface.
2026-03-26 11:23:18 +01:00
Pontoporeia
37f3a07c6e admin: merge status + logs into unified system.php with instant tabs
Replace the separate /admin/status.php and /admin/logs.php pages with a
single /admin/system.php page organised around a tab bar.

- system.php — top-level tab bar: 'Statut' + one tab per log file
  (nginx accès, nginx erreurs, PHP-FPM).  Switching tabs is a plain
  href (?tab=…) so no JS required for navigation; the lines-selector
  SELECT triggers a location change on 'change' for instant reload
  without a submit button.
- Status tab preserves all existing service cards, PHP runtime grid,
  and disk-usage bar from the old status.php.
- Log tabs preserve line-count selector, file metadata bar, and
  per-line colour coding from the old logs.php.
- New: copy-to-clipboard button on each log output block (Clipboard
  API with textarea execCommand fallback).
- status.php / logs.php replaced with 301 redirect stubs so existing
  bookmarks and links keep working.
- templates/admin/head.php: 'Statut' + 'Journaux' nav items replaced
  with a single 'Système' item; active state covers all three page
  names for redirect compatibility.
2026-03-24 15:55:48 +01:00
Pontoporeia
20a633c0e2 Add admin account page for PHP password management
Implements the admin user management UI as a self-contained PHP password
change/set flow — no SSH or sudo required.

- public/admin/account.php: shows auth status (PHP hash present, credentials
  file path), password change form (requires current password when one exists,
  min 12 chars, confirm field), and a danger-zone form to delete the
  credentials file entirely
- public/admin/actions/account.php: CSRF-guarded POST handler; verifies
  current password via AdminAuth::login() before accepting a new one;
  generates bcrypt (cost 12) hash; writes config/admin_credentials.php
  atomically via a temp file + rename; regenerates session on success;
  redirects to /admin/login.php when credentials are deleted
- templates/admin/head.php: 'Compte' nav link added (active on account.php)
- public/assets/admin.css: .admin-account-status, .admin-section-title,
  .admin-field-hint, .admin-danger-zone component styles added

Note: the nginx htpasswd flow (manage-admin-users.sh) requires root on the
server and is intentionally kept as a CLI-only operation.
2026-03-24 15:52:00 +01:00
Pontoporeia
020bfa5a33 admin: add server log viewer; fix curl_close() PHP 8.5 deprecation in status.php
- public/admin/logs.php: new page tailing nginx error/access + PHP-FPM logs.
  Selector for log file and line count (50/100/200/500, default 100).
  Lines reversed (newest first), colour-coded by severity, numbered gutter.
  Graceful degradation when exec() unavailable or file unreadable (dev msg).

- templates/admin/head.php: 'Journaux' nav link added after 'Statut'.

- public/admin/status.php: remove curl_close() call deprecated in PHP 8.5
  (no-op since PHP 8.0); replace with unset($ch) to silence the warning
  that was leaking raw text above the page output.
2026-03-24 15:47:38 +01:00
Pontoporeia
c678b75494 Add admin server status page
New page /admin/status.php gives a real-time health dashboard:

- Services panel: nginx (systemctl), php-fpm (auto-detects versioned unit names),
  site HTTP ping (curl HEAD with latency), SQLite DB (exists/writable/row count/size),
  storage directory (writable, banner/cover file counts), maintenance-mode flag.
- PHP runtime panel: version, SAPI, memory_limit, upload_max_filesize, post_max_size,
  max_execution_time.
- Disk usage bar for the partition containing APP_ROOT (colour-coded: green/amber/red).
- All shell calls go through safeExec() which suppresses stderr and checks exit code;
  systemctl/curl unavailability degrades gracefully to 'unknown' without fatal errors.
- 'Statut' nav link added to templates/admin/head.php (active state on status.php).
2026-03-24 15:41:30 +01:00
Pontoporeia
92e344b757 feat: admin tag management, maintenance mode, TFE visibility states
Tags admin:
- Database: getAllTagsWithCount(), renameTag(), mergeTag(), deleteTag()
- public/admin/tags.php: table with inline rename/merge/delete forms, CSRF-guarded
- public/admin/actions/tag.php: routes on action=rename|merge|delete
- templates/admin/head.php: 'Mots-clés' nav link
- admin.css: admin-inline-form, admin-btn--sm/warning/danger variants

Maintenance mode:
- config/bootstrap.php: gate on MAINTENANCE_FLAG file; admin/ and maintenance.php exempt
- public/maintenance.php: 503 dark minimal page
- public/admin/actions/maintenance.php: enable/disable toggle
- public/admin/index.php: status bar with toggle button
- admin.css: admin-maintenance-bar styles

TFE Visibility (Libre/Interne/Interdit via existing access_type_id):
- migration 002_add_visibility.sql: seeds access_types if missing
- Database: setVisibility(), bulkSetVisibility(), getAccessTypes()
- public/media.php: blocks thesis files for access_type_id=3
- public/tfe.php: shows access_type, context_note; hides file panel for Interdit
- public/admin/edit.php: access_type_id select + context_note textarea; saves both
- public/admin/index.php: three-state badge (Libre/Interne/Interdit) per row
- public/admin/actions/visibility.php: single + bulk visibility action handler
- admin.css: status-access badge variants
2026-03-24 15:35:52 +01:00
Pontoporeia
0933137540 refactor: rename keywords→tags M2M (migration 001)
- migration 001_rename_keywords_to_tags.sql: CREATE tags/thesis_tags from keywords/thesis_keywords,
  copy data, drop old tables, rebuild indexes and views
- schema.sql: tags table, thesis_tags junction, updated indexes and v_theses_full/v_theses_public
- Database.php: findOrCreateTag(), getUsedTags() with proper JOIN; backwards-compat aliases;
  buildSearchConditions uses EXISTS subquery on thesis_tags+tags with vp. alias throughout
- admin/actions/formulaire.php: INSERT OR IGNORE INTO thesis_tags
- admin/edit.php: DELETE FROM thesis_tags + findOrCreateTag
- search.php: $kw['name'] (was $kw['keyword'])
- fixtures/CreateTestDatabase.php: tags/thesis_tags table names
2026-03-24 13:30:53 +01:00
Pontoporeia
cefceb046c feat: jury composition + banner image upload
- migration 004: thesis_supervisors.role + is_external; view adds jury_president/jury_promoteurs/jury_lecteurs
- migration 005: theses.banner_path; view exposes t.banner_path and t.license_id
- Database: getThesisJury(), setThesisJury(), setBannerPath()
- admin/add.php: jury fieldset (président/promoteur/lecteurs + externe checkboxes, JS add/remove rows); banner file input
- admin/edit.php: jury fieldset pre-populated from DB; banner preview + remove checkbox + upload; multipart form
- admin/actions/formulaire.php: parse jury fields → setThesisJury(); banner upload to banners/
- tfe.php: three conditional jury rows (président·e, promoteur·ice, lecteur·ices)
- schema.sql: updated thesis_supervisors, theses, v_theses_full, v_theses_public definitions
- admin.css: fieldset, jury-row, jury-entry, btn-remove styles
2026-03-24 13:25:23 +01:00
Pontoporeia
d87348c388 feat: licence page, admin pages editor, license types, gradient card placeholders, latest-year home view
- Feature 1: public /licence.php fetches 'licenses' page from DB, renders Markdown
- Feature 1: nav.php adds 'Licence' link with active state
- Feature 2: Database::getPage(), savePage(), getAllPages() methods
- Feature 2: bundled src/Parsedown.php (MIT, zero-dependency)
- Feature 2: apropos.php now renders 'about' page content from DB via Parsedown
- Feature 2: admin/pages.php (list) + admin/pages-edit.php (EasyMDE editor)
- Feature 2: admin/actions/page.php (auth+CSRF+validation+save)
- Feature 2: admin/head.php adds 'Pages statiques' nav link
- Feature 3: storage/schema.sql seeds 8 CC license types
- Feature 3: storage/migrations/003_seed_license_types.sql (applied to live DB)
- Feature 3: Database::getLicenseTypes() / getAllLicenseTypes()
- Feature 3: admin/add.php + formulaire.php: license_id field on add form
- Feature 3: admin/edit.php: license_id field on edit form with raw FK lookup
- Feature 3: tfe.php: shows 'Licence :' meta row when non-null
- Feature 6: main.css: .card__media--gradient styles
- Feature 6: index.php: deterministic HSL gradient placeholder cards
- Feature 6: Database::getLatestYearTheses() + getLatestPublishedYear()
- Feature 6: index.php default home = random latest-year theses with info label
2026-03-24 13:12:48 +01:00
Pontoporeia
1fb9644d5a fix favicon 404s: add <link rel=icon> to all pages, nginx 204 for /favicon.ico 2026-03-02 16:08:45 +01:00
Pontoporeia
2110d2b916 Redesign UI to match target design images
- Flat purple-gradient nav bar with POSTERG/RÉPERTOIRE/À PROPOS links
- Full-width search bar with icon, bottom-border only, below nav
- Home: white bg, media card grid (thumbnail + author/title label below)
- Répertoire: 4-column index (Années/Catégories/Étudiantes/Mots-clés)
- TFE: 2-column layout (large text left, media right)
- À Propos: 2-column, large monospace text, new apropos.php page
- Admin: dark theme (#1a1a1a), purple gradient nav, bottom-border inputs
- New shared partials: templates/nav.php, templates/search-bar.php
- Rewrote all CSS: common, main, search, tfe, apropos, admin
2026-02-24 23:34:17 +01:00
Pontoporeia
eaad740574 refactor: extract buildSearchConditions, add getThesesList, remove dead code, fix SearchTest
- Database: extract private buildSearchConditions(array $params): array shared by
  searchTheses() and countSearchResults(), eliminating ~80 lines of duplication;
  add array type hints to both public methods
- Database: add getThesesList(array $filters) and getAllYears() so admin/index.php
  no longer builds raw SQL inline
- admin/index.php: replace inline PDO query block with $db->getThesesList() /
  $db->getAllYears(); drop the now-unused $pdo local
- config/bootstrap.php: remove dead include_template() helper and the
  vendor/autoload.php Composer stub (no vendor/ directory exists)
- apps/: delete entire directory (leftover artefact, no code references it)
- tests/Integration/SearchTest.php: fix three searchTheses() calls from bare
  strings to proper array params to match the method signature (prevented TypeError)
2026-02-24 23:21:44 +01:00