feat: extract MediaController, wire into Dispatcher, delete media.php

This commit is contained in:
Pontoporeia
2026-04-17 11:44:08 +02:00
parent b03be51b92
commit 75f808bee4
157 changed files with 1713 additions and 452 deletions

View File

@@ -0,0 +1,36 @@
# Security headers
<IfModule mod_headers.c>
# Prevent clickjacking
Header always set X-Frame-Options "SAMEORIGIN"
# Prevent MIME type sniffing
Header always set X-Content-Type-Options "nosniff"
# Enable XSS protection
Header always set X-XSS-Protection "1; mode=block"
# Referrer policy
Header always set Referrer-Policy "strict-origin-when-cross-origin"
# Content Security Policy (adjust as needed)
Header always set Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
</IfModule>
# Prevent directory listing
Options -Indexes
# Protect sensitive files
<FilesMatch "^\.">
Require all denied
</FilesMatch>
<FilesMatch "(composer\.(json|lock)|error\.log)$">
Require all denied
</FilesMatch>
# PHP security settings (if .htaccess can override)
<IfModule mod_php.c>
php_flag display_errors Off
php_flag log_errors On
php_value error_log error.log
</IfModule>

145
app/public/admin/README.md Normal file
View File

@@ -0,0 +1,145 @@
# Admin Panel Structure
This directory contains the admin panel for managing Post-ERG thesis database.
## Directory Structure
```
public/admin/
├── index.php # List all theses (main page)
├── add.php # Add new thesis form
├── edit.php # Edit existing thesis form
├── import.php # CSV import form
├── thanks.php # Thank you page after submission
├── actions/ # Backend processing scripts (no HTML output)
│ ├── formulaire.php # Process thesis submission from add.php
│ └── publish.php # Toggle publish/unpublish status
├── inc/ # Shared templates
│ ├── head.php # HTML head, CSS, navigation
│ └── footer.php # HTML footer
└── data/ # Upload directory (not in git)
├── theses/ # PDF files
└── covers/ # Cover images
```
## File Types
### User-Facing Templates (Root Directory)
Files that display HTML to users:
- **index.php** - Lists all theses with filters and bulk actions
- **add.php** - Form to add a new thesis
- **edit.php** - Form to edit an existing thesis
- **import.php** - CSV import interface
- **thanks.php** - Success confirmation page
### Backend Scripts (actions/)
Files that process forms and redirect (no HTML output):
- **formulaire.php** - Processes thesis submission from add.php
- **publish.php** - Handles publish/unpublish actions
### Shared Templates (inc/)
Reusable HTML components:
- **head.php** - HTML head, CSS links, navigation menu
- **footer.php** - HTML footer
## Workflow
### Adding a Thesis
1. User visits `add.php` (displays form)
2. User submits form to `actions/formulaire.php` (processes data)
3. On success, redirects to `thanks.php?id=123`
4. On error, redirects back to `add.php` with error message
### Publishing/Unpublishing
1. User clicks publish/unpublish button in `index.php`
2. Form submits to `actions/publish.php` (processes action)
3. Redirects back to `index.php` with success/error message
## Security
- All pages require HTTP Basic Auth (configured in nginx) — primary layer
- All pages require PHP session auth (`AdminAuth::requireLogin()`) — defence-in-depth
- CSRF tokens protect all forms
- File uploads validated and sanitized
- Database queries use prepared statements
- Upload directory outside public/ in production
See `nginx/PHP_AUTH_LAYER.md` for details on the dual-auth architecture.
## Templates
The `inc/` folder contains shared templates:
- `head.php` - Included at the top of each page (DOCTYPE, CSS, nav)
- `footer.php` - Included at the bottom of each page (closing tags)
Usage:
```php
<?php include "inc/head.php" ?>
<!-- Page content here -->
<?php include "inc/footer.php" ?>
```
## URL Structure
- `/admin/` - List theses (index.php)
- `/admin/add.php` - Add new thesis
- `/admin/edit.php?id=123` - Edit thesis #123
- `/admin/import.php` - Import CSV
- `/admin/thanks.php?id=123` - Thank you page
Backend actions (not directly accessed):
- `/admin/actions/formulaire.php` - Form processor
- `/admin/actions/publish.php` - Publish toggle
## Development
### Adding a New Page
1. Create the template in `/admin/yourpage.php`:
```php
<?php
require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../lib/AdminAuth.php';
AdminAuth::requireLogin();
$pageTitle = "Your Page Title";
?>
<?php include "inc/head.php" ?>
<!-- Your content here -->
<?php include "inc/footer.php" ?>
```
2. Add navigation link in `inc/head.php` if needed
### Adding a New Action
1. Create the script in `/admin/actions/youraction.php`:
```php
<?php
require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../lib/AdminAuth.php';
AdminAuth::requireLogin();
// Verify CSRF token
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
$_SESSION['error'] = "Security error";
header('Location: ../index.php');
exit;
}
// Process action...
// Redirect
header('Location: ../yourpage.php');
exit;
```
2. Create form in template that posts to `actions/youraction.php`
## Notes
- Bootstrap path from actions/: `__DIR__ . "/../../config/bootstrap.php"`
- Redirects from actions/: use `../` prefix (e.g., `../index.php`)
- Database class: `require_once __DIR__ . '/../../lib/Database.php'`
- All forms must include CSRF token from `$_SESSION['csrf_token']`

View File

@@ -0,0 +1,201 @@
<?php
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
require_once __DIR__ . '/../../src/ShareLink.php';
App::adminGuard();
$shareLink = ShareLink::make();
$links = $shareLink->listAll();
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$baseUrl = $protocol . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
$pageTitle = 'Accès étudiant·e';
$isAdmin = true;
$bodyClass = 'admin-body';
?>
<?php require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<div class="admin-list-toolbar">
<h1>Accès étudiant·e</h1>
<div class="admin-list-toolbar__right">
<button type="button" class="admin-btn admin-btn--sm" id="open-create-dialog">
Créer un lien
</button>
</div>
</div>
<?php if (empty($links)): ?>
<p class="admin-empty">Aucun lien d'accès créé. Cliquez sur « Créer un lien » pour générer un lien partageable.</p>
<?php else: ?>
<table>
<thead>
<tr>
<th scope="col">Lien</th>
<th scope="col">Statut</th>
<th scope="col">Mot de passe</th>
<th scope="col">Utilisations</th>
<th scope="col">Expiration</th>
<th scope="col">Créé le</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($links as $link): ?>
<?php
$isExpired = $link['expires_at'] !== null && strtotime($link['expires_at']) < time();
$isActive = (bool)$link['is_active'] && !$isExpired;
$statusLabel = $isExpired ? 'Expiré' : ($link['is_active'] ? 'Actif' : 'Désactivé');
if ($isExpired) {
$statusClass = 'status-badge status-pending';
} elseif ($link['is_active']) {
$statusClass = 'status-badge status-published';
} else {
$statusClass = 'status-badge';
$statusClass .= ' style="background:var(--error-muted-bg);color:var(--error);"';
}
$fullUrl = $baseUrl . '/partage/' . htmlspecialchars($link['slug']);
$created = date('d/m/Y H:i', strtotime($link['created_at']));
$expires = $link['expires_at'] ? date('d/m/Y', strtotime($link['expires_at'])) : '—';
$hasPassword = !empty($link['password_hash']);
?>
<tr>
<td>
<code style="font-size:var(--step--2);color:var(--text-secondary);"><?= htmlspecialchars($link['slug']) ?></code>
<input type="hidden" id="url-<?= $link['id'] ?>" value="<?= $fullUrl ?>">
</td>
<td>
<?php if ($isExpired): ?>
<span class="status-badge status-pending"><?= $statusLabel ?></span>
<?php elseif ($link['is_active']): ?>
<span class="status-badge status-published"><?= $statusLabel ?></span>
<?php else: ?>
<span style="display:inline-block;padding:var(--space-3xs) var(--space-2xs);border-radius:3px;font-size:var(--step--2);font-weight:500;letter-spacing:0.04em;background:var(--error-muted-bg);color:var(--error);"><?= $statusLabel ?></span>
<?php endif; ?>
</td>
<td><?= $hasPassword ? '🔒 Oui' : 'Non' ?></td>
<td style="text-align:center;"><?= intval($link['usage_count']) ?></td>
<td><?= $expires ?></td>
<td><?= $created ?></td>
<td>
<div class="admin-actions">
<button type="button" class="admin-btn-sm admin-btn-view"
onclick="copyUrl(<?= $link['id'] ?>)" title="Copier l'URL">
Copier
</button>
<form method="post" action="actions/acces-etudiante.php" class="publish-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="id" value="<?= $link['id'] ?>">
<button type="submit"
class="admin-btn-sm <?= $link['is_active'] ? 'admin-btn-unpublish' : 'admin-btn-publish' ?>"
title="<?= $link['is_active'] ? 'Désactiver' : 'Activer' ?>">
<?= $link['is_active'] ? '⏸' : '▶' ?>
</button>
</form>
<button type="button" class="admin-btn-sm admin-btn-edit"
onclick="openPasswordDialog(<?= $link['id'] ?>, <?= $hasPassword ? 'true' : 'false' ?>)"
title="Modifier le mot de passe">
🔑
</button>
<form method="post" action="actions/acces-etudiante.php" class="publish-form"
onsubmit="return confirm('Supprimer ce lien ? Les soumissions via ce lien seront bloquées.')">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= $link['id'] ?>">
<button type="submit" class="admin-btn-sm admin-btn-delete" title="Supprimer">
🗑
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</main>
<!-- ═══════════════════════ CREATE DIALOG ═══════════════════════ -->
<dialog id="create-dialog" class="admin-dialog" aria-labelledby="create-dialog-title">
<div class="admin-dialog__header">
<h2 id="create-dialog-title">Créer un lien d'accès</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="document.getElementById('create-dialog').close()">&#x2715;</button>
</div>
<form method="post" action="actions/acces-etudiante.php" class="admin-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="create">
<div>
<label for="create-password">Mot de passe (optionnel)</label>
<input type="password" id="create-password" name="password" autocomplete="new-password">
<small>Laissez vide pour un lien sans mot de passe.</small>
</div>
<div>
<label for="create-expires">Expiration (optionnel)</label>
<input type="datetime-local" id="create-expires" name="expires_at">
<small>Laissez vide pour qu'il n'expire jamais.</small>
</div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Créer le lien</button>
<button type="button" class="admin-btn-secondary"
onclick="document.getElementById('create-dialog').close()">Annuler</button>
</div>
</form>
</dialog>
<!-- ═══════════════════════ PASSWORD DIALOG ═══════════════════════ -->
<dialog id="password-dialog" class="admin-dialog" aria-labelledby="password-dialog-title">
<div class="admin-dialog__header">
<h2 id="password-dialog-title">Mot de passe</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="document.getElementById('password-dialog').close()">&#x2715;</button>
</div>
<form method="post" action="actions/acces-etudiante.php" class="admin-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="set_password">
<input type="hidden" name="id" id="password-link-id" value="">
<div>
<label for="password-input">Nouveau mot de passe</label>
<input type="password" id="password-input" name="password" autocomplete="new-password">
<small>Laissez vide pour supprimer le mot de passe.</small>
<p id="password-current-info" style="font-size:var(--step--2);color:var(--text-secondary);margin-top:var(--space-2xs);"></p>
</div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Enregistrer</button>
<button type="button" class="admin-btn-secondary"
onclick="document.getElementById('password-dialog').close()">Annuler</button>
</div>
</form>
</dialog>
<script>
document.getElementById('open-create-dialog').addEventListener('click', () => {
document.getElementById('create-dialog').showModal();
});
function copyUrl(id) {
const input = document.getElementById('url-' + id);
navigator.clipboard.writeText(input.value).then(() => {
const btn = event.target.closest('button');
const orig = btn.textContent;
btn.textContent = '✓ Copié';
setTimeout(() => { btn.textContent = orig; }, 1200);
});
}
function openPasswordDialog(id, hasPassword) {
document.getElementById('password-link-id').value = id;
const info = document.getElementById('password-current-info');
info.textContent = hasPassword
? 'Un mot de passe est actuellement configuré. Entrez-en un nouveau ou laissez vide pour le supprimer.'
: 'Aucun mot de passe configuré.';
document.getElementById('password-dialog').showModal();
}
</script>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,108 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
$pageTitle = "Compte administrateur";
$hasPassword = AdminAuth::hasPassword();
// Flash messages are consumed by the flash-messages partial below.
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<h1>Compte administrateur</h1>
<!-- Status info -->
<dl class="admin-account-status">
<div class="admin-account-status__row">
<dt class="admin-account-status__label">Authentification PHP</dt>
<dd><?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Active'; $badgeWarnLabel = 'Non configurée'; include APP_ROOT . '/templates/partials/status-badge.php'; ?></dd>
</div>
<div class="admin-account-status__row">
<dt class="admin-account-status__label">Stockage</dt>
<dd>
<code class="admin-account-status__code">site_settings (DB)</code>
<?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Présent'; $badgeWarnLabel = 'Absent'; include APP_ROOT . '/templates/partials/status-badge.php'; ?>
</dd>
</div>
<?php if (!$hasPassword): ?>
<p class="admin-account-status__note">
Aucun mot de passe PHP configuré. Le formulaire ci-dessous stockera
un hash bcrypt dans la base de données.
</p>
<?php endif; ?>
</dl>
<!-- Password change form -->
<h2 class="admin-section-title"><?= $hasPassword ? 'Changer le mot de passe' : 'Définir le mot de passe' ?></h2>
<form method="post" action="/admin/actions/account.php" class="admin-form" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<?php if ($hasPassword): ?>
<div>
<label for="current_password">Mot de passe actuel</label>
<div>
<input type="password" id="current_password"
name="current_password" required autocomplete="current-password">
</div>
</div>
<?php endif; ?>
<div>
<label for="new_password">Nouveau mot de passe</label>
<div>
<input type="password" id="new_password"
name="new_password" required autocomplete="new-password"
minlength="12">
<small>Minimum 12 caractères.</small>
</div>
</div>
<div>
<label for="confirm_password">Confirmer le mot de passe</label>
<div>
<input type="password" id="confirm_password"
name="confirm_password" required autocomplete="new-password">
</div>
</div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">
<?= $hasPassword ? 'Mettre à jour le mot de passe' : 'Définir le mot de passe' ?>
</button>
</div>
</form>
<?php if ($hasPassword): ?>
<!-- Danger zone: remove password -->
<h2 class="admin-section-title admin-section-title--danger">Zone de danger</h2>
<div class="admin-danger-zone">
<p class="admin-danger-zone__description">
<strong>Supprimer la configuration du mot de passe PHP</strong><br>
<small>
Supprime le hash de la base de données. L'accès admin
dépendra uniquement de l'authentification nginx Basic Auth si elle est configurée.
</small>
</p>
<form method="post" action="/admin/actions/account.php"
onsubmit="return confirm('Supprimer le mot de passe PHP ? L\'accès admin ne sera protégé que par nginx Basic Auth.')">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="remove_credentials">
<input type="hidden" name="current_password_remove" id="current_password_remove" value="">
<button type="submit" class="admin-btn admin-btn--danger">Supprimer</button>
</form>
</div>
<?php endif; ?>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,69 @@
<?php
/**
* Student-access link actions (create, toggle, set_password, delete).
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../src/ShareLink.php';
App::adminGuard();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
exit('CSRF token invalide.');
}
$action = $_POST['action'] ?? '';
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
$shareLink = ShareLink::make();
switch ($action) {
case 'create':
$password = !empty($_POST['password']) ? trim($_POST['password']) : null;
$expiresRaw = !empty($_POST['expires_at']) ? trim($_POST['expires_at']) : null;
$expiresAt = null;
if ($expiresRaw) {
// datetime-local gives "YYYY-MM-DDTHH:MM"
$expiresAt = date('Y-m-d H:i:s', strtotime($expiresRaw));
if ($expiresAt <= date('Y-m-d H:i:s')) {
App::redirect('/admin/acces-etudiante.php', error: "La date d'expiration doit être dans le futur.");
}
}
$shareLink->create(1, $password, $expiresAt);
App::redirect('/admin/acces-etudiante.php', success: 'Lien d\'accès créé.');
break;
case 'toggle':
if ($id > 0) {
$shareLink->toggleActive($id);
App::redirect('/admin/acces-etudiante.php', success: 'Statut du lien modifié.');
} else {
App::redirect('/admin/acces-etudiante.php', error: 'Lien introuvable.');
}
break;
case 'set_password':
if ($id > 0) {
$password = isset($_POST['password']) && $_POST['password'] !== '' ? trim($_POST['password']) : null;
$shareLink->setPassword($id, $password);
App::redirect('/admin/acces-etudiante.php', success: 'Mot de passe mis à jour.');
} else {
App::redirect('/admin/acces-etudiante.php', error: 'Lien introuvable.');
}
break;
case 'delete':
if ($id > 0) {
$shareLink->delete($id);
App::redirect('/admin/acces-etudiante.php', success: 'Lien supprimé.');
} else {
App::redirect('/admin/acces-etudiante.php', error: 'Lien introuvable.');
}
break;
default:
App::redirect('/admin/acces-etudiante.php', error: 'Action inconnue.');
break;
}

View File

@@ -0,0 +1,94 @@
<?php
/**
* Admin account action — update or remove admin password.
*
* Actions:
* POST (default) — set/change the PHP admin password
* POST action=remove_credentials — remove the password from DB
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../src/Database.php';
AdminAuth::requireLogin();
// ── CSRF ──────────────────────────────────────────────────────────────────────
if (empty($_SESSION['csrf_token']) || ($_POST['csrf_token'] ?? '') !== $_SESSION['csrf_token']) {
http_response_code(403);
die('Invalid CSRF token.');
}
$hasPassword = AdminAuth::hasPassword();
$action = $_POST['action'] ?? 'change_password';
// ── Remove credential ────────────────────────────────────────────────────────
if ($action === 'remove_credentials') {
$backUrl = $_POST['redirect'] ?? '/admin/parametres.php';
if (!preg_match('#^/admin/#', $backUrl)) { $backUrl = '/admin/parametres.php'; }
if (!$hasPassword) {
App::flash('error', 'Aucun mot de passe à supprimer.');
header('Location: ' . $backUrl);
exit;
}
AdminAuth::removePasswordHash();
// Destroy session so the user is forced to re-authenticate.
AdminAuth::logout();
header('Location: /admin/login.php');
exit;
}
// ── Change / set password ─────────────────────────────────────────────────────
// 1. If a password is already set, verify the current one.
$backUrl = $_POST['redirect'] ?? '/admin/parametres.php';
if (!preg_match('#^/admin/#', $backUrl)) { $backUrl = '/admin/parametres.php'; }
if ($hasPassword) {
$currentPassword = $_POST['current_password'] ?? '';
if (!AdminAuth::login($currentPassword)) {
App::flash('error', 'Mot de passe actuel incorrect.');
header('Location: ' . $backUrl);
exit;
}
}
// 2. Validate new password.
$newPassword = $_POST['new_password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
if (strlen($newPassword) < 12) {
App::flash('error', 'Le nouveau mot de passe doit contenir au moins 12 caractères.');
header('Location: ' . $backUrl);
exit;
}
if ($newPassword !== $confirmPassword) {
App::flash('error', 'Les mots de passe ne correspondent pas.');
header('Location: ' . $backUrl);
exit;
}
// 3. Generate bcrypt hash (cost 12).
$hash = password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => 12]);
if ($hash === false) {
App::flash('error', 'Erreur lors du hachage du mot de passe.');
header('Location: ' . $backUrl);
exit;
}
// 4. Store hash in DB.
AdminAuth::setPasswordHash($hash);
// 5. Regenerate session (password changed — invalidate old sessions).
session_regenerate_id(true);
$_SESSION['admin_authenticated'] = true;
App::flash('success', $hasPassword
? 'Mot de passe mis à jour avec succès.'
: 'Mot de passe défini avec succès. L\'authentification PHP est maintenant active.');
header('Location: ' . $backUrl);
exit;

View File

@@ -0,0 +1,76 @@
<?php
/**
* Save handler for apropos contents (contacts, credits).
* Structure: groups[] with label/role, each having entries[] of {text, url, email}.
*/
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
// CSRF check
if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) ||
!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
die("Erreur de sécurité : token invalide.");
}
$allowedKeys = ['contacts', 'credits'];
$aproposKey = $_POST['apropos_key'] ?? '';
if (!in_array($aproposKey, $allowedKeys)) {
die("Clé invalide.");
}
require_once __DIR__ . '/../../../src/Database.php';
try {
$db = new Database();
$groups = $_POST['groups'] ?? [];
$cleaned = [];
foreach ($groups as $group) {
if ($aproposKey === 'credits') {
$label = trim($group['label'] ?? '');
if ($label === '') continue;
$entries = [];
foreach ($group['entries'] ?? [] as $entry) {
$text = trim($entry['text'] ?? '');
if ($text === '') continue;
$e = ['text' => $text];
$url = trim($entry['url'] ?? '');
if ($url !== '') $e['url'] = $url;
$entries[] = $e;
}
if (empty($entries)) continue;
$cleaned[] = ['label' => $label, 'entries' => $entries];
} else { // contacts
$role = trim($group['role'] ?? '');
if ($role === '') continue;
$entries = [];
foreach ($group['entries'] ?? [] as $entry) {
$text = trim($entry['text'] ?? '');
if ($text === '') continue;
$e = [
'text' => $text,
'email' => trim($entry['email'] ?? ''),
];
$url = trim($entry['url'] ?? '');
if ($url !== '') $e['url'] = $url;
$entries[] = $e;
}
if (empty($entries)) continue;
$cleaned[] = ['role' => $role, 'entries' => $entries];
}
}
if (empty($cleaned)) {
die("Au moins un groupe avec des entrées est requis.");
}
$db->saveAproposContent($aproposKey, $cleaned);
App::flash('success', "Contenu « $aproposKey » mis à jour avec succès.");
} catch (Exception $e) {
error_log("Apropos save error: " . $e->getMessage());
die("Erreur lors de la sauvegarde : " . htmlspecialchars($e->getMessage()));
}
header('Location: /admin/contenus.php');
exit;

View File

@@ -0,0 +1,60 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php';
// CSRF validation
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
App::flash('error', 'Erreur de sécurité : token invalide.');
header('Location: ../index.php');
exit;
}
$isBulk = !empty($_POST['bulk']);
$isDeleteAll = !empty($_POST['delete_all']);
try {
$db = new Database();
if ($isDeleteAll) {
$count = $db->deleteAllTheses();
App::flash('success', "$count TFE(s) supprimé(s) avec succès.");
} elseif ($isBulk) {
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
if (empty($ids)) {
App::flash('error', 'Aucun TFE sélectionné.');
header('Location: ../index.php');
exit;
}
$db->bulkDeleteTheses($ids);
$count = count($ids);
App::flash('success', "$count TFE(s) supprimé(s) avec succès.");
} else {
$thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT);
if (!$thesisId || $thesisId <= 0) {
App::flash('error', 'ID invalide.');
header('Location: ../index.php');
exit;
}
$db->deleteThesis($thesisId);
App::flash('success', 'TFE supprimé avec succès.');
}
} catch (Exception $e) {
error_log('delete.php error: ' . $e->getMessage());
App::flash('error', 'Erreur lors de la suppression : ' . $e->getMessage());
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: ../index.php');
exit;

View File

@@ -0,0 +1,53 @@
<?php
// Bootstrap application
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin();
// Only handle POST requests
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../index.php');
exit();
}
// Verify CSRF token
if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) ||
!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
error_log("CSRF token validation failed in edit action");
die("Erreur de sécurité : token invalide. Veuillez recharger le formulaire.");
}
$thesisId = isset($_POST['thesis_id']) ? intval($_POST['thesis_id']) : 0;
if ($thesisId <= 0) {
die("ID de TFE invalide.");
}
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
try {
$ctrl = ThesisEditController::create();
$ctrl->save($thesisId, $_POST, $_FILES);
// Regenerate CSRF token after successful save
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
App::flash('success', "TFE mis à jour avec succès!");
header('Location: ../edit.php?id=' . $thesisId);
exit();
} catch (Exception $e) {
error_log("Edit action error: " . $e->getMessage());
App::flash('error', $e->getMessage());
// WCAG 3.3.1 — map error message to field name for autofocus on re-render.
$autofocusField = ThesisEditController::autofocusFieldForError($e->getMessage());
if ($autofocusField !== null) {
App::flashAutofocus($autofocusField);
}
header('Location: ../edit.php?id=' . $thesisId);
exit();
}

View File

@@ -0,0 +1,36 @@
<?php
/**
* Export TFE listings as CSV.
*
* Thin dispatcher — delegates all data assembly to ExportController,
* then streams the response.
*/
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Controllers/ExportController.php';
$controller = ExportController::create();
$filename = 'posterg-export-' . date('Y-m-d') . '.csv';
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Cache-Control: no-cache, must-revalidate');
// UTF-8 BOM for Excel compatibility
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'w');
// Column headers
fputcsv($out, ExportController::CSV_HEADERS, ',', '"', '');
// Data rows
$rows = $controller->exportAllTheses();
foreach ($rows as $csvLine) {
fputcsv($out, $csvLine, ',', '"', '');
}
fclose($out);
exit;

View File

@@ -0,0 +1,29 @@
<?php
/**
* Export the whole SQLite database as a file download.
*
* Thin dispatcher — delegates to ExportController.
*/
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Controllers/ExportController.php';
$controller = ExportController::create();
$dbPath = $controller->getDatabasePath();
if (!file_exists($dbPath)) {
http_response_code(500);
exit('Base de données introuvable.');
}
$filename = 'posterg-db-' . date('Y-m-d') . '.sqlite';
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($dbPath));
header('Cache-Control: no-cache, must-revalidate');
readfile($dbPath);
exit;

View File

@@ -0,0 +1,47 @@
<?php
// Bootstrap application
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', 'error.log');
AdminAuth::requireLogin();
// Verify CSRF token
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
error_log('CSRF token validation failed in formulaire.php');
die('Erreur de sécurité : token invalide. Veuillez recharger le formulaire.');
}
error_log('FILES array: ' . print_r($_FILES, true));
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
try {
$ctrl = ThesisCreateController::make();
$thesisId = $ctrl->submit($_POST, $_FILES);
unset($_SESSION['csrf_token']);
header('Location: ' . $redirect);
exit();
} catch (Exception $e) {
error_log('ThesisCreateController error: ' . $e->getMessage());
App::flash('error', $e->getMessage());
$_SESSION['form_data'] = $_POST;
$redirect = '../add.php';
$autofocusField = ThesisCreateController::autofocusFieldForError($e->getMessage());
if ($autofocusField !== null) {
App::flashAutofocus($autofocusField);
}
header('Location: ' . $redirect);
exit();
}

View File

@@ -0,0 +1,33 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
die("Accès refusé.");
}
$action = $_POST['action'] ?? '';
if ($action === 'enable_maintenance') {
file_put_contents(MAINTENANCE_FLAG, date('c'));
App::flash('success', "Mode maintenance activé.");
} elseif ($action === 'disable_maintenance') {
if (file_exists(MAINTENANCE_FLAG)) {
unlink(MAINTENANCE_FLAG);
}
App::flash('success', "Mode maintenance désactivé.");
} else {
App::flash('error', "Action inconnue.");
}
$redirect = isset($_POST['redirect']) ? $_POST['redirect'] : '/admin/';
// Allow only internal admin redirects for safety
if (!preg_match('#^/admin/#', $redirect)) {
$redirect = '/admin/';
}
header('Location: ' . $redirect);
exit();

View File

@@ -0,0 +1,65 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php';
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
App::flash('error', 'Erreur de sécurité : token invalide.');
header('Location: ../index.php');
exit;
}
$action = $_POST['action'] ?? '';
$isBulk = !empty($_POST['bulk']);
if (!in_array($action, ['publish', 'unpublish'], true)) {
App::flash('error', 'Action invalide.');
header('Location: ../index.php');
exit;
}
$published = ($action === 'publish');
try {
$db = new Database();
if ($isBulk) {
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
if (empty($ids)) {
App::flash('error', 'Aucun TFE sélectionné.');
header('Location: ../index.php');
exit;
}
$db->bulkSetPublished($ids, $published);
$count = count($ids);
App::flash('success', $published
? "$count TFE(s) publié(s) avec succès."
: "$count TFE(s) retiré(s) de la publication.");
} else {
$thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT);
if (!$thesisId || $thesisId <= 0) {
App::flash('error', 'ID invalide.');
header('Location: ../index.php');
exit;
}
$db->setPublished($thesisId, $published);
App::flash('success', $published ? 'TFE publié avec succès.' : 'TFE retiré de la publication.');
}
} catch (Exception $e) {
error_log('publish.php error: ' . $e->getMessage());
App::flash('error', 'Erreur lors de la modification : ' . $e->getMessage());
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: ../index.php');
exit;

View File

@@ -0,0 +1,49 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
App::flash('error', "Erreur de sécurité : token invalide.");
header('Location: /admin/parametres.php');
exit;
}
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SmtpRelay.php';
$db = new Database();
$section = $_POST['section'] ?? '';
if ($section === 'formulaire') {
// Save access-type toggle settings
$allowed = ['access_type_libre_enabled', 'access_type_interne_enabled', 'access_type_interdit_enabled'];
foreach ($allowed as $key) {
$value = isset($_POST[$key]) ? '1' : '0';
$db->setSetting($key, $value);
}
App::flash('success', "Paramètres du formulaire mis à jour.");
} elseif ($section === 'smtp') {
$smtpData = [
'host' => $_POST['smtp_host'] ?? '',
'port' => $_POST['smtp_port'] ?? 587,
'encryption' => $_POST['smtp_encryption'] ?? 'tls',
'username' => $_POST['smtp_username'] ?? '',
'from_email' => $_POST['smtp_from_email'] ?? '',
'from_name' => $_POST['smtp_from_name'] ?? 'Post-ERG',
];
// Only update password when user actually typed something.
$pwd = $_POST['smtp_password'] ?? '';
if ($pwd !== '') {
$smtpData['password'] = $pwd;
}
SmtpRelay::updateSettings($db, $smtpData);
App::flash('success', "Paramètres SMTP mis à jour.");
} else {
App::flash('error', "Section inconnue.");
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: /admin/parametres.php');
exit;

View File

@@ -0,0 +1,50 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
die("Accès refusé.");
}
require_once __DIR__ . '/../../../src/Database.php';
try {
$db = new Database();
$action = $_POST['action'] ?? '';
switch ($action) {
case 'rename':
$id = filter_var($_POST['tag_id'] ?? '', FILTER_VALIDATE_INT);
$newName = trim($_POST['new_name'] ?? '');
if (!$id || $newName === '') throw new Exception("Paramètres invalides.");
$db->renameTag($id, $newName);
break;
case 'merge':
$sourceId = filter_var($_POST['source_id'] ?? '', FILTER_VALIDATE_INT);
$targetId = filter_var($_POST['target_id'] ?? '', FILTER_VALIDATE_INT);
if (!$sourceId || !$targetId) throw new Exception("Paramètres invalides.");
$db->mergeTag($sourceId, $targetId);
break;
case 'delete':
$id = filter_var($_POST['tag_id'] ?? '', FILTER_VALIDATE_INT);
if (!$id) throw new Exception("ID invalide.");
$db->deleteTag($id);
break;
default:
throw new Exception("Action inconnue.");
}
App::flash('success', "Opération effectuée.");
} catch (Exception $e) {
App::flash('error', $e->getMessage());
}
header('Location: /admin/tags.php');
exit();

View File

@@ -0,0 +1,55 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
App::flash('error', "Erreur de sécurité : token invalide.");
header('Location: /admin/');
exit;
}
require_once __DIR__ . '/../../../src/Database.php';
$action = $_POST['action'] ?? ''; // 'set_visibility'
$accessTypeId = filter_var($_POST['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
$isBulk = !empty($_POST['bulk']);
$validAccess = [null, 1, 2, 3];
if (!in_array($accessTypeId, $validAccess, true)) {
App::flash('error', "Valeur de visibilité invalide.");
header('Location: /admin/');
exit;
}
try {
$db = new Database();
if ($isBulk) {
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
if (empty($ids)) {
App::flash('error', "Aucun TFE sélectionné.");
header('Location: /admin/');
exit;
}
$db->bulkSetVisibility($ids, $accessTypeId);
App::flash('success', count($ids) . " TFE(s) mis à jour.");
} else {
$thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT);
if (!$thesisId) {
App::flash('error', "ID invalide.");
header('Location: /admin/');
exit;
}
$db->setVisibility($thesisId, $accessTypeId);
App::flash('success', "Visibilité mise à jour.");
}
} catch (Exception $e) {
error_log("visibility.php error: " . $e->getMessage());
App::flash('error', "Erreur : " . $e->getMessage());
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: /admin/');
exit;

160
app/public/admin/add.php Normal file
View File

@@ -0,0 +1,160 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (empty($_SESSION["csrf_token"])) {
$_SESSION["csrf_token"] = bin2hex(random_bytes(32));
}
$pageTitle = "Ajouter un TFE";
require_once __DIR__ . '/../../src/Controllers/ThesisCreateController.php';
try {
$ctrl = ThesisCreateController::make();
extract($ctrl->loadFormData());
} catch (Exception $e) {
error_log('Failed to load form data: ' . $e->getMessage());
die('Erreur lors du chargement du formulaire.');
}
$formData = $_SESSION['form_data'] ?? [];
unset($_SESSION['form_data']);
$autofocusField = App::consumeAutofocus();
/**
* Merge autofocus into the $attrs array for a given field.
*/
function withAutofocus(string $fieldName, array $attrs = []): array {
global $autofocusField;
if ($autofocusField === $fieldName) {
$attrs['autofocus'] = true;
}
return $attrs;
}
function old($key, $default = "") {
global $formData;
return isset($formData[$key]) ? htmlspecialchars($formData[$key]) : $default;
}
function wasSelected($key, $value) {
global $formData;
if (!isset($formData[$key])) return false;
if (is_array($formData[$key])) return in_array($value, $formData[$key]);
return $formData[$key] == $value;
}
?>
<?php
$isAdmin = true;
$bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
?>
<main id="main-content">
<div class="thesis-add-header">
<h1>Ajouter un TFE</h1>
</div>
<form action="actions/formulaire.php" method="post" enctype="multipart/form-data" class="admin-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
<!-- ═══════════════════ Informations du TFE ═══════════════════ -->
<fieldset>
<legend>Informations du TFE</legend>
<?php $name = 'titre'; $label = 'Titre :'; $value = old('titre'); $required = true; $attrs = withAutofocus('titre'); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'subtitle'; $label = 'Sous-titre (si applicable) :'; $value = old('subtitle'); $required = false; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'auteurice'; $label = 'Auteur·ice(s) :'; $value = old('auteurice'); $required = true; $attrs = withAutofocus('auteurice', ['autocomplete' => 'name']); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'mail'; $label = 'Contact(s) (optionnel) [mail/site/insta/etc.] :'; $value = old('mail'); $attrs = ['autocomplete' => 'email']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<div class="admin-form-group">
<label class="admin-checkbox-label">
<input type="checkbox" name="contact_public" value="1"
<?= isset($formData['contact_public']) ? 'checked' : '' ?>>
Je veux que mon contact soit accessible à toustes depuis la plateforme xamxam
</label>
<small>Si cette case est cochée, votre contact apparaîtra sur la page publique de votre TFE.</small>
</div>
<div>
<label for="synopsis">Synopsis :</label>
<textarea id="synopsis" name="synopsis"
rows="7" required
<?= $autofocusField === 'synopsis' ? 'autofocus' : '' ?>><?= old('synopsis') ?></textarea>
</div>
</fieldset>
<!-- ═══════════════════ Composition du jury ═══════════════════ -->
<?php require APP_ROOT . '/templates/partials/form/jury-fieldset.php'; ?>
<!-- ═══════════════════ Cadre académique ═══════════════════ -->
<fieldset>
<legend>Cadre académique</legend>
<?php
$name = 'année'; $label = 'Année :'; $value = old('année'); $required = true;
$type = 'number';
$placeholder = date('Y');
$attrs = withAutofocus('année', ['min' => 2000, 'max' => date('Y') + 1]);
include APP_ROOT . '/templates/partials/form/text-field.php';
?>
<?php $name = 'orientation'; $label = 'Orientation :'; $options = $orientations; $selected = $formData['orientation'] ?? ''; $required = true; $placeholder = ''; $attrs = withAutofocus('orientation'); include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'ap'; $label = 'Atelier pluridisciplinaire :'; $options = $apPrograms; $selected = $formData['ap'] ?? ''; $required = true; $placeholder = ''; $attrs = withAutofocus('ap'); include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'finality'; $label = 'Finalité du master :'; $options = $finalityTypes; $selected = $formData['finality'] ?? ''; $required = true; $placeholder = ''; $attrs = withAutofocus('finality'); include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'languages'; $label = 'Langue(s) :'; $options = $languages; $checked = $formData['languages'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'formats'; $label = 'Format(s) :'; $options = $formatTypes; $checked = $formData['formats'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'tag'; $label = 'Mots-clés :'; $value = old('tag'); $placeholder = 'sociologie, anthropologie, ...'; $hint = 'Séparez par des virgules. Max 10 mots-clés.'; $attrs = withAutofocus('tag'); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
</fieldset>
<!-- ═══════════════════ Fichiers ═══════════════════ -->
<fieldset>
<legend>Fichiers</legend>
<?php $name = 'couverture'; $label = 'Image de couverture :'; $accept = 'image/jpeg,image/png'; $hint = 'JPG, PNG. Taille max : 10 MB.'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
<?php $name = 'banner'; $label = 'Image bannière (accueil) :'; $accept = 'image/jpeg,image/png,image/webp'; $hint = 'JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB.'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
<?php $name = 'files'; $label = 'Fichiers du TFE :'; $accept = '.pdf,.jpg,.jpeg,.png,.mp4,.zip,.vtt'; $hint = 'PDF, JPG, PNG, MP4, ZIP. Max 50 MB par fichier. Pour les vidéos, un fichier .vtt de sous-titres peut être joint (il sera associé automatiquement à la vidéo correspondante).'; $multiple = true; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
</fieldset>
<!-- ═══════════════════ Métadonnées complémentaires ═══════════════════ -->
<fieldset>
<legend>Métadonnées complémentaires</legend>
<?php $name = 'license_id'; $label = 'Licence :'; $options = $licenseTypes; $selected = $formData['license_id'] ?? ''; $placeholder = '— Inconnue —'; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'duration_info'; $label = 'Durée / Taille :'; $value = old('duration_info'); $placeholder = 'Ex : 84 pages'; $hint = 'Durée (minutes) ou nombre de pages.'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'lien'; $label = 'Lien (site / ressource) :'; $value = old('lien'); $type = 'url'; $placeholder = 'https://...'; $attrs = withAutofocus('lien'); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php
$accessOptions = array_map(function($at) {
return ['id' => $at['id'], 'name' => $at['name']];
}, $enabledAccessTypes);
$defaultAccessType = 2;
$selectedAccessType = isset($formData['access_type_id'])
? (int)$formData['access_type_id']
: $defaultAccessType;
$name = 'access_type_id';
$label = 'Visibilité / Accès :';
$options = $accessOptions;
$selected = $selectedAccessType;
$placeholder = null;
$required = true;
$attrs = [];
include APP_ROOT . '/templates/partials/form/select-field.php';
?>
</fieldset>
<div class="form-footer">
<button type="submit" name="go">Soumettre</button>
</div>
</form>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,232 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once __DIR__ . '/../../src/Database.php';
if (empty($_SESSION["csrf_token"])) {
$_SESSION["csrf_token"] = bin2hex(random_bytes(32));
}
$allowedPageSlugs = ["about", "licenses", "charte"];
$allowedApropos = ["contacts", "credits"];
$pageSlug = $_GET["slug"] ?? "";
$aproposKey = $_GET["apropos"] ?? "";
// Exactly one target must be specified
if ($pageSlug && !in_array($pageSlug, $allowedPageSlugs)) {
$pageSlug = "";
}
if ($aproposKey && !in_array($aproposKey, $allowedApropos)) {
$aproposKey = "";
}
if (!$pageSlug && !$aproposKey) {
header("Location: /admin/contenus.php");
exit();
}
try {
$db = new Database();
if ($pageSlug) {
$page = $db->getPage($pageSlug);
if (!$page) {
die("Page introuvable.");
}
$editTitle = $page["title"];
$editType = "page";
} else {
$editType = "apropos";
$value = $db->getAproposContent($aproposKey);
$editTitle = match($aproposKey) {
'contacts' => 'Contacts',
'credits' => 'Crédits',
};
}
} catch (Exception $e) {
die("Erreur: " . htmlspecialchars($e->getMessage()));
}
$pageTitle = "Éditer : " . $editTitle;
$extraJs = ["/assets/js/overtype.min.js"];
$extraJsInline = <<<'JS'
var OT = window.OverType.default || window.OverType;
var hidden = document.getElementById('content');
var editor = new OT(document.getElementById('editor'), {
value: hidden.value,
minHeight: '400px',
spellcheck: false,
onChange: function(value) { hidden.value = value; }
});
JS;
$aproposEditorJs = null;
if ($editType === 'apropos' && in_array($aproposKey, ['contacts', 'credits'])) {
// Rich textarea for JSON arrays rendered as structured form
$aproposEditorJs = <<<'JS'
// Auto-format JSON in the hidden field for display purposes
JS;
}
$initialContent = '';
if ($editType === 'page') {
$initialContent = $page["content"] ?? "";
} else {
// For apropos, show structured form
}
?>
<?php
$isAdmin = true;
$bodyClass = "admin-body";
require_once APP_ROOT . "/templates/head.php";
?>
<?php include APP_ROOT . "/templates/header.php"; ?>
<main id="main-content">
<h1>Éditer : <?= htmlspecialchars($editTitle) ?></h1>
<?php if ($editType === 'page'): ?>
<form action="/admin/actions/page.php" method="post" class="admin-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
<input type="hidden" name="slug" value="<?= htmlspecialchars($pageSlug) ?>">
<label for="editor">Contenu (Markdown) :</label>
<input type="hidden" id="content" name="content"
value="<?= htmlspecialchars($initialContent) ?>">
<div id="editor"></div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Enregistrer</button>
<a href="/admin/contenus.php" class="admin-btn-secondary admin-cancel-link">Annuler</a>
</div>
</form>
<?php else: ?>
<?php
$groups = is_array($value) ? $value : [];
?>
<form action="/admin/actions/apropos.php" method="post" class="admin-form" id="apropos-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
<input type="hidden" name="apropos_key" value="<?= htmlspecialchars($aproposKey) ?>">
<?php foreach ($groups as $gi => $group): ?>
<fieldset class="apropos-group">
<legend><?= htmlspecialchars($aproposKey === 'contacts' ? 'Contact' : 'Crédit') ?> <?= $gi + 1 ?></legend>
<?php if ($aproposKey === 'contacts'): ?>
<label for="group_<?= $gi ?>_role">Rôle :</label>
<input type="text" id="group_<?= $gi ?>_role"
name="groups[<?= $gi ?>][role]"
value="<?= htmlspecialchars($group['role'] ?? '') ?>">
<?php else: ?>
<label for="group_<?= $gi ?>_label">Label :</label>
<input type="text" id="group_<?= $gi ?>_label"
name="groups[<?= $gi ?>][label]"
value="<?= htmlspecialchars($group['label'] ?? '') ?>">
<?php endif; ?>
<?php $entries = is_array($group['entries'] ?? null) ? $group['entries'] : []; ?>
<?php foreach ($entries as $ei => $entry): ?>
<div class="apropos-entry">
<label for="entry_<?= $gi ?>_<?= $ei ?>_text"><?= $aproposKey === 'contacts' ? 'Nom' : 'Texte' ?> :</label>
<input type="text" id="entry_<?= $gi ?>_<?= $ei ?>_text"
name="groups[<?= $gi ?>][entries][<?= $ei ?>][text]"
value="<?= htmlspecialchars($entry['text'] ?? '') ?>">
<?php if ($aproposKey === 'contacts'): ?>
<label for="entry_<?= $gi ?>_<?= $ei ?>_email">Email :</label>
<input type="email" id="entry_<?= $gi ?>_<?= $ei ?>_email"
name="groups[<?= $gi ?>][entries][<?= $ei ?>][email]"
value="<?= htmlspecialchars($entry['email'] ?? '') ?>">
<?php endif; ?>
<label for="entry_<?= $gi ?>_<?= $ei ?>_url">Lien (optionnel) :</label>
<input type="url" id="entry_<?= $gi ?>_<?= $ei ?>_url"
name="groups[<?= $gi ?>][entries][<?= $ei ?>][url]"
value="<?= htmlspecialchars($entry['url'] ?? '') ?>">
</div>
<?php endforeach; ?>
<button type="button" class="admin-btn admin-btn--sm add-entry-btn" data-group="<?= $gi ?>">+ Ajouter une entrée</button>
</fieldset>
<?php endforeach; ?>
<button type="button" class="admin-btn" id="add-group-btn">+ Ajouter un <?= $aproposKey === 'contacts' ? 'contact' : 'groupe de crédit' ?></button>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Enregistrer</button>
<a href="/admin/contenus.php" class="admin-btn-secondary admin-cancel-link">Annuler</a>
</div>
<template id="entry-template-<?= $aproposKey ?>">
<div class="apropos-entry">
<label>Entrée :</label>
<input type="text" name="groups[{{gi}}][entries][{{ei}}][text]">
<?php if ($aproposKey === 'contacts'): ?>
<label>Email :</label>
<input type="email" name="groups[{{gi}}][entries][{{ei}}][email]">
<?php endif; ?>
<label>Lien (optionnel) :</label>
<input type="url" name="groups[{{gi}}][entries][{{ei}}][url]">
</div>
</template>
<template id="group-template-<?= $aproposKey ?>">
<fieldset class="apropos-group">
<legend><?= htmlspecialchars($aproposKey === 'contacts' ? 'Contact' : 'Crédit') ?> {{gi}}</legend>
<?php if ($aproposKey === 'contacts'): ?>
<label>Rôle :</label>
<input type="text" name="groups[{{gi}}][role]">
<?php else: ?>
<label>Label :</label>
<input type="text" name="groups[{{gi}}][label]">
<?php endif; ?>
<button type="button" class="admin-btn admin-btn--sm add-entry-btn" data-group="{{gi}}">+ Ajouter une entrée</button>
</fieldset>
</template>
</form>
<script>
(function() {
const aproposKey = '<?= $aproposKey ?>';
let groupCount = <?= count($groups) ?>;
const entryTpl = document.getElementById('entry-template-' + aproposKey).innerHTML;
const groupTpl = document.getElementById('group-template-' + aproposKey).innerHTML;
// Add entry to a group
document.querySelectorAll('.add-entry-btn').forEach(btn => {
btn.addEventListener('click', function() {
const gi = parseInt(this.dataset.group);
const fieldset = this.closest('fieldset');
const entryCount = fieldset.querySelectorAll('.apropos-entry').length;
const html = entryTpl.replaceAll('{{gi}}', gi).replaceAll('{{ei}}', entryCount);
this.insertAdjacentHTML('beforebegin', html);
});
});
// Add new group
document.getElementById('add-group-btn').addEventListener('click', function() {
groupCount++;
const html = groupTpl.replaceAll('{{gi}}', groupCount);
this.insertAdjacentHTML('beforebegin', html);
// Re-bind add-entry buttons for the new group
const newGroup = this.previousElementSibling;
if (newGroup && newGroup.classList.contains('apropos-group')) {
const btn = newGroup.querySelector('.add-entry-btn');
if (btn) {
btn.dataset.group = groupCount;
btn.addEventListener('click', function() {
const gi = parseInt(this.dataset.group);
const fieldset = this.closest('fieldset');
const entryCount = fieldset.querySelectorAll('.apropos-entry').length;
const html = entryTpl.replaceAll('{{gi}}', gi).replaceAll('{{ei}}', entryCount);
this.insertAdjacentHTML('beforebegin', html);
});
}
}
});
})();
</script>
<?php endif; ?>
</main>
<?php require_once APP_ROOT . "/templates/admin/footer.php"; ?>

View File

@@ -0,0 +1,85 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once __DIR__ . '/../../src/Database.php';
$pageTitle = "Contenus";
try {
$db = new Database();
$pages = $db->getAllPages();
$aproposKeys = $db->getAllAproposContents();
} catch (Exception $e) {
error_log("Error loading contenus: " . $e->getMessage());
die("Erreur lors du chargement des contenus.");
}
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<h1>Contenus</h1>
<h2>Pages statiques</h2>
<table>
<thead>
<tr>
<th scope="col">Slug</th>
<th scope="col">Titre</th>
<th scope="col">Mis à jour</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($pages as $p): ?>
<tr>
<td><code><?= htmlspecialchars($p['slug']) ?></code></td>
<td><?= htmlspecialchars($p['title']) ?></td>
<td><?= htmlspecialchars($p['updated_at'] ?? '—') ?></td>
<td>
<a href="/admin/contenus-edit.php?slug=<?= urlencode($p['slug']) ?>"
class="admin-btn admin-btn--sm">Éditer</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<h2 style="margin-top:2rem;">À propos</h2>
<table>
<thead>
<tr>
<th scope="col">Clé</th>
<th scope="col">Type</th>
<th scope="col">Mis à jour</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($aproposKeys as $a): ?>
<?php
$typeLabel = match($a['key']) {
'contacts' => 'Contacts',
'credits' => 'Crédits',
};
?>
<tr>
<td><code><?= htmlspecialchars($a['key']) ?></code></td>
<td><?= htmlspecialchars($typeLabel) ?></td>
<td><?= htmlspecialchars($a['updated_at'] ?? '—') ?></td>
<td>
<a href="/admin/contenus-edit.php?apropos=<?= urlencode($a['key']) ?>"
class="admin-btn admin-btn--sm">Éditer</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

172
app/public/admin/edit.php Normal file
View File

@@ -0,0 +1,172 @@
<?php
// Bootstrap application
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin();
// Generate CSRF token
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
$thesisId = isset($_GET['id']) ? intval($_GET['id']) : 0;
if ($thesisId <= 0) {
die("ID invalide");
}
// WCAG 3.3.1 — consume the autofocus hint stored by the edit action on
// validation failure.
$autofocusField = App::consumeAutofocus();
try {
$ctrl = ThesisEditController::create();
$view = $ctrl->load($thesisId);
extract($view); // thesis, currentLanguages, currentFormats, jury, lookup tables, pageTitle …
} catch (Exception $e) {
error_log("Error loading edit page: " . $e->getMessage());
die("Erreur lors du chargement: " . $e->getMessage());
}
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<h1>Modifier un TFE</h1>
<form method="post" action="/admin/actions/edit.php" class="admin-form" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="thesis_id" value="<?= $thesisId ?>">
<?php $name = 'auteurice'; $label = 'Auteur·ice(s) :'; $value = htmlspecialchars($thesis['authors']); $required = true; $attrs = array_merge(['autocomplete' => 'name'], $autofocusField === 'auteurice' ? ['autofocus' => true] : []); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'mail'; $label = 'Contact :'; $value = htmlspecialchars($currentAuthorEmail ?? ''); $attrs = ['autocomplete' => 'email']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<!-- Contact visibility -->
<div class="admin-form-group">
<label class="admin-checkbox-label">
<input type="checkbox" name="contact_public" value="1"
<?= !empty($currentAuthorShowContact) ? 'checked' : '' ?>>
Je veux que mon contact soit accessible à toustes depuis la plateforme xamxam
</label>
<small>Si cette case est cochée, le contact apparaît sur la page publique du TFE.</small>
</div>
<?php
$name = 'année'; $label = 'Année :'; $value = htmlspecialchars((string)$thesis['year']); $required = true;
$type = 'number';
$attrs = $autofocusField === 'année' ? ['autofocus' => true] : [];
include APP_ROOT . '/templates/partials/form/text-field.php';
?>
<?php $name = 'orientation'; $label = 'Orientation :'; $options = $orientations; $selected = $thesis['orientation']; $required = true; $placeholder = null; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'ap'; $label = 'Atelier pluridisciplinaire :'; $options = $apPrograms; $selected = $thesis['ap_program']; $required = true; $placeholder = null; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'finality'; $label = 'Finalité du master :'; $options = $finalityTypes; $selected = $thesis['finality_type']; $required = true; $placeholder = null; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<!-- Composition du jury -->
<?php
$juryPresident = null;
$juryPromoteur = null;
$juryPromoteurExt = 0;
$juryLecteurs = [];
foreach ($jury as $jm) {
if ($jm['role'] === 'president') {
$juryPresident = $jm['name'];
} elseif ($jm['role'] === 'promoteur') {
$juryPromoteur = $jm['name'];
$juryPromoteurExt = (int)$jm['is_external'];
} elseif ($jm['role'] === 'lecteur') {
$juryLecteurs[] = $jm;
}
}
?>
<?php require APP_ROOT . '/templates/partials/form/jury-fieldset.php'; ?>
<?php
// Access type select: options need 'id'+'name'; description appended inline
$accessOptions = array_map(function($at) {
$label = $at['name'];
if (!empty($at['description'])) {
$label .= ' — ' . $at['description'];
}
return ['id' => $at['id'], 'name' => $label];
}, $accessTypes);
$name = 'access_type_id'; $label = 'Visibilité / Accès :'; $options = $accessOptions; $selected = $currentAccessTypeId; $placeholder = '- Non défini -';
include APP_ROOT . '/templates/partials/form/select-field.php';
?>
<!-- Context note (textarea — no text-field partial for textarea) -->
<div>
<label for="context_note">Note contextuelle :</label>
<div>
<textarea id="context_note" name="context_note"
rows="4" maxlength="1500"><?= htmlspecialchars($currentContextNote ?? '') ?></textarea>
<small>Visible publiquement pour les TFE Interne ou Interdit. Max 1 500 caractères.</small>
</div>
</div>
<?php $name = 'license_id'; $label = 'Licence :'; $options = $licenseTypes; $selected = $currentLicenseId; $placeholder = '- Inconnue -'; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'titre'; $label = 'Titre :'; $value = htmlspecialchars($thesis['title']); $required = true; $attrs = $autofocusField === 'titre' ? ['autofocus' => true] : []; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'subtitle'; $label = 'Sous-titre :'; $value = htmlspecialchars($thesis['subtitle'] ?? ''); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<!-- Synopsis (textarea — not covered by text-field partial) -->
<div>
<label for="synopsis">Synopsis :</label>
<textarea id="synopsis" name="synopsis" rows="7" required
<?= $autofocusField === 'synopsis' ? 'autofocus' : '' ?>><?= htmlspecialchars($thesis['synopsis'] ?? '') ?></textarea>
</div>
<?php $name = 'languages'; $label = 'Langue(s) :'; $options = $languages; $checked = $currentLanguages; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'formats'; $label = 'Format(s) :'; $options = $formatTypes; $checked = $currentFormats; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'tag'; $label = 'Mots-clés :'; $value = htmlspecialchars($thesis['keywords'] ?? ''); $hint = 'Séparer par des virgules. Max 10.'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'duration_info'; $label = 'Durée / Taille :'; $value = htmlspecialchars($thesis['file_size_info'] ?? ''); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'lien'; $label = 'Lien externe :'; $value = htmlspecialchars($thesis['baiu_link'] ?? ''); $type = 'url'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<!-- Image bannière (custom: includes current banner preview + remove checkbox) -->
<div>
<label>Image bannière (accueil) :</label>
<div>
<?php if (!empty($thesis['banner_path'])): ?>
<div class="admin-banner-preview">
<img src="/media.php?path=<?= urlencode($thesis['banner_path']) ?>"
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" accept="image/jpeg,image/png,image/webp">
<small>JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB.</small>
</div>
</div>
<!-- Publication toggle -->
<div>
<label>Publication :</label>
<label class="admin-checkbox-label">
<input type="checkbox" name="is_published" value="1"
<?= $thesis['is_published'] ? 'checked' : '' ?>>
Publier ce TFE sur le site public
</label>
</div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Enregistrer</button>
<a href="/admin/thanks.php?id=<?= $thesisId ?>" class="admin-btn-secondary admin-cancel-link">Annuler</a>
</div>
</form>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,5 @@
<?php
// import.php is no longer a standalone page.
// CSV import is handled inline on the list page via a dialog.
header('Location: /admin/');
exit();

559
app/public/admin/index.php Normal file
View File

@@ -0,0 +1,559 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$pageTitle = "Liste des TFE";
require_once __DIR__ . '/../../src/Database.php';
// ── CSV Import (inline, submitted to this same page) ─────────────────────────
$importMessage = '';
$importErrors = [];
$importResults = [];
$importDone = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
$importErrors[] = "Erreur de sécurité : token invalide.";
} else {
$importedCount = 0;
$skippedCount = 0;
try {
$importDb = new Database();
$importPdo = $importDb->getPDO();
if ($_FILES['csv_file']['error'] !== UPLOAD_ERR_OK) {
throw new Exception("Erreur lors du téléversement du fichier.");
}
$handle = fopen($_FILES['csv_file']['tmp_name'], 'r');
if (!$handle) throw new Exception("Impossible d'ouvrir le fichier CSV.");
fgetcsv($handle, 0, ',', '"', '');
fgetcsv($handle, 0, ',', '"', '');
fgetcsv($handle, 0, ',', '"', '');
fgetcsv($handle, 0, ',', '"', ''); // skip 4 header rows
$orientationMap = [
'SC'=>'Sculpture','VI'=>'Vidéographie','CA'=>"Cinéma d'animation",
'IP'=>'Installation-Performance','PE'=>'Peinture','PH'=>'Photographie',
'DE'=>'Dessin','AN'=>'Arts Numériques','GR'=>'Graphisme',
'TY'=>'Typographie','DN'=>'Design Numérique','IL'=>'Illustration',
'BD'=>'Bande-Dessinée','SE'=>'Sérigraphie','GV'=>'Gravure',
];
$lineNumber = 5;
while (($row = fgetcsv($handle, 0, ',', '"', '')) !== false) {
$lineNumber++;
if (empty($row[0]) && empty($row[1])) continue;
try {
$importDb->beginTransaction();
$identifier = trim($row[0] ?? '');
$title = trim($row[1] ?? '');
$subtitle = trim($row[2] ?? '');
$authorsRaw = trim($row[3] ?? '');
$contact = trim($row[4] ?? '');
$supervisorsRaw = trim($row[5] ?? '');
$formatsRaw = trim($row[6] ?? '');
$year = intval($row[7] ?? 0);
$apCode = trim($row[8] ?? '');
$orientationCode = trim($row[9] ?? '');
$finalityName = trim($row[10] ?? '');
$keywordsRaw = trim($row[11] ?? '');
$synopsis = trim($row[12] ?? '');
$context = trim($row[13] ?? '');
$remarks = trim($row[14] ?? '');
$languageRaw = trim($row[15] ?? '');
$access = trim($row[16] ?? '');
$license = trim($row[17] ?? '');
$sizeInfo = trim($row[18] ?? '');
$juryPoints = !empty($row[19]) ? floatval($row[19]) : null;
$baiuLink = trim($row[20] ?? '');
if (empty($title) || empty($year)) throw new Exception("Titre et année requis.");
$orientationName = $orientationMap[$orientationCode] ?? null;
$orientationId = null;
if ($orientationName) {
$s = $importPdo->prepare("SELECT id FROM orientations WHERE name = ?");
$s->execute([$orientationName]);
$r = $s->fetch(); $orientationId = $r ? $r['id'] : null;
}
$apProgramId = null;
if (!empty($apCode)) {
$s = $importPdo->prepare("SELECT id FROM ap_programs WHERE code = ?");
$s->execute([$apCode]);
$r = $s->fetch(); $apProgramId = $r ? $r['id'] : null;
}
$finalityId = null;
if (!empty($finalityName)) {
$s = $importPdo->prepare("SELECT id FROM finality_types WHERE name = ?");
$s->execute([$finalityName]);
$r = $s->fetch(); $finalityId = $r ? $r['id'] : null;
}
$accessTypeId = null;
if (!empty($access)) {
$s = $importPdo->prepare("SELECT id FROM access_types WHERE name = ?");
$s->execute([ucfirst(strtolower($access))]);
$r = $s->fetch(); $accessTypeId = $r ? $r['id'] : null;
}
if ($accessTypeId === null) $accessTypeId = 1;
if (!empty($identifier)) {
$s = $importPdo->prepare("SELECT id FROM theses WHERE identifier = ?");
$s->execute([$identifier]);
if ($s->fetch()) {
$importDb->rollback();
$skippedCount++;
$importResults[] = ['type'=>'skip', 'msg'=>"Ligne $lineNumber: identifiant \"$identifier\" déjà présent, ignoré."];
continue;
}
}
$s = $importPdo->prepare("
INSERT INTO theses (
identifier, title, subtitle, year,
orientation_id, ap_program_id, finality_id,
synopsis, context_note, remarks,
file_size_info, jury_points, baiu_link,
access_type_id, is_published, submitted_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,CURRENT_TIMESTAMP)
");
$s->execute([
!empty($identifier) ? $identifier : null, $title,
!empty($subtitle) ? $subtitle : null, $year,
$orientationId, $apProgramId, $finalityId,
!empty($synopsis) ? $synopsis : null,
!empty($context) ? $context : null,
!empty($remarks) ? $remarks : null,
!empty($sizeInfo) ? $sizeInfo : null,
$juryPoints,
!empty($baiuLink) ? $baiuLink : null,
$accessTypeId,
]);
$thesisId = $importPdo->lastInsertId();
if (!empty($authorsRaw)) {
foreach (array_map('trim', explode(',', $authorsRaw)) as $idx => $name) {
if ($name) {
$aId = $importDb->findOrCreateAuthor($name, $idx === 0 ? $contact : null);
$s = $importPdo->prepare("INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?,?,?)");
$s->execute([$thesisId, $aId, $idx + 1]);
}
}
}
if (!empty($supervisorsRaw)) {
foreach (array_map('trim', explode(',', $supervisorsRaw)) as $idx => $name) {
if ($name) {
$sId = $importDb->findOrCreateSupervisor($name);
$s = $importPdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, supervisor_order) VALUES (?,?,?)");
$s->execute([$thesisId, $sId, $idx + 1]);
}
}
}
if (!empty($keywordsRaw)) {
foreach (array_slice(array_map('trim', explode(',', $keywordsRaw)), 0, 10) as $kw) {
if ($kw) {
$tId = $importDb->findOrCreateTag($kw);
if ($tId) {
$s = $importPdo->prepare("INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)");
$s->execute([$thesisId, $tId]);
}
}
}
}
if (!empty($languageRaw)) {
$s = $importPdo->prepare("SELECT id FROM languages WHERE name = ?");
$s->execute([ucfirst(strtolower($languageRaw))]);
$r = $s->fetch();
if ($r) {
$s2 = $importPdo->prepare("INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)");
$s2->execute([$thesisId, $r['id']]);
}
}
if (!empty($formatsRaw)) {
foreach (array_map('trim', explode(',', $formatsRaw)) as $fmt) {
if ($fmt) {
$s = $importPdo->prepare("SELECT id FROM format_types WHERE name = ?");
$s->execute([ucfirst(strtolower($fmt))]);
$r = $s->fetch();
if ($r) {
$s2 = $importPdo->prepare("INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?,?)");
$s2->execute([$thesisId, $r['id']]);
}
}
}
}
$importDb->commit();
$importedCount++;
$importResults[] = ['type'=>'ok', 'msg'=>"\"$title\" (ID: $thesisId)"];
} catch (Exception $e) {
$importDb->rollback();
$skippedCount++;
$importResults[] = ['type'=>'error', 'msg'=>"Ligne $lineNumber: " . $e->getMessage()];
error_log("Import error on line $lineNumber: " . $e->getMessage());
}
}
fclose($handle);
$importMessage = "Import terminé : $importedCount TFE importés, $skippedCount ignorés.";
$importDone = true;
} catch (Exception $e) {
$importErrors[] = $e->getMessage();
error_log("CSV import error: " . $e->getMessage());
}
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
try {
$db = new Database();
$searchQuery = isset($_GET['search']) ? trim($_GET['search']) : '';
$yearFilter = isset($_GET['year']) ? intval($_GET['year']) : null;
$orientationFilter = isset($_GET['orientation']) ? intval($_GET['orientation']) : null;
$apFilter = isset($_GET['ap']) ? intval($_GET['ap']) : null;
$sortCol = isset($_GET['sort']) ? trim($_GET['sort']) : 'submitted_at';
$sortDir = isset($_GET['dir']) ? trim($_GET['dir']) : 'desc';
$filters = [];
if ($searchQuery) $filters['search'] = $searchQuery;
if ($yearFilter) $filters['year'] = $yearFilter;
if ($orientationFilter) $filters['orientation'] = $orientationFilter;
if ($apFilter) $filters['ap'] = $apFilter;
$filters['sort'] = $sortCol;
$filters['dir'] = $sortDir;
$perPage = 25;
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$totalCount = $db->getThesesListCount($filters);
$totalPages = $totalCount > 0 ? (int) ceil($totalCount / $perPage) : 1;
$page = min($page, $totalPages);
$offset = ($page - 1) * $perPage;
$theses = $db->getThesesList($filters, $perPage, $offset);
$stats = $db->getThesesStats();
$years = $db->getAllYears();
$orientations = $db->getAllOrientations();
$apPrograms = $db->getAllAPPrograms();
} catch (Exception $e) {
error_log("Error loading theses list: " . $e->getMessage());
die("Erreur lors du chargement de la liste.");
}
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<script>
function toggleAll(src) {
document.querySelectorAll('input[name="selected_theses[]"]').forEach(cb => cb.checked = src.checked);
updateBulk();
}
function updateBulk() {
const checked = document.querySelectorAll('input[name="selected_theses[]"]:checked');
const bulk = document.getElementById('bulk-actions');
document.getElementById('selected-count').textContent = checked.length;
bulk.style.display = checked.length > 0 ? 'flex' : 'none';
}
function bulkAction(action) {
const checked = document.querySelectorAll('input[name="selected_theses[]"]:checked');
if (!checked.length) { alert('Sélectionnez au moins un TFE.'); return; }
let word, endpoint;
if (action === 'publish') { word = 'publier'; endpoint = 'actions/publish.php'; }
else if (action === 'unpublish') { word = 'dépublier'; endpoint = 'actions/publish.php'; }
else if (action === 'delete') { word = 'supprimer'; endpoint = 'actions/delete.php'; }
else return;
if (action === 'delete') {
if (!confirm(`Supprimer définitivement ${checked.length} TFE(s) ? Cette action est irréversible.`)) return;
} else {
if (!confirm(`${word.charAt(0).toUpperCase()+word.slice(1)} ${checked.length} TFE(s) ?`)) return;
}
document.getElementById('bulk-action-input').value = action;
document.getElementById('bulk-form').action = endpoint;
const container = document.getElementById('bulk-checkboxes');
container.innerHTML = '';
checked.forEach(cb => {
const inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'selected_theses[]'; inp.value = cb.value;
container.appendChild(inp);
});
document.getElementById('bulk-form').submit();
}
function deleteThesis(id, title) {
if (!confirm(`Supprimer « ${title} » ?\nCette action est irréversible.`)) return;
const form = document.getElementById('delete-form-' + id);
if (form) form.submit();
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('input[name="selected_theses[]"]').forEach(cb => cb.addEventListener('change', updateBulk));
});
</script>
<main id="main-content">
<!-- Title + filters + stats + import all in one toolbar row -->
<div class="admin-list-toolbar">
<h1>Liste des TFE</h1>
<form class="admin-filters" method="get" action="/admin/">
<input type="text" name="search" placeholder="Titre, auteur..."
value="<?= htmlspecialchars($searchQuery) ?>">
<select name="year">
<option value="">Année</option>
<?php foreach ($years as $y): ?>
<option value="<?= $y ?>" <?= $yearFilter == $y ? 'selected' : '' ?>><?= $y ?></option>
<?php endforeach; ?>
</select>
<select name="orientation">
<option value="">Orientation</option>
<?php foreach ($orientations as $o): ?>
<option value="<?= $o['id'] ?>" <?= $orientationFilter == $o['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($o['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="ap">
<option value="">AP</option>
<?php foreach ($apPrograms as $ap): ?>
<option value="<?= $ap['id'] ?>" <?= $apFilter == $ap['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($ap['name']) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="admin-filters-btn">Filtrer</button>
<?php if ($searchQuery || $yearFilter || $orientationFilter || $apFilter): ?>
<button type="button" class="admin-filters-reset"
onclick="window.location='/admin/'">&#x2715; Réinitialiser</button>
<?php endif; ?>
</form>
<div class="admin-list-toolbar__right">
<dl class="admin-stats">
<div class="admin-stat">
<dt class="admin-stat__label">Total</dt>
<dd class="admin-stat__number"><?= $stats['total'] ?></dd>
</div>
<div class="admin-stat">
<dt class="admin-stat__label">Publiés</dt>
<dd class="admin-stat__number"><?= $stats['published'] ?></dd>
</div>
<div class="admin-stat">
<dt class="admin-stat__label">Attente</dt>
<dd class="admin-stat__number"><?= $stats['pending'] ?></dd>
</div>
</dl>
<a href="/admin/add.php" class="admin-btn admin-btn--sm">Ajouter un TFE</a>
<button type="button" class="admin-btn admin-btn--sm" id="import-dialog-btn"
onclick="document.getElementById('import-dialog').showModal()">
Importer un CSV
</button>
<a href="/admin/actions/export-csv.php" class="admin-btn admin-btn--sm">
Exporter CSV
</a>
</div>
</div>
<!-- Bulk actions bar -->
<div id="bulk-actions" class="admin-bulk-actions" role="toolbar" aria-label="Actions groupées">
<strong><span id="selected-count">0</span> TFE(s) sélectionné(s)</strong>
<div class="admin-bulk-btns">
<button type="button" class="admin-btn-sm admin-btn-publish" onclick="bulkAction('publish')">Publier</button>
<button type="button" class="admin-btn-sm admin-btn-unpublish" onclick="bulkAction('unpublish')">Dépublier</button>
<button type="button" class="admin-btn-sm admin-btn-delete" onclick="bulkAction('delete')">Supprimer</button>
</div>
</div>
<form id="bulk-form" method="post" action="actions/publish.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" id="bulk-action-input" name="action" value="">
<input type="hidden" name="bulk" value="1">
<div id="bulk-checkboxes"></div>
</form>
<!-- Table -->
<?php if (empty($theses)): ?>
<p class="admin-empty">Aucun TFE trouvé.</p>
<?php else: ?>
<p class="admin-list-meta">
<?php
$from = $offset + 1;
$to = min($offset + $perPage, $totalCount);
if ($totalPages > 1) {
echo "{$from}-{$to} sur {$totalCount} TFE";
} else {
echo "$totalCount TFE";
}
?>
</p>
<?php
$sortParams = array_filter([
'search' => $searchQuery,
'year' => $yearFilter ?: '',
'orientation' => $orientationFilter ?: '',
'ap' => $apFilter ?: '',
]);
$sortLink = function(string $col) use ($sortCol, $sortDir, $sortParams): string {
$params = $sortParams;
$params['sort'] = $col;
$params['dir'] = ($sortCol === $col && $sortDir === 'desc') ? 'asc' : 'desc';
return '/admin/?' . http_build_query($params);
};
$sortArrow = function(string $col) use ($sortCol, $sortDir): string {
if ($sortCol !== $col) return '';
return $sortDir === 'asc' ? ' ↑' : ' ↓';
};
?>
<table>
<thead>
<tr>
<th scope="col"><input type="checkbox" onchange="toggleAll(this)"></th>
<th scope="col"><a href="<?= $sortLink('identifier') ?>" class="admin-sort-link">ID<?= $sortArrow('identifier') ?></a></th>
<th scope="col"><a href="<?= $sortLink('title') ?>" class="admin-sort-link">Titre<?= $sortArrow('title') ?></a></th>
<th scope="col">Auteur(s)</th>
<th scope="col"><a href="<?= $sortLink('year') ?>" class="admin-sort-link">Année<?= $sortArrow('year') ?></a></th>
<th scope="col"><a href="<?= $sortLink('orientation') ?>" class="admin-sort-link">Orientation<?= $sortArrow('orientation') ?></a></th>
<th scope="col"><a href="<?= $sortLink('ap_program') ?>" class="admin-sort-link">AP<?= $sortArrow('ap_program') ?></a></th>
<th scope="col"><a href="<?= $sortLink('is_published') ?>" class="admin-sort-link">Statut<?= $sortArrow('is_published') ?></a></th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($theses as $thesis): ?>
<tr>
<td><input type="checkbox" name="selected_theses[]" value="<?= $thesis['id'] ?>"></td>
<td class="admin-table-id"><?= htmlspecialchars($thesis['identifier'] ?? $thesis['id']) ?></td>
<td>
<div class="thesis-title"><?= htmlspecialchars($thesis['title']) ?></div>
<?php if ($thesis['subtitle']): ?>
<div class="thesis-subtitle"><?= htmlspecialchars($thesis['subtitle']) ?></div>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($thesis['authors'] ?? 'N/A') ?></td>
<td><?= $thesis['year'] ?></td>
<td><?= htmlspecialchars($thesis['orientation'] ?? 'N/A') ?></td>
<td><?= htmlspecialchars($thesis['ap_program'] ?? 'N/A') ?></td>
<td>
<?php $badgeType = 'publish'; $badgeValue = $thesis['is_published']; include APP_ROOT . '/templates/partials/status-badge.php'; ?>
<?php if (!empty($thesis['access_type'])): ?>
<br><?php $badgeType = 'access'; $badgeValue = $thesis['access_type']; include APP_ROOT . '/templates/partials/status-badge.php'; ?>
<?php endif; ?>
</td>
<td>
<div class="admin-actions">
<a href="/admin/thanks.php?id=<?= $thesis['id'] ?>" class="admin-btn-sm admin-btn-view">Voir</a>
<a href="/admin/edit.php?id=<?= $thesis['id'] ?>" class="admin-btn-sm admin-btn-edit">Éditer</a>
<form method="post" action="actions/publish.php" class="publish-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="thesis_id" value="<?= $thesis['id'] ?>">
<?php if ($thesis['is_published']): ?>
<input type="hidden" name="action" value="unpublish">
<button type="submit" class="admin-btn-sm admin-btn-unpublish"
onclick="return confirm('Retirer de la publication ?')">Dépublier</button>
<?php else: ?>
<input type="hidden" name="action" value="publish">
<button type="submit" class="admin-btn-sm admin-btn-publish">Publier</button>
<?php endif; ?>
</form>
<form method="post" action="actions/delete.php" id="delete-form-<?= $thesis['id'] ?>" class="publish-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="thesis_id" value="<?= $thesis['id'] ?>">
<button type="button" class="admin-btn-sm admin-btn-delete"
onclick="deleteThesis(<?= $thesis['id'] ?>, <?= htmlspecialchars(json_encode($thesis['title']), ENT_QUOTES) ?>)">Supprimer</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php
$baseParams = array_filter([
'search' => $searchQuery,
'year' => $yearFilter ?: '',
'orientation' => $orientationFilter ?: '',
'ap' => $apFilter ?: '',
'sort' => $sortCol,
'dir' => $sortDir,
]);
include APP_ROOT . '/templates/partials/pagination.php';
?>
</main>
<!-- ══════════════════════════════════════════════════════════════
IMPORT DIALOG
══════════════════════════════════════════════════════════════ -->
<dialog id="import-dialog" class="admin-dialog" aria-labelledby="import-dialog-title">
<div class="admin-dialog__header">
<h2 id="import-dialog-title">Importer une liste de TFE</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="document.getElementById('import-dialog').close()">&#x2715;</button>
</div>
<?php if ($importMessage || !empty($importErrors)): ?>
<div class="admin-import-status-card">
<?php if (!empty($importErrors)): ?>
<div class="toast admin-import-status-card__errors" role="alert" data-type="error">
<strong>⚠ Erreurs :</strong>
<ul class="admin-error-list">
<?php foreach ($importErrors as $err): ?>
<li><?= htmlspecialchars($err) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if ($importMessage): ?>
<p class="toast admin-import-status-card__success" role="status" data-type="success">✓ <?= htmlspecialchars($importMessage) ?></p>
<?php endif; ?>
</div>
<?php endif; ?>
<form method="post" enctype="multipart/form-data" class="admin-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<div>
<label for="csv_file">Fichier CSV</label>
<div class="admin-file-input">
<input type="file" id="csv_file" name="csv_file" accept=".csv" required>
<small class="admin-file-hint">
Colonnes : Identifiant, Titre, Sous-titre, Auteur·ice(s), Contact, Promoteur·ice(s), Format, Année, AP, Orientation, Finalité, Mots-clés, Synopsis, Contexte, Remarques, Langue, Autorisation, License, taille, Points sur 20, lien BAIU<br>
Quatre premières lignes ignorées — Séparateur : virgule — UTF-8
</small>
</div>
</div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Importer</button>
<button type="button" class="admin-btn-secondary"
onclick="document.getElementById('import-dialog').close()">Annuler</button>
</div>
</form>
<?php if (!empty($importResults)): ?>
<details class="admin-import-log-details">
<summary>Logs d'importation (<?= count($importResults) ?> entrées)</summary>
<ul class="admin-import-log">
<?php foreach ($importResults as $r): ?>
<li class="admin-import-log__item admin-import-log__item--<?= $r['type'] ?>"><?= htmlspecialchars($r['msg']) ?></li>
<?php endforeach; ?>
</ul>
</details>
<?php endif; ?>
</dialog>
<?php if ($importMessage || !empty($importErrors)): ?>
<script>document.getElementById('import-dialog').showModal();</script>
<?php endif; ?>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,49 @@
<?php
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
if (!AdminAuth::hasPassword()) {
header('Location: /admin/');
exit;
}
if (AdminAuth::isAuthenticated()) {
header('Location: /admin/');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$password = $_POST['password'] ?? '';
if (AdminAuth::login($password)) {
header('Location: /admin/');
exit;
}
$error = 'Mot de passe incorrect.';
}
$pageTitle = 'Connexion';
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<div class="admin-login-wrap">
<div class="admin-login-box">
<h2>Administration</h2>
<?php if ($error): ?>
<p class="toast" role="alert" data-type="error">⚠ <?= htmlspecialchars($error) ?></p>
<?php endif; ?>
<form method="post" action="/admin/login.php" class="admin-form">
<div>
<label for="password">Mot de passe</label>
<input type="password" id="password" name="password" required autofocus>
</div>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Se connecter</button>
</div>
</form>
</div>
</div>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,8 @@
<?php
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::logout();
header('Location: /admin/login.php');
exit;

View File

@@ -0,0 +1,4 @@
<?php
// Redirects legacy /admin/logs.php → /admin/system.php?tab=nginx_access
header('Location: /admin/system.php?tab=nginx_access', true, 301);
exit;

View File

@@ -0,0 +1,317 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
$pageTitle = "Paramètres";
$hasPassword = AdminAuth::hasPassword();
$maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag');
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SmtpRelay.php';
$db = new Database();
$siteSettings = $db->getAllSettings();
$stats = $db->getThesesStats();
$smtpSettings = SmtpRelay::getSettings($db);
$smtpConfigured = SmtpRelay::isConfigured($db);
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<h1>Paramètres</h1>
<!-- ══════════════════════════════════════════════════════════════
MAINTENANCE
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-maintenance-title">
<h2 id="settings-maintenance-title">Maintenance</h2>
<div class="param-maintenance-row">
<?php if ($maintenanceOn): ?>
<p>
<strong>⚠ Mode maintenance activé</strong> — le site public est inaccessible.
</p>
<form method="post" action="actions/maintenance.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="disable_maintenance">
<input type="hidden" name="redirect" value="/admin/parametres.php">
<button type="submit">Désactiver la maintenance</button>
</form>
<?php else: ?>
<p>Site public : <strong>en ligne</strong></p>
<form method="post" action="actions/maintenance.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="enable_maintenance">
<input type="hidden" name="redirect" value="/admin/parametres.php">
<button type="submit" class="param-btn-warning"
onclick="return confirm('Mettre le site en maintenance ? Les visiteurs verront une page 503.')">
Activer la maintenance
</button>
</form>
<?php endif; ?>
</div>
<!-- Export database -->
<fieldset class="param-export-zone">
<legend>Exporter la base de données</legend>
<p>Télécharger une copie complète de la base de données SQLite.
Cela inclut tous les TFE, auteurs, jury, mots-clés, paramètres, etc.</p>
<button type="button" class="param-btn-export"
onclick="document.getElementById('export-db-dialog').showModal()">
Exporter la base de données
</button>
</fieldset>
<!-- Danger zone: delete all TFE → now inside maintenance -->
<fieldset class="param-danger-zone">
<legend>Supprimer tous les TFE</legend>
<p>
Supprime définitivement tous les TFE de la base de données, y compris auteurs,
promoteurs, tags, fichiers associés. Cette action est <strong>irréversible</strong>.
</p>
<form method="post" action="actions/delete.php"
onsubmit="return confirm('⚠ Supprimer définitivement TOUS les TFE ? Cette action est IRRÉVERSIBLE.')">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="delete_all" value="1">
<button type="submit" class="param-btn-danger">Supprimer tous les TFE (<?= $stats['total'] ?? '?' ?>)</button>
</form>
</fieldset>
</section>
<!-- ══════════════════════════════════════════════════════════════
FORMULAIRE
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-formulaire-title">
<h2 id="settings-formulaire-title">Formulaire</h2>
<p>Options de visibilité disponibles dans le formulaire d'ajout de TFE.</p>
<p class="param-note">L'option <strong>Libre</strong> ne sera activée qu'à partir de l'année académique prochaine.</p>
<form method="post" action="actions/settings.php" class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="formulaire">
<fieldset>
<legend>Types d'accès</legend>
<label class="param-checkbox">
<input type="checkbox" name="access_type_interdit_enabled" value="1"
<?= ($siteSettings['access_type_interdit_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
<span>
<strong>Interdit</strong><br>
<small>TFE non disponible en physique ni sur le site</small>
</span>
</label>
<label class="param-checkbox">
<input type="checkbox" name="access_type_interne_enabled" value="1"
<?= ($siteSettings['access_type_interne_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
<span>
<strong>Interne</strong><br>
<small>TFE accessible uniquement sur place en physique</small>
</span>
</label>
<label class="param-checkbox param-checkbox--disabled">
<input type="checkbox" name="access_type_libre_enabled" value="1"
<?= ($siteSettings['access_type_libre_enabled'] ?? '0') === '1' ? 'checked' : '' ?>>
<span>
<strong>Libre</strong><br>
<small>Libre accès — disponible à partir de l'année académique prochaine</small>
</span>
</label>
</fieldset>
<button type="submit">Enregistrer</button>
</form>
</section>
<!-- ══════════════════════════════════════════════════════════════
RELAY SMTP
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-smtp-title">
<h2 id="settings-smtp-title">Relay SMTP</h2>
<p>
Identifiants du serveur SMTP utilisé pour l'envoi d'e-mails
(notifications, partage de TFE, etc.).
</p>
<div class="param-smtp-status">
<?php if ($smtpConfigured): ?>
<span class="param-badge-ok">✓ Configuré</span>
<span><?= htmlspecialchars($smtpSettings['host']) ?>:<?= (int)$smtpSettings['port'] ?> (<?= htmlspecialchars($smtpSettings['encryption']) ?>)</span>
<?php else: ?>
<span class="param-badge-warn">✗ Non configuré</span>
<?php endif; ?>
</div>
<form method="post" action="actions/settings.php" class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="smtp">
<div class="param-grid">
<div>
<label for="smtp_host">Hôte SMTP</label>
<input type="text" id="smtp_host" name="smtp_host"
value="<?= htmlspecialchars($smtpSettings['host']) ?>"
placeholder="smtp.example.com">
</div>
<div>
<label for="smtp_port">Port</label>
<input type="number" id="smtp_port" name="smtp_port"
value="<?= (int)$smtpSettings['port'] ?>"
min="1" max="65535">
</div>
<div>
<label for="smtp_encryption">Chiffrement</label>
<select id="smtp_encryption" name="smtp_encryption">
<option value="tls" <?= $smtpSettings['encryption'] === 'tls' ? 'selected' : '' ?>>TLS (STARTTLS)</option>
<option value="ssl" <?= $smtpSettings['encryption'] === 'ssl' ? 'selected' : '' ?>>SSL (SMTPS)</option>
<option value="none" <?= $smtpSettings['encryption'] === 'none' ? 'selected' : '' ?>>Aucun</option>
</select>
</div>
<div>
<label for="smtp_username">Nom d'utilisateur</label>
<input type="text" id="smtp_username" name="smtp_username"
value="<?= htmlspecialchars($smtpSettings['username']) ?>">
</div>
<div>
<label for="smtp_password">Mot de passe</label>
<input type="password" id="smtp_password" name="smtp_password"
value="<?= htmlspecialchars($smtpSettings['password']) ?>"
autocomplete="new-password"
placeholder="Laissez vide pour ne pas modifier">
</div>
</div>
<fieldset class="param-fieldset-inline">
<legend>Expéditeur par défaut</legend>
<div class="param-grid">
<div>
<label for="smtp_from_email">Adresse e-mail</label>
<input type="email" id="smtp_from_email" name="smtp_from_email"
value="<?= htmlspecialchars($smtpSettings['from_email']) ?>"
placeholder="noreply@example.com">
</div>
<div>
<label for="smtp_from_name">Nom d'expéditeur</label>
<input type="text" id="smtp_from_name" name="smtp_from_name"
value="<?= htmlspecialchars($smtpSettings['from_name']) ?>">
</div>
</div>
</fieldset>
<button type="submit">Enregistrer</button>
</form>
</section>
<!-- ══════════════════════════════════════════════════════════════
COMPTE ADMINISTRATEUR
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-account-title">
<h2 id="settings-account-title">Compte administrateur</h2>
<dl class="param-account-status">
<div>
<dt>Authentification PHP</dt>
<dd><?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Active'; $badgeWarnLabel = 'Non configurée'; include APP_ROOT . '/templates/partials/status-badge.php'; ?></dd>
</div>
<div>
<dt>Stockage du hash</dt>
<dd>
<code>site_settings (DB)</code>
<?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Présent'; $badgeWarnLabel = 'Absent'; include APP_ROOT . '/templates/partials/status-badge.php'; ?>
</dd>
</div>
</dl>
<?php if (!$hasPassword): ?>
<p class="param-note">
Aucun mot de passe PHP configuré. Le formulaire ci-dessous stockera
un hash bcrypt dans la base de données.
</p>
<?php endif; ?>
<form method="post" action="/admin/actions/account.php" class="param-form" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="redirect" value="/admin/parametres.php">
<?php if ($hasPassword): ?>
<div>
<label for="current_password">Mot de passe actuel</label>
<input type="password" id="current_password"
name="current_password" required autocomplete="current-password">
</div>
<?php endif; ?>
<div>
<label for="new_password">Nouveau mot de passe</label>
<input type="password" id="new_password"
name="new_password" required autocomplete="new-password"
minlength="12">
<small>Minimum 12 caractères.</small>
</div>
<div>
<label for="confirm_password">Confirmer le mot de passe</label>
<input type="password" id="confirm_password"
name="confirm_password" required autocomplete="new-password">
</div>
<button type="submit">
<?= $hasPassword ? 'Mettre à jour le mot de passe' : 'Définir le mot de passe' ?>
</button>
</form>
<!-- Danger zone: remove credentials -->
<?php if ($hasPassword): ?>
<fieldset class="param-danger-zone">
<legend>Supprimer la configuration du mot de passe PHP</legend>
<p>
Supprime le hash de la base de données. L'accès admin
dépendra uniquement de l'authentification nginx Basic Auth si elle est configurée.
</p>
<form method="post" action="/admin/actions/account.php"
onsubmit="return confirm('Supprimer le mot de passe PHP ? L\'accès admin ne sera protégé que par nginx Basic Auth.')">>
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="remove_credentials">
<input type="hidden" name="redirect" value="/admin/parametres.php">
<input type="hidden" name="current_password_remove" value="">
<button type="submit" class="param-btn-danger">Supprimer le mot de passe</button>
</form>
</fieldset>
<?php endif; ?>
</section>
<!-- ══════════════════════════════════════════════════════════════════
EXPORT DATABASE DIALOG
═══════════════════════════════════════════════════════════════ -->
<dialog id="export-db-dialog" class="admin-dialog" aria-labelledby="export-db-dialog-title">
<div class="admin-dialog__header">
<h2 id="export-db-dialog-title">Exporter la base de données</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="document.getElementById('export-db-dialog').close()">&#x2715;</button>
</div>
<p>Télécharger une copie complète de la base de données SQLite.
Cela inclut tous les TFE, auteurs, jury, mots-clés, paramètres, etc.</p>
<div class="admin-form-footer">
<a href="/admin/actions/export-db.php" class="admin-btn">Exporter la base de données</a>
<button type="button" class="admin-btn-secondary"
onclick="document.getElementById('export-db-dialog').close()">Annuler</button>
</div>
</dialog>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -0,0 +1,4 @@
<?php
// Redirects legacy /admin/status.php → /admin/system.php?tab=status
header('Location: /admin/system.php?tab=status', true, 301);
exit;

View File

@@ -0,0 +1,146 @@
<?php
/**
* system-fragment.php — returns only the tab-panel HTML for the admin system page.
*
* Called by fetch() from system.php JS when switching tabs or changing line count.
* With JS disabled the user never hits this URL directly; the tab <a> hrefs still
* point at system.php?tab=… so navigation degrades gracefully.
*
* Response: text/html fragment (no <html>/<head>/<body> wrapper).
* On any auth failure or bad request: 403 / 400 with a plain-text body.
*/
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SystemCache.php';
require_once APP_ROOT . '/src/Controllers/SystemController.php';
if (!AdminAuth::isAuthenticated()) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo 'Non autorisé';
exit;
}
// ── Validate inputs ────────────────────────────────────────────────────────
$tab = $_GET['tab'] ?? 'nginx_access';
if ($tab !== 'nginx_config' && !array_key_exists($tab, SystemController::LOG_FILES)) {
$tab = 'nginx_access';
}
$n = isset($_GET['n']) ? (int) $_GET['n'] : 100;
if (!in_array($n, SystemController::ALLOWED_LINES, true)) {
$n = 100;
}
header('Content-Type: text/html; charset=utf-8');
header('X-Robots-Tag: noindex');
// ── Build data via controller ──────────────────────────────────────────────
$_db = new Database();
$_cache = new SystemCache($_db->getPDO());
$_controller = new SystemController($_db, $_cache);
// ── Render ─────────────────────────────────────────────────────────────────
if ($tab === 'nginx_config') {
$data = $_controller->getNginxConfigData();
$lines = $data['lines'];
$source = $data['source'];
$meta = $data['meta'];
$error = $data['error'];
if ($meta): ?>
<div class="log-meta">
<span data-label="Fichier"><?= htmlspecialchars($meta['path']) ?></span>
<span data-label="Taille"><?= $meta['size'] ?></span>
<span data-label="Modifié"><?= $meta['mtime'] ?></span>
<?php if ($source === 'live'): ?>
<span class="nginx-source-badge nginx-source-badge--live">● Config déployée</span>
<?php else: ?>
<span class="nginx-source-badge nginx-source-badge--local">⚠ Référence locale (config live inaccessible)</span>
<?php endif; ?>
</div>
<?php endif;
if ($error !== null): ?>
<div class="log-unavailable">
<strong>Configuration nginx non disponible</strong>
<div class="log-unavail-path"><?= htmlspecialchars($error) ?></div>
<?php if (php_sapi_name() === 'cli-server'): ?>
<div class="log-unavail-dev">
En développement, <code>/etc/nginx/sites-available/posterg</code> n'existe pas.
La config de référence se trouve dans <code>nginx/posterg.conf</code>.
</div>
<?php endif; ?>
</div>
<?php elseif (empty($lines)): ?>
<div class="log-empty">Le fichier de configuration est vide.</div>
<?php else: ?>
<div class="log-output" id="log-output" role="region" aria-label="Configuration nginx">
<button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier la configuration"
onclick="copyLogContent(this);return false">Copier</button>
<?php foreach ($lines as $i => $line): ?>
<span class="log-line <?= SystemController::nginxLineClass($line) ?>"
data-n="<?= $i + 1 ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span>
<?php endforeach; ?>
</div>
<?php endif;
} else {
// ── Log tab ────────────────────────────────────────────────────────────
$data = $_controller->getLogData($tab, $n);
$logLines = $data['lines'];
$logError = $data['error'];
$logMeta = $data['meta'];
?>
<div class="log-toolbar">
<form id="lines-form" hx-get="/admin/system-fragment.php"
hx-target="#sys-tab-panel"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
hx-trigger="change"
hx-vals='{"tab":"<?= htmlspecialchars($tab) ?>"}'>
<label for="lines-select">Afficher</label>
<select id="lines-select" name="n" aria-label="Nombre de lignes">
<?php foreach (SystemController::ALLOWED_LINES as $opt): ?>
<option value="<?= $opt ?>" <?= $opt === $n ? 'selected' : '' ?>><?= $opt ?> dernières lignes</option>
<?php endforeach; ?>
</select>
</form>
<?php if ($logLines !== null && count($logLines) > 0): ?>
<span class="log-count-badge"><?= count($logLines) ?> ligne(s)</span>
<?php endif; ?>
</div>
<?php if ($logMeta): ?>
<div class="log-meta">
<span data-label="Fichier"><?= htmlspecialchars($logMeta['path']) ?></span>
<span data-label="Taille"><?= $logMeta['size'] ?></span>
<span data-label="Modifié"><?= $logMeta['mtime'] ?></span>
</div>
<?php endif; ?>
<?php if ($logError !== null): ?>
<div class="log-unavailable">
<strong>Journaux non disponibles</strong>
<div class="log-unavail-path"><?= $logError ?></div>
<?php if (php_sapi_name() === 'cli-server'): ?>
<div class="log-unavail-dev">
En environnement de développement, les logs nginx ne sont pas disponibles.
Cette page est pleinement fonctionnelle sur le serveur de production.
</div>
<?php endif; ?>
</div>
<?php elseif (empty($logLines)): ?>
<div class="log-empty">Le fichier journal est vide.</div>
<?php else: ?>
<div class="log-output" id="log-output" role="log" aria-live="off" aria-label="Contenu du journal">
<button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier le contenu"
onclick="copyLogContent(this);return false">Copier</button>
<?php foreach ($logLines as $i => $line): ?>
<span class="log-line <?= SystemController::logLineClass($line) ?>"
data-n="<?= count($logLines) - $i ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span>
<?php endforeach; ?>
</div>
<?php endif;
}

358
app/public/admin/system.php Normal file
View File

@@ -0,0 +1,358 @@
<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SystemCache.php';
require_once APP_ROOT . '/src/Controllers/SystemController.php';
AdminAuth::requireLogin();
$pageTitle = "Système";
$_db = new Database();
$_cache = new SystemCache($_db->getPDO());
$_controller = new SystemController($_db, $_cache);
// ?refresh=1 force-busts all cached sections
if (isset($_GET['refresh']) && $_GET['refresh'] === '1') {
$_controller->invalidateAll();
}
// ── Status / PHP / Disk data ──────────────────────────────────────────────────
$statusData = $_controller->getStatusData();
$checks = $statusData['checks'];
$statusCached = $statusData['cached'];
$statusCacheAge = $statusData['cacheAge'];
$phpInfo = $_controller->getPhpInfo();
$diskInfo = $_controller->getDiskInfo();
$diskTotal = $diskInfo['total'];
$diskFree = $diskInfo['free'];
$diskUsed = $diskInfo['used'];
$diskPct = $diskInfo['pct'];
$diskColor = SystemController::diskColor($diskPct);
// ── Active tab + line count ───────────────────────────────────────────────────
$activeTab = $_GET['tab'] ?? 'nginx_access';
if ($activeTab === 'status') {
$activeTab = 'nginx_access'; // legacy redirect
} elseif ($activeTab !== 'nginx_config' && !array_key_exists($activeTab, SystemController::LOG_FILES)) {
$activeTab = 'nginx_access';
}
$selectedN = isset($_GET['n']) ? (int) $_GET['n'] : 100;
if (!in_array($selectedN, SystemController::ALLOWED_LINES, true)) {
$selectedN = 100;
}
// ── Tab content data ──────────────────────────────────────────────────────────
$logLines = null;
$logError = null;
$logFileMeta = null;
$nginxConfigLines = null;
$nginxConfigSource = null;
$nginxConfigError = null;
$nginxConfigMeta = null;
if ($activeTab === 'nginx_config') {
$nginxData = $_controller->getNginxConfigData();
$nginxConfigLines = $nginxData['lines'];
$nginxConfigSource = $nginxData['source'];
$nginxConfigMeta = $nginxData['meta'];
$nginxConfigError = $nginxData['error'];
} else {
$logData = $_controller->getLogData($activeTab, $selectedN);
$logLines = $logData['lines'];
$logError = $logData['error'];
$logFileMeta = $logData['meta'];
}
$isAdmin = true; $bodyClass = 'admin-body';
$extraCss = ['/assets/css/system.css'];
// HTMX loaded once in footer; status collapse + copy via inline JS
require_once APP_ROOT . '/templates/head.php';
// Restore collapsed state from cookie
$collapsed = $_COOKIE['sys_collapsed'] ?? null;
$statusInitiallyCollapsed = $collapsed === '1';
?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<h1>Système</h1>
<p class="sys-refresh-note">
Affiché le <?= date('d/m/Y à H:i:s') ?> —
<a href="?tab=<?= htmlspecialchars($activeTab) ?>&amp;n=<?= $selectedN ?>">Rafraîchir</a> —
<a href="?tab=<?= htmlspecialchars($activeTab) ?>&amp;n=<?= $selectedN ?>&amp;refresh=1">Forcer actualisation</a>
</p>
<!-- ════════════════════════════════════════════════════════════════════
STATUS SECTION — always visible above tabs
════════════════════════════════════════════════════════════════════ -->
<section class="sys-status-section" aria-label="Statut du système">
<div class="sys-status-header">
<h2 class="srv-section-title srv-section-title--compact">Statut
<?php if ($statusCached && $statusCacheAge !== null): ?>
<span class="sys-cache-badge sys-cache-badge--hit" title="Données en cache">
⚡ Cache — il y a <?= $statusCacheAge ?>s
</span>
<?php else: ?>
<span class="sys-cache-badge sys-cache-badge--miss" title="Données fraîches">
⟳ Actualisé
</span>
<?php endif; ?>
</h2>
<button id="sys-status-toggle" class="sys-status-toggle"
aria-expanded="<?= $statusInitiallyCollapsed ? 'false' : 'true' ?>" aria-controls="sys-status-body"
type="button"
onclick="var b=document.getElementById('sys-status-body');var c=b.hidden;b.hidden=!c;this.setAttribute('aria-expanded',c);this.textContent=c?'▲ Réduire':'▼ Développer';document.cookie='sys_collapsed='+(!c)+';path=/;max-age=31536000';return false">
<?= $statusInitiallyCollapsed ? '▼ Développer' : '▲ Réduire' ?>
</button>
</div>
<div id="sys-status-body"<?= $statusInitiallyCollapsed ? ' hidden' : '' ?>>
<div class="srv-grid">
<?php foreach ($checks as $check): ?>
<?php $st = $check['status'] ?? 'unknown'; ?>
<div class="srv-card">
<div class="srv-card__header">
<span class="srv-card__name"><?= htmlspecialchars($check['label']) ?></span>
<span class="<?= SystemController::statusClass($st) ?>"><?= SystemController::statusLabel($st) ?></span>
</div>
<?php if (!empty($check['detail'])): ?>
<div class="srv-card__detail"><?= htmlspecialchars($check['detail']) ?></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<div class="sys-status-meta">
<div>
<h3 class="srv-section-title srv-section-title--sub">Environnement PHP</h3>
<div class="php-grid php-grid--flush">
<?php foreach ($phpInfo as $key => $val): ?>
<div class="php-item">
<div class="php-item__key"><?= htmlspecialchars($key) ?></div>
<div class="php-item__val"><?= htmlspecialchars($val) ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<div>
<h3 class="srv-section-title srv-section-title--sub">Espace disque</h3>
<div class="disk-bar-wrap">
<div class="disk-bar" style="--disk-pct:<?= $diskPct ?>%;--disk-color:<?= $diskColor ?>"></div>
</div>
<div class="disk-stats">
<span><?= SystemController::humanBytes($diskUsed) ?> utilisé (<?= $diskPct ?>%)</span>
<span><?= SystemController::humanBytes($diskFree) ?> libre / <?= SystemController::humanBytes($diskTotal) ?></span>
</div>
</div>
</div>
</div>
</section>
<!-- ── Tab bar ─────────────────────────────────────────────────────── -->
<nav class="sys-tabs" aria-label="Journaux et configuration">
<?php foreach (SystemController::LOG_FILES as $key => $def): ?>
<a href="?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
class="sys-tab <?= $activeTab === $key ? 'active' : '' ?>"
hx-get="/admin/system-fragment.php?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
hx-target="#sys-tab-panel"
hx-push-url="?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
data-tab="<?= htmlspecialchars($key) ?>"
<?= $activeTab === $key ? 'aria-current="page"' : '' ?>>
<?= htmlspecialchars($def['label']) ?>
</a>
<?php endforeach; ?>
<a href="?tab=nginx_config"
class="sys-tab <?= $activeTab === 'nginx_config' ? 'active' : '' ?>"
hx-get="/admin/system-fragment.php?tab=nginx_config"
hx-target="#sys-tab-panel"
hx-push-url="?tab=nginx_config"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
data-tab="nginx_config"
<?= $activeTab === 'nginx_config' ? 'aria-current="page"' : '' ?>>nginx — config</a>
</nav>
<!-- Tab panel — content swapped by HTMX -->
<div id="sys-tab-panel">
<?php if ($activeTab === 'nginx_config'): ?>
<!-- ════════════════════════════════════════════════════════════════════
NGINX CONFIG PANEL
════════════════════════════════════════════════════════════════════ -->
<?php if ($nginxConfigMeta): ?>
<div class="log-meta">
<span data-label="Fichier"><?= htmlspecialchars($nginxConfigMeta['path']) ?></span>
<span data-label="Taille"><?= $nginxConfigMeta['size'] ?></span>
<span data-label="Modifié"><?= $nginxConfigMeta['mtime'] ?></span>
<?php if ($nginxConfigSource === 'live'): ?>
<span class="nginx-source-badge nginx-source-badge--live">● Config déployée</span>
<?php else: ?>
<span class="nginx-source-badge nginx-source-badge--local">⚠ Référence locale (config live inaccessible)</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($nginxConfigError !== null): ?>
<div class="log-unavailable">
<strong>Configuration nginx non disponible</strong>
<div class="log-unavail-path"><?= htmlspecialchars($nginxConfigError) ?></div>
<?php if (php_sapi_name() === 'cli-server'): ?>
<div class="log-unavail-dev">
En développement, <code>/etc/nginx/sites-available/posterg</code> n'existe pas.
La config de référence se trouve dans <code>nginx/posterg.conf</code>.
</div>
<?php endif; ?>
</div>
<?php elseif (empty($nginxConfigLines)): ?>
<div class="log-empty">Le fichier de configuration est vide.</div>
<?php else: ?>
<div class="log-output" id="log-output" role="region" aria-label="Configuration nginx">
<button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier la configuration"
onclick="copyLogContent(this);return false">
Copier
</button>
<?php foreach ($nginxConfigLines as $i => $line): ?>
<span class="log-line <?= SystemController::nginxLineClass($line) ?>"
data-n="<?= $i + 1 ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php else: ?>
<!-- ════════════════════════════════════════════════════════════════════
LOG PANEL
════════════════════════════════════════════════════════════════════ -->
<!-- Lines selector (submits via JS on change; no button needed) -->
<div class="log-toolbar">
<label for="lines-select">Afficher</label>
<form id="lines-form" hx-get="/admin/system-fragment.php"
hx-target="#sys-tab-panel"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
hx-trigger="change"
hx-vals='{"tab":"<?= htmlspecialchars($activeTab) ?>"}'>
<label for="lines-select">Afficher</label>
<select id="lines-select" name="n" aria-label="Nombre de lignes">
<?php foreach (SystemController::ALLOWED_LINES as $n): ?>
<option value="<?= $n ?>" <?= $n === $selectedN ? 'selected' : '' ?>>
<?= $n ?> dernières lignes
</option>
<?php endforeach; ?>
</select>
</form>
<?php if ($logLines !== null && count($logLines) > 0): ?>
<span class="log-count-badge"><?= count($logLines) ?> ligne(s)</span>
<?php endif; ?>
</div>
<!-- File metadata -->
<?php if ($logFileMeta): ?>
<div class="log-meta">
<span data-label="Fichier"><?= htmlspecialchars(SystemController::LOG_FILES[$activeTab]['path']) ?></span>
<span data-label="Taille"><?= $logFileMeta['size'] ?></span>
<span data-label="Modifié"><?= $logFileMeta['mtime'] ?></span>
</div>
<?php endif; ?>
<!-- Log output -->
<?php if ($logError !== null): ?>
<div class="log-unavailable">
<strong>Journaux non disponibles</strong>
<div class="log-unavail-path"><?= $logError ?></div>
<?php if (php_sapi_name() === 'cli-server'): ?>
<div class="log-unavail-dev">
En environnement de développement, les logs nginx ne sont pas disponibles.
Cette page est pleinement fonctionnelle sur le serveur de production.
</div>
<?php endif; ?>
</div>
<?php elseif (empty($logLines)): ?>
<div class="log-empty">Le fichier journal est vide.</div>
<?php else: ?>
<div class="log-output" id="log-output" role="log" aria-live="off" aria-label="Contenu du journal">
<button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier le contenu"
onclick="copyLogContent(this);return false">
Copier
</button>
<?php foreach ($logLines as $i => $line): ?>
<span class="log-line <?= SystemController::logLineClass($line) ?>"
data-n="<?= count($logLines) - $i ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div><!-- #sys-tab-panel -->
</main>
<script>
function copyLogContent(btn) {
var logOut = document.querySelector('#log-output');
if (!logOut) return;
var text = Array.from(logOut.querySelectorAll('.log-line'))
.map(function(el){ return el.textContent; }).join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function(){
btn.textContent = '\u2713 Copi\u00e9';
btn.classList.add('copied');
setTimeout(function(){ btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
});
} else {
fallbackCopy(text, btn);
}
}
function fallbackCopy(text, btn) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); btn.textContent = '\u2713 Copi\u00e9'; btn.classList.add('copied');
setTimeout(function(){ btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
} catch(e) {}
document.body.removeChild(ta);
}
// Update active tab class after each HTMX swap on #sys-tab-panel
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'sys-tab-panel') {
var rc = evt.detail.requestConfig;
var tab = null;
// Tab clicks carry ?tab=… in the path
var qIdx = rc.path.indexOf('?');
if (qIdx !== -1) {
tab = new URLSearchParams(rc.path.substring(qIdx + 1)).get('tab');
}
// Line-count form sends tab via hx-vals in parameters
if (!tab && rc.parameters && rc.parameters.tab) {
tab = rc.parameters.tab;
}
if (tab) {
document.querySelectorAll('.sys-tabs .sys-tab').forEach(function(a) {
var isActive = a.getAttribute('data-tab') === tab;
a.classList.toggle('active', isActive);
if (isActive) a.setAttribute('aria-current', 'page');
else a.removeAttribute('aria-current');
});
}
}
});
</script>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

91
app/public/admin/tags.php Normal file
View File

@@ -0,0 +1,91 @@
<?php
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
require_once __DIR__ . '/../../src/Database.php';
$pageTitle = "Gestion des mots-clés";
try {
$db = new Database();
$tags = $db->getAllTagsWithCount();
} catch (Exception $e) {
die("Erreur : " . htmlspecialchars($e->getMessage()));
}
// Flash messages are consumed by the flash-messages partial below.
?>
<?php $isAdmin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main id="main-content">
<h1>Mots-clés (<?= count($tags) ?>)</h1>
<table>
<thead>
<tr>
<th scope="col">Nom</th>
<th scope="col">TFE associés</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($tags as $tag): ?>
<tr>
<td><?= htmlspecialchars($tag['name']) ?></td>
<td class="admin-tags-count"><?= (int)$tag['thesis_count'] ?></td>
<td>
<!-- Rename -->
<form method="post" action="actions/tag.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="rename">
<input type="hidden" name="tag_id" value="<?= (int)$tag['id'] ?>">
<input class="admin-input--inline" type="text" name="new_name"
value="<?= htmlspecialchars($tag['name']) ?>" required>
<button type="submit" class="admin-btn admin-btn--sm">Renommer</button>
</form>
<!-- Merge into another tag -->
<form method="post" action="actions/tag.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="merge">
<input type="hidden" name="source_id" value="<?= (int)$tag['id'] ?>">
<select name="target_id" class="admin-select--inline" required>
<option value="">— Fusionner dans… —</option>
<?php foreach ($tags as $other): ?>
<?php if ($other['id'] !== $tag['id']): ?>
<option value="<?= (int)$other['id'] ?>"><?= htmlspecialchars($other['name']) ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<button type="submit" class="admin-btn admin-btn--sm admin-btn--warning"
onclick="return confirm('Fusionner ce tag dans la cible ? Le tag source sera supprimé.')">
Fusionner
</button>
</form>
<!-- Delete -->
<form method="post" action="actions/tag.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="tag_id" value="<?= (int)$tag['id'] ?>">
<button type="submit" class="admin-btn admin-btn--sm admin-btn--danger"
onclick="return confirm('Supprimer le tag « <?= htmlspecialchars(addslashes($tag['name'])) ?> » ? Cette action est irréversible.')">
Supprimer
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

195
app/public/admin/thanks.php Normal file
View File

@@ -0,0 +1,195 @@
<?php
// Bootstrap application
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
// Configure error reporting
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', 'error.log');
$studentMode = isset($_GET['mode']) && $_GET['mode'] === 'student';
if (!$studentMode) {
AdminAuth::requireLogin();
}
require_once __DIR__ . '/../../src/Database.php';
// Security: Validate thesis ID parameter
$thesisId = null;
$thesis = null;
$files = [];
$error = null;
if (isset($_GET['id'])) {
$thesisId = filter_var($_GET['id'], FILTER_VALIDATE_INT);
if ($thesisId !== false && $thesisId > 0) {
try {
$db = new Database();
// Get thesis data
$thesis = $db->getThesis($thesisId);
if (!$thesis) {
$error = "TFE non trouvé.";
} else {
$files = $db->getThesisFiles($thesisId);
}
} catch (Exception $e) {
error_log("Error loading thesis: " . $e->getMessage());
$error = "Erreur lors de la lecture des données.";
}
} else {
error_log("Invalid thesis ID: " . $_GET['id']);
$error = "Identifiant invalide.";
}
} else {
$error = "Aucun identifiant spécifié.";
}
// Helper function to format file size
function formatFileSize($bytes) {
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' GB';
} elseif ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' MB';
} elseif ($bytes >= 1024) {
return number_format($bytes / 1024, 2) . ' KB';
} else {
return $bytes . ' bytes';
}
}
// Set page title for header
$pageTitle = "Récapitulatif TFE";
?>
<?php
$isAdmin = true;
if ($studentMode) {
$bodyClass = 'admin-body student-body';
require_once APP_ROOT . '/templates/head.php';
} else {
$bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
}
?>
<main id="main-content">
<?php if ($studentMode): ?>
<!-- ═══════════════════ STUDENT MODE: Thank you page ═══════════════════ -->
<div class="thanks-student-page">
<?php if ($error): ?>
<div class="thanks-error">
<h1>⚠ Oups…</h1>
<p><?= htmlspecialchars($error) ?></p>
<a href="/admin/add.php?mode=student" class="btn-new-form">← Retour au formulaire</a>
</div>
<?php elseif ($thesis): ?>
<div class="thanks-success">
<h1>Merci 🎉</h1>
<p class="thanks-message">
Ton TFE <strong><?= htmlspecialchars($thesis['title']) ?></strong> a bien été soumis.
</p>
<a href="/admin/add.php?mode=student" class="btn-new-form">+ Ajouter un nouveau TFE</a>
</div>
<?php else: ?>
<div class="thanks-error">
<h1>Erreur</h1>
<p>Aucune donnée à afficher.</p>
<a href="/admin/add.php?mode=student" class="btn-new-form">← Retour au formulaire</a>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<!-- ═══════════════════ ADMIN MODE: Recap page ═══════════════════ -->
<h1>Récapitulatif TFE</h1>
<?php if ($error): ?>
<p class="toast" role="alert" data-type="error">⚠ <?= htmlspecialchars($error) ?></p>
<p><a href="/admin/add.php" class="admin-btn-secondary">Retour au formulaire</a></p>
<?php elseif ($thesis): ?>
<section>
<h2>Informations de base</h2>
<dl>
<dt>Identifiant</dt><dd><?= htmlspecialchars($thesis['identifier']) ?></dd>
<dt>Titre</dt><dd><?= htmlspecialchars($thesis['title']) ?></dd>
<?php if ($thesis['subtitle']): ?>
<dt>Sous-titre</dt><dd><?= htmlspecialchars($thesis['subtitle']) ?></dd>
<?php endif; ?>
<dt>Auteur·ice(s)</dt><dd><?= htmlspecialchars($thesis['authors']) ?></dd>
<dt>Année</dt><dd><?= htmlspecialchars($thesis['year']) ?></dd>
</dl>
</section>
<section>
<h2>Détails académiques</h2>
<dl>
<dt>Orientation</dt><dd><?= htmlspecialchars($thesis['orientation'] ?? '') ?></dd>
<dt>Atelier pratique</dt><dd><?= htmlspecialchars($thesis['ap_program'] ?? '') ?></dd>
<dt>Finalité</dt><dd><?= htmlspecialchars($thesis['finality_type'] ?? '') ?></dd>
<?php if ($thesis['supervisors']): ?>
<dt>Promoteur·ice(s)</dt><dd><?= htmlspecialchars($thesis['supervisors']) ?></dd>
<?php endif; ?>
</dl>
</section>
<section>
<h2>Contenu</h2>
<dl>
<?php if ($thesis['languages']): ?>
<dt>Langue(s)</dt><dd><?= htmlspecialchars($thesis['languages']) ?></dd>
<?php endif; ?>
<?php if ($thesis['formats']): ?>
<dt>Format(s)</dt><dd><?= htmlspecialchars($thesis['formats']) ?></dd>
<?php endif; ?>
<?php if ($thesis['keywords']): ?>
<dt>Mots-clés</dt><dd><?= htmlspecialchars($thesis['keywords']) ?></dd>
<?php endif; ?>
<?php if ($thesis['file_size_info']): ?>
<dt>Durée / Taille</dt><dd><?= htmlspecialchars($thesis['file_size_info']) ?></dd>
<?php endif; ?>
<?php if ($thesis['baiu_link']): ?>
<dt>Lien</dt><dd><a href="<?= htmlspecialchars($thesis['baiu_link']) ?>" target="_blank" rel="noopener"><?= htmlspecialchars($thesis['baiu_link']) ?></a></dd>
<?php endif; ?>
</dl>
</section>
<?php if (!empty($files)): ?>
<section>
<h2>Fichiers</h2>
<table>
<thead><tr><th scope="col">Type</th><th scope="col">Fichier</th><th scope="col">Taille</th><th scope="col">Date</th></tr></thead>
<tbody>
<?php foreach ($files as $f): ?>
<tr>
<td><?= htmlspecialchars($f['file_type']) ?></td>
<td><?= htmlspecialchars($f['file_name']) ?></td>
<td><?= formatFileSize($f['file_size']) ?></td>
<td><?= date('d/m/Y H:i', strtotime($f['uploaded_at'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>
<?php endif; ?>
<div class="admin-action-bar">
<a href="/admin/edit.php?id=<?= $thesisId ?>" class="admin-btn">Modifier</a>
<a href="/admin/add.php" class="admin-btn-secondary">Ajouter un autre TFE</a>
<a href="/admin/" class="admin-btn-secondary">Retour à la liste</a>
</div>
<?php else: ?>
<p class="admin-muted">Aucune donnée à afficher.</p>
<p><a href="/admin/add.php" class="admin-btn-secondary">Retour au formulaire</a></p>
<?php endif; ?>
<?php endif; ?>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

152
app/public/apropos.php Normal file
View File

@@ -0,0 +1,152 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php';
$currentNav = 'apropos';
define('APROPOS_STATIC_CONTENT', "Ce site POSTERG a été créé pour répertorier et valoriser les mémoires de l'erg École de Recherches Graphique de Bruxelles.\n\nL'objectif est à la fois d'offrir une vitrine aux projets des anciennes étudiantes et de mettre en lumière la diversité des disciplines et des parcours qui façonnent l'histoire de l'école à travers les âges, depuis près de 100 ans.");
/**
* Render a comma-separated list of entries with links.
* Entries joined with comma, last two joined with " & ".
*/
function renderEntries(array $entries): string {
if (empty($entries)) return '';
$parts = [];
foreach ($entries as $e) {
$text = htmlspecialchars($e['text'] ?? '');
$url = $e['url'] ?? '';
if (!empty($url)) {
$parts[] = '<span class="apropos-entry"><a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener">' . $text . '</a></span>';
} else {
$parts[] = '<span class="apropos-entry">' . $text . '</span>';
}
}
$count = count($parts);
if ($count === 1) return $parts[0];
// Join all but last two with ", ", join last two with " & "
$prefix = implode(', ', array_slice($parts, 0, $count - 2));
$suffix = implode(' & ', array_slice($parts, -2));
return $prefix !== '' ? $prefix . ', ' . $suffix : $suffix;
}
try {
$db = Database::getInstance();
// Intro text from pages table
$aboutPage = $db->getPage('about');
$rawContent = $aboutPage ? $aboutPage['content'] : '';
if (empty(trim($rawContent)) || trim($rawContent) === 'Contenu à venir') {
$rawContent = APROPOS_STATIC_CONTENT;
}
// Contacts and credits from apropos_contents table
$contacts = $db->getAproposContent('contacts');
$credits = $db->getAproposContent('credits');
// Apply defaults if DB returns empty
$contacts = is_array($contacts) && !empty($contacts) ? $contacts : null;
$credits = is_array($credits) && !empty($credits) ? $credits : null;
} catch (Exception $e) {
error_log("Error loading about page: " . $e->getMessage());
$rawContent = APROPOS_STATIC_CONTENT;
$contacts = null;
$credits = null;
}
$pd = new Parsedown();
$pd->setSafeMode(true);
$aboutHtml = $pd->text($rawContent);
$pageTitle = 'À Propos Posterg';
$metaDescription = 'À propos de Posterg, le répertoire des mémoires de fin d\'études de l\'erg École de Recherches Graphiques de Bruxelles.';
$ogTags = [
'type' => 'website',
'title' => $pageTitle,
'description' => $metaDescription,
'url' => 'https://posterg.erg.be/apropos.php',
'site_name' => 'Posterg ERG',
];
$extraCss = ['/assets/css/apropos.css'];
$bodyClass = 'apropos-body';
?>
<?php include APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main class="apropos-main" id="main-content">
<div class="apropos-layout">
<!-- LEFT: sticky table of contents -->
<nav class="apropos-toc" aria-label="Sections de la page">
<p class="apropos-toc-label">Parties</p>
<ul>
<li><a href="#apropos-intro">À propos</a></li>
<?php if (!empty($contacts)): ?>
<li><a href="#apropos-contacts">Contacts</a></li>
<?php endif; ?>
<?php if (!empty($credits)): ?>
<li><a href="#apropos-credits">Crédits</a></li>
<?php endif; ?>
</ul>
<div class="apropos-toc-erg">
<a href="https://erg.be" target="_blank" rel="noopener">
Site de l'erg ↗
</a>
</div>
</nav>
<!-- MIDDLE: main prose + sections -->
<div class="apropos-content">
<!-- Intro text from DB -->
<section class="apropos-section" id="apropos-intro">
<div class="prose">
<?= $aboutHtml ?>
</div>
</section>
<?php if (!empty($contacts)): ?>
<!-- Contacts section -->
<section class="apropos-section" id="apropos-contacts">
<h2 class="apropos-section-title">Contacts</h2>
<div class="apropos-contacts-grid">
<?php foreach ($contacts as $group): ?>
<address class="apropos-contact-card">
<?= renderEntries($group['entries'] ?? []) ?>
<?php if (!empty($group['role'])): ?>
<span><?= htmlspecialchars($group['role']) ?></span>
<?php endif; ?>
<?php
// Show the email from the first entry (or any that has one on separate line)
$emails = array_filter(array_column($group['entries'] ?? [], 'email'), fn($e) => !empty($e));
foreach ($emails as $email):
?>
<a href="mailto:<?= htmlspecialchars($email) ?>"><?= htmlspecialchars($email) ?></a>
<?php endforeach; ?>
</address>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php if (!empty($credits)): ?>
<!-- Credits section -->
<section class="apropos-section" id="apropos-credits">
<h2 class="apropos-section-title">Crédits</h2>
<dl class="apropos-credits-list">
<?php foreach ($credits as $group): ?>
<div class="apropos-credit-row">
<dt><?= htmlspecialchars($group['label']) ?></dt>
<dd><?= renderEntries($group['entries'] ?? []) ?></dd>
</div>
<?php endforeach; ?>
</dl>
</section>
<?php endif; ?>
</div>
</div>
</main>
<?php include APP_ROOT . '/templates/footer.php'; ?>

View File

@@ -0,0 +1,21 @@
# CSS Architecture
## File Structure
- **variables.css** — all CSS custom properties (single source of truth for every color/token)
- **common.css** — reset, header/nav, search bar, accessibility utilities (loaded on all pages)
- **main.css** — home page
- **search.css** — search/directory page
- **tfe.css** — individual thesis page
- **apropos.css** — about + licence pages
- **system.css** — admin system dashboard
- **admin.css** — admin section (loaded alongside `common.css` on every admin page)
- **modern-normalize.min.css** — third-party reset (minified, do not edit)
## Rules
- Every color value lives in `variables.css` as a CSS custom property.
- No hardcoded hex, rgb(), or rgba() in any other file.
- All files `@import url("./variables.css")` at the top.
- Admin and public share the same token names — no separate admin theme.
- No dark-mode media query. System page uses the same light tokens as the rest of the admin section.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,350 @@
/* ============================================================
À PROPOS PAGE (apropos.php)
============================================================ */
@import url("./variables.css");
/* ------------------------------------------------------------------ */
/* Page shell */
/* ------------------------------------------------------------------ */
.apropos-body {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg-primary);
}
.apropos-main {
flex: 1;
padding: var(--space-xl) var(--space-l) var(--space-2xl);
}
/* ------------------------------------------------------------------ */
/* Two-column layout: sticky TOC nav | content */
/* ------------------------------------------------------------------ */
.apropos-layout {
display: grid;
grid-template-columns: 180px 1fr;
gap: var(--space-2xl);
max-width: 860px;
margin: 0 auto;
align-items: start;
}
/* ------------------------------------------------------------------ */
/* Left — sticky table of contents */
/* ------------------------------------------------------------------ */
.apropos-toc {
position: sticky;
top: var(--space-l);
}
.apropos-toc-label {
font-family: var(--font-body);
font-size: var(--step--2);
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-tertiary);
margin: 0 0 var(--space-2xs) 0;
}
.apropos-toc ul {
list-style: none;
margin: 0 0 var(--space-m) 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
.apropos-toc ul a {
font-family: var(--font-body);
font-size: var(--step--1);
color: var(--text-secondary);
text-decoration: none;
display: block;
padding: var(--space-3xs) 0;
transition: color 0.15s;
border-left: 2px solid transparent;
padding-left: var(--space-2xs);
}
.apropos-toc ul a:hover {
color: var(--text-primary);
border-left-color: var(--accent-primary);
}
.apropos-toc-erg {
padding-top: var(--space-s);
border-top: 1px solid var(--border-primary);
}
.apropos-toc-erg a {
font-size: var(--step--2);
color: var(--accent-primary);
text-decoration: none;
transition: opacity 0.15s;
}
.apropos-toc-erg a:hover {
opacity: 0.75;
}
/* ------------------------------------------------------------------ */
/* Right — main content area */
/* ------------------------------------------------------------------ */
.apropos-content {
display: flex;
flex-direction: column;
gap: 0;
}
.apropos-section {
padding-bottom: var(--space-xl);
border-bottom: 1px solid var(--border-primary);
margin-bottom: var(--space-xl);
}
.apropos-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
/* ------------------------------------------------------------------ */
/* Section titles */
/* ------------------------------------------------------------------ */
.apropos-section-title {
font-family: var(--font-display);
font-size: var(--step-3);
font-weight: 400;
color: var(--text-primary);
margin: 0 0 var(--space-m) 0;
line-height: 1.1;
}
/* ------------------------------------------------------------------ */
/* Intro prose — Markdown-rendered content */
/* ------------------------------------------------------------------ */
.prose {
font-family: var(--font-body);
font-size: var(--step-0);
line-height: 1.6;
color: var(--text-primary);
font-weight: 400;
}
.prose p {
margin: 0 0 1em 0;
}
.prose p:last-child {
margin-bottom: 0;
}
.prose h1,
.prose h2,
.prose h3 {
font-family: var(--font-display);
font-weight: 400;
margin: 1.5em 0 0.5em 0;
}
.prose h1 { font-size: var(--step-3); }
.prose h2 { font-size: var(--step-2); }
.prose h3 { font-size: var(--step-1); }
.prose a {
color: var(--accent-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose ul,
.prose ol {
padding-left: var(--space-m);
margin-bottom: var(--space-s);
}
.prose li {
margin-bottom: 0.3em;
}
.prose strong { font-weight: 700; }
.prose em { font-style: italic; }
.prose code {
font-family: "Courier New", Courier, monospace;
font-size: 0.88em;
background: var(--bg-tertiary);
padding: 0.1em 0.3em;
border-radius: 2px;
}
/* ------------------------------------------------------------------ */
/* Contacts grid */
/* ------------------------------------------------------------------ */
.apropos-contacts-grid {
display: flex;
flex-direction: column;
gap: 0;
}
.apropos-contact-card {
font-style: normal;
padding: var(--space-s) 0;
border-bottom: 1px solid var(--border-primary);
}
.apropos-contact-card:first-child {
border-top: 1px solid var(--border-primary);
}
.apropos-contact-card strong {
display: block;
font-size: var(--step-0);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-3xs);
}
.apropos-contact-card span {
display: block;
font-size: var(--step--1);
color: var(--text-secondary);
line-height: 1.4;
margin-bottom: var(--space-3xs);
}
.apropos-contact-card a {
font-size: var(--step--1);
color: var(--accent-primary);
text-decoration: underline;
text-underline-offset: 2px;
transition: opacity 0.15s;
}
.apropos-contact-card a:hover {
opacity: 0.75;
}
/* ------------------------------------------------------------------ */
/* Credits list */
/* ------------------------------------------------------------------ */
.apropos-credits-list {
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.apropos-credit-row {
display: grid;
grid-template-columns: 1fr 1.6fr;
gap: var(--space-s);
padding: var(--space-s) 0;
border-bottom: 1px solid var(--border-primary);
align-items: baseline;
}
.apropos-credit-row:first-child {
border-top: 1px solid var(--border-primary);
}
.apropos-credits-list dt {
font-size: var(--step--2);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.apropos-credits-list dd {
font-size: var(--step--1);
color: var(--text-primary);
margin: 0;
line-height: 1.5;
}
/* ------------------------------------------------------------------ */
/* Single-column layout — used by licence.php (no sidebar) */
/* ------------------------------------------------------------------ */
.apropos-single {
max-width: 720px;
}
/* ------------------------------------------------------------------ */
/* Responsive */
/* ------------------------------------------------------------------ */
@media (max-width: 900px) {
.apropos-layout {
grid-template-columns: 1fr;
gap: var(--space-l);
}
.apropos-toc {
position: static;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-s);
padding-bottom: var(--space-s);
border-bottom: 1px solid var(--border-primary);
}
.apropos-toc-label {
margin: 0;
}
.apropos-toc ul {
flex-direction: row;
margin: 0;
gap: var(--space-xs);
}
.apropos-toc ul a {
border-left: none;
padding-left: 0;
}
.apropos-toc-erg {
border-top: none;
padding-top: 0;
margin-left: auto;
}
.prose {
font-size: var(--step-0);
}
.apropos-section-title {
font-size: var(--step-2);
}
}
@media (max-width: 600px) {
.apropos-main {
padding: var(--space-m) var(--space-s) var(--space-xl);
}
.prose {
font-size: var(--step-0);
}
.apropos-credit-row {
grid-template-columns: 1fr;
gap: var(--space-3xs);
}
}

View File

@@ -0,0 +1,263 @@
@import url("./variables.css");
@font-face {
font-family: "Ductus";
src: url("../fonts/DuctusRegular.otf") format("opentype");
font-style: normal;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "BBBDMSans";
src: url("../fonts/BBBDMSans-Light.otf") format("opentype");
font-style: normal;
font-weight: 300;
font-display: swap;
}
@font-face {
font-family: "BBBDMSans";
src: url("../fonts/BBBDMSans-Regular.otf") format("opentype");
font-style: normal;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "BBBDMSans";
src: url("../fonts/BBBDMSans-Medium.otf") format("opentype");
font-style: normal;
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: "BBBDMSans";
src: url("../fonts/BBBDMSans-Bold.otf") format("opentype");
font-style: normal;
font-weight: 700;
font-display: swap;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
body {
font-family: var(--font-body);
background: var(--bg-primary);
color: var(--text-primary);
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: none;
}
/* ============================================================
HEADER / NAV BAR (public pages)
============================================================ */
body > header {
flex-shrink: 0;
background: linear-gradient(
180deg,
var(--gradient-1) 0%,
var(--gradient-2) 33%,
var(--gradient-3) 66%,
var(--gradient-4) 100%
);
}
body > header nav {
padding: var(--space-s) var(--space-s);
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-logo {
font-family: var(--font-display);
font-size: var(--step-2);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-foreground);
text-decoration: none;
text-shadow:
0 0 16px var(--header-shadow-strong),
0 0 32px var(--header-shadow-soft);
}
.nav-left {
display: flex;
align-items: center;
gap: var(--space-l);
}
.nav-left-links,
.nav-right-links {
display: flex;
gap: var(--space-l);
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}
body > header nav > a {
font-family: var(--font-display);
font-size: var(--step-0);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-foreground);
text-decoration: none;
}
body > header nav > ul {
display: flex;
gap: var(--space-l);
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}
body > header nav ul a {
font-size: var(--step--1);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-foreground);
text-decoration: none;
transition: opacity 0.15s;
}
/* Subtle shadow on all header text to improve legibility against the gradient */
body > header nav > a,
body > header nav ul a {
text-shadow:
0 0 16px var(--header-shadow-strong),
0 0 32px var(--header-shadow-soft);
}
body > header nav ul a:hover {
opacity: 1;
}
body > header nav ul a[aria-current="page"] {
opacity: 1;
border-bottom: 1px solid var(--header-nav-active-border);
padding-bottom: 1px;
}
/* ============================================================
SEARCH BAR (shared)
============================================================ */
.header-search-wrap {
padding: 0 0;
background-color: var(--gradient-4);
background: linear-gradient(
180deg,
var(--gradient-4) 0%,
#ffffffee 100%
);
}
.header-search-wrap form[role="search"] {
display: flex;
align-items: center;
gap: var(--space-2xs);
padding: var(--space-3xs) var(--space-s);
border: 1px solid var(--accent-primary);
border-radius: 10px;
background: var(--bg-primary);
width: 100%;
color: var(--accent-primary);
}
.header-search-wrap form[role="search"] svg {
color: var(--text-tertiary);
flex-shrink: 0;
width: 16px;
height: 16px;
stroke: var(--accent-primary);
}
.header-search-wrap form[role="search"] input {
flex: 1;
border: none;
font-size: var(--step--1);
color: var(--text-primary);
background: transparent;
padding: var(--space-3xs) 0;
font-family: inherit;
}
.header-search-wrap form[role="search"] input::placeholder {
color: var(--accent-primary);
}
/* ============================================================
ACCESSIBILITY UTILITIES
============================================================ */
/* Visually-hidden but screen-reader-accessible */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Skip-to-content link (visible only on keyboard focus) */
.skip-link {
position: absolute;
top: -999px;
left: 1rem;
z-index: 9999;
padding: var(--space-2xs) var(--space-s);
background: var(--accent-primary);
color: var(--text-primary);
font-size: var(--step--1);
font-weight: 600;
text-decoration: none;
border-radius: 0 0 4px 4px;
}
.skip-link:focus {
top: 0;
}
/* Consistent keyboard-focus outline for all interactive elements */
:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
/* Respect user motion preferences */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,275 @@
/* ============================================================
HOME PAGE (index.php)
============================================================ */
@import url("./variables.css");
.home-body {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--bg-primary);
}
/* Cards grid — scrollable main area */
.home-main {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
}
.cards-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-s);
list-style: none;
margin: 0;
padding: var(--space-xs) var(--space-s) var(--space-m);
}
@media (min-width: 1400px) {
.cards-container {
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
}
}
@media (max-width: 768px) {
.cards-container {
grid-template-columns: repeat(2, 1fr);
}
}
/* Each card = list item containing a block <a> link */
.card {
display: flex;
flex-direction: column;
cursor: pointer;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-primary);
overflow: hidden;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
.card > a {
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
flex: 1;
border-radius: inherit;
}
/* Media wrapper: <figure> for real images/video, <div class="card__media--gradient"> for placeholders */
.home-body figure {
width: 100%;
aspect-ratio: 4/3;
overflow: hidden;
background: var(--bg-tertiary);
position: relative;
margin: 0; /* reset <figure> default margin */
border-radius: 7px 7px 0 0;
}
.home-body figure img,
.home-body figure video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
.card:hover figure img,
.card:hover figure video {
transform: scale(1.02);
}
.card__media--placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--bg-tertiary), var(--bg-active));
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: var(--step-3);
}
.card__media--gradient {
width: 100%;
aspect-ratio: 4/3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-s);
text-align: center;
box-sizing: border-box;
border-radius: 7px 7px 0 0;
background: linear-gradient(
180deg,
rgba(60, 133, 108, 1) 0%,
rgba(96, 236, 180, 1) 33%,
rgba(227, 144, 255, 1) 66%,
rgba(149, 87, 181, 1) 100%
);
}
.card__gradient-author {
color: var(--accent-foreground);
font-size: var(--step--2);
opacity: 0.85;
margin-bottom: 0.25rem;
display: block;
}
.card__gradient-title {
color: var(--accent-foreground);
font-size: var(--step--1);
font-weight: 600;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Section label */
.home-section-label {
font-style: normal;
background: transparent;
color: var(--text-primary);
font-size: var(--step--2);
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: var(--space-s) var(--space-s) var(--space-xs);
border-bottom: 1px solid var(--border-primary);
margin: 0;
}
/* Card caption — <p> directly inside the card <a> link, below the media */
.home-body li > a > p {
padding: var(--space-2xs) var(--space-xs) var(--space-xs);
font-size: var(--step--1);
line-height: 1.35;
color: var(--text-primary);
margin: 0;
font-weight: 400;
border-top: 1px solid var(--border-primary);
}
/* Empty-state message shown when no cards exist */
.cards-empty {
padding: var(--space-l);
color: var(--text-tertiary);
font-size: var(--step--1);
list-style: none;
}
/* Filter info */
.filter-info {
background: var(--accent-muted);
color: var(--accent-secondary);
padding: var(--space-3xs) var(--space-s);
font-size: var(--step--1);
display: flex;
align-items: center;
gap: var(--space-s);
flex-shrink: 0;
}
.clear-filter {
color: var(--accent-secondary);
text-decoration: none;
padding: var(--space-3xs) var(--space-2xs);
background: var(--accent-muted);
border-radius: 3px;
font-size: var(--step--1);
}
.clear-filter:hover {
background: var(--accent-secondary);
}
/* Pagination */
.pagination-wrap {
border-top: 1px solid var(--border-secondary);
background: var(--bg-primary);
flex-shrink: 0;
}
.pagination-wrap ul {
display: flex;
justify-content: center;
align-items: center;
gap: var(--space-2xs);
padding: var(--space-s);
list-style: none;
margin: 0;
}
.pagination-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0 var(--space-2xs);
border: 1px solid var(--border-secondary);
border-radius: 3px;
color: var(--text-primary);
font-size: var(--step--1);
text-decoration: none;
transition: all 0.15s;
}
.pagination-btn:hover:not(.disabled) {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.pagination-btn.disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
.pagination-info {
font-size: var(--step--1);
color: var(--text-secondary);
padding: 0 var(--space-2xs);
}
.page-current {
font-weight: 600;
color: var(--text-primary);
}
/* Suppress card hover scale for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
.home-body figure img,
.home-body figure video {
transition: none;
}
.card:hover figure img,
.card:hover figure video {
transform: none;
}
.card__media--gradient {
transition: none;
}
.card {
transition: none;
}
.card:hover {
transform: none;
}
}

View File

@@ -0,0 +1,9 @@
/**
* Minified by jsDelivr using clean-css v5.3.3.
* Original file: /npm/modern-normalize@3.0.1/modern-normalize.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */
*,::after,::before{box-sizing:border-box}html{font-family:system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji';line-height:1.15;-webkit-text-size-adjust:100%;tab-size:4}body{margin:0}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-color:currentcolor}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}legend{padding:0}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}
/*# sourceMappingURL=/sm/d2d8cd206fb9f42f071e97460f3ad9c875edb5e7a4b10f900a83cdf8401c53a9.map */

View File

@@ -0,0 +1,338 @@
/* ============================================================
RÉPERTOIRE / SEARCH PAGE (repertoire.php)
============================================================ */
@import url("./variables.css");
.search-body {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--bg-primary);
}
.search-main {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* ---- 6-column index layout ---- */
.repertoire-index {
display: grid;
grid-template-columns: 0.7fr 1.2fr 1.4fr 0.9fr 1.4fr 1fr;
gap: 0;
padding: 0 var(--space-m);
min-height: 100%;
}
@media (max-width: 1024px) {
.repertoire-index {
grid-template-columns: 1fr 1fr 1fr;
padding: 0 var(--space-s);
min-height: auto;
}
.repertoire-col {
border-right: 1px solid var(--border-secondary);
border-bottom: 1px solid var(--border-primary);
}
.repertoire-col:nth-child(3n) {
border-right: none;
}
}
@media (max-width: 600px) {
.repertoire-index {
grid-template-columns: 1fr;
padding: 0 var(--space-s);
min-height: auto;
}
.repertoire-col {
border-right: none;
border-bottom: 1px solid var(--border-primary);
padding: var(--space-s) 0 var(--space-m);
}
.repertoire-col:last-child {
border-bottom: none;
}
}
.repertoire-col {
padding: var(--space-xs) var(--space-2xs) var(--space-l);
border-right: 1px solid var(--border-secondary);
}
.repertoire-col:last-child {
border-right: none;
}
.repertoire-index section > h2 {
font-size: var(--step--2);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-secondary);
font-weight: 400;
margin: 0 0 var(--space-2xs) 0;
padding-bottom: var(--space-3xs);
border-bottom: 1px solid var(--border-secondary);
}
/* Strip list chrome inside repertoire columns */
.repertoire-col ul {
list-style: none;
margin: 0;
padding: 0;
}
/* ---- rep-entry: shared base (button + link variants) ---- */
.rep-entry {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
padding: var(--space-3xs) 0;
margin: 0;
font-family: inherit;
font-size: var(--step-0);
color: var(--text-primary);
line-height: 1.4;
cursor: pointer;
text-decoration: none;
transition: color 0.15s, opacity 0.15s;
}
.rep-entry:hover {
color: var(--accent-primary);
}
/* Link variant (students col) — no underline by default */
.rep-entry--link {
text-decoration: none;
}
/* Selected: accent color */
.rep-entry--selected {
color: var(--accent-primary) !important;
}
/* Faded/disabled: muted, not interactive */
.rep-entry--faded {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
/* Years column — big bold numbers */
.repertoire-col[data-col="years"] .rep-entry {
font-size: var(--step-3);
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.02em;
padding: var(--space-3xs) 0;
}
/* Empty state in students column */
.rep-empty {
color: var(--text-tertiary);
font-size: var(--step--1);
padding: var(--space-3xs) 0;
}
/* ---- HTMX loading indicator ---- */
.rep-indicator {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
background: var(--accent-primary);
opacity: 0;
transition: opacity 0.15s;
z-index: 100;
pointer-events: none;
}
.rep-indicator.htmx-request {
opacity: 1;
animation: rep-progress 1.2s ease-in-out infinite;
}
@keyframes rep-progress {
0% { transform: scaleX(0); transform-origin: left; }
50% { transform: scaleX(0.7); transform-origin: left; }
100% { transform: scaleX(1); transform-origin: left; }
}
/* ---- Search results view (grid) ---- */
.search-results-header {
display: block;
font-size: var(--step--1);
color: var(--text-secondary);
margin: var(--space-s) var(--space-m) var(--space-2xs);
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-m);
list-style: none;
margin: 0;
padding: var(--space-2xs) var(--space-m) var(--space-m);
}
.result-card {
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
.result-card__authors {
font-size: var(--step--1);
font-weight: 500;
color: var(--text-primary);
}
.result-card__title {
font-size: var(--step--1);
color: var(--text-secondary);
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.result-card__meta {
font-size: var(--step--2);
color: var(--accent-primary);
font-style: normal;
}
/* Search controls bar */
.search-controls {
display: flex;
align-items: center;
gap: var(--space-s);
padding: var(--space-3xs) var(--space-m);
border-bottom: 1px solid var(--border-secondary);
flex-shrink: 0;
flex-wrap: wrap;
}
.search-filter-label {
display: flex;
align-items: center;
gap: var(--space-3xs);
font-size: var(--step--2);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
cursor: default;
}
.search-filter-select {
font-size: var(--step--1);
border: 1px solid var(--border-primary);
border-radius: 3px;
padding: var(--space-3xs) var(--space-2xs);
background: var(--bg-primary);
color: var(--text-primary);
font-family: inherit;
cursor: pointer;
}
.search-filter-select:focus {
border-color: var(--accent-primary);
}
.search-apply-btn {
font-size: var(--step--1);
padding: var(--space-3xs) var(--space-xs);
background: var(--accent-primary);
color: var(--accent-foreground);
border: none;
border-radius: 3px;
cursor: pointer;
font-family: inherit;
transition: background 0.15s;
}
.search-apply-btn:hover {
background: var(--accent-secondary);
}
.search-reset-link {
font-size: var(--step--1);
color: var(--text-secondary);
text-decoration: underline;
cursor: pointer;
}
/* Search results pagination */
.pagination-wrap {
display: flex;
justify-content: center;
align-items: center;
gap: var(--space-2xs);
padding: var(--space-m) 0;
}
.pagination-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0 var(--space-2xs);
border: 1px solid var(--border-secondary);
border-radius: 3px;
color: var(--text-primary);
font-size: var(--step--1);
text-decoration: none;
transition: all 0.15s;
}
.pagination-btn:hover:not(.disabled) {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.pagination-btn.disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
.pagination-info {
font-size: var(--step--1);
color: var(--text-secondary);
padding: 0 var(--space-2xs);
}
.search-empty {
padding: var(--space-xl) var(--space-m);
color: var(--text-secondary);
font-size: var(--step-0);
text-align: center;
}
/* Error message */
.search-error {
background: var(--search-error-bg);
border-left: 3px solid var(--search-error-border);
color: var(--search-error-color);
padding: var(--space-2xs) var(--space-s);
font-size: var(--step--1);
margin: var(--space-2xs) var(--space-m);
flex-shrink: 0;
}

View File

@@ -0,0 +1,396 @@
/* ============================================================
SYSTEM PAGE
============================================================ */
@import url("./variables.css");
/* ── System page tabs ──────────────────────────────────────────────────── */
.sys-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-primary);
margin-bottom: var(--space-m);
}
.sys-tab {
display: inline-block;
padding: var(--space-3xs) var(--space-s);
font-size: var(--step--1);
font-weight: 500;
color: var(--text-secondary);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color .15s, border-color .15s;
}
.sys-tab:hover {
color: var(--text-primary);
}
.sys-tab.active {
color: var(--accent-primary);
border-bottom-color: var(--accent-primary);
}
/* ── Status section (always-visible panel above tabs) ─────────────────── */
.sys-status-section {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 6px;
padding: var(--space-s) var(--space-m);
margin-bottom: var(--space-m);
}
.sys-status-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-s);
}
.sys-status-toggle {
background: none;
border: 1px solid var(--border-primary);
color: var(--text-secondary);
border-radius: 3px;
font-size: var(--step--2);
font-family: inherit;
padding: var(--space-3xs) var(--space-2xs);
cursor: pointer;
white-space: nowrap;
transition: color .15s, border-color .15s;
}
.sys-status-toggle:hover {
color: var(--text-primary);
border-color: var(--border-secondary);
}
.sys-status-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m) var(--space-l);
margin-top: var(--space-m);
padding-top: var(--space-m);
border-top: 1px solid var(--border-primary);
}
@media (max-width: 700px) {
.sys-status-meta { grid-template-columns: 1fr; }
}
/* ── Status cards ──────────────────────────────────────────────────────── */
.srv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--space-s);
margin-bottom: var(--space-l);
}
.srv-card {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 5px;
padding: var(--space-s) var(--space-m);
}
.srv-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3xs);
}
.srv-card__name {
font-size: var(--step--1);
text-transform: uppercase;
letter-spacing: .07em;
color: var(--text-secondary);
font-weight: 500;
}
.srv-card__detail {
font-size: var(--step--2);
color: var(--text-tertiary);
margin-top: var(--space-3xs);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
}
.status-ok { color: var(--accent-green); font-weight: 600; font-size: var(--step--1); }
.status-warn { color: var(--warning); font-weight: 600; font-size: var(--step--1); }
.status-err { color: var(--error); font-weight: 600; font-size: var(--step--1); }
.status-unknown { color: var(--text-tertiary); font-weight: 600; font-size: var(--step--1); }
.srv-section-title {
font-size: var(--step--1);
text-transform: uppercase;
letter-spacing: .1em;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-primary);
padding-bottom: var(--space-3xs);
margin: 0 0 var(--space-s);
font-weight: 500;
}
/* Compact variant: no border, no margin — used inside sys-status-header */
.srv-section-title--compact {
margin: 0;
border: none;
padding: 0;
}
/* Sub-section variant: tighter bottom margin — used for PHP/disk sub-headings */
.srv-section-title--sub { margin-bottom: var(--space-xs); }
/* ── PHP info grid ─────────────────────────────────────────────────────── */
.php-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--space-3xs) var(--space-xs);
margin-bottom: var(--space-l);
}
/* Flush variant: no bottom margin — used inside sys-status-meta cell */
.php-grid--flush { margin-bottom: 0; }
.php-item {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: var(--space-3xs) var(--space-xs);
}
.php-item__key {
font-size: var(--step--2);
text-transform: uppercase;
letter-spacing: .06em;
color: var(--text-tertiary);
}
.php-item__val {
font-size: var(--step--1);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
color: var(--text-primary);
margin-top: var(--space-3xs);
}
/* ── Disk bar ──────────────────────────────────────────────────────────── */
.disk-bar-wrap {
background: var(--border-primary);
border-radius: 3px;
height: 6px;
margin-top: var(--space-2xs);
overflow: hidden;
}
.disk-bar {
height: 100%;
border-radius: 3px;
width: var(--disk-pct, 0%);
background: var(--disk-color, var(--accent-green));
transition: width .3s;
}
.disk-stats {
display: flex;
justify-content: space-between;
font-size: var(--step--2);
color: var(--text-secondary);
margin-top: var(--space-3xs);
}
/* ── Tab panel loading state ───────────────────────────────────────────── */
#sys-tab-panel {
min-height: 8rem;
position: relative;
}
#sys-tab-panel.htmx-request {
opacity: 0.4;
pointer-events: none;
transition: opacity 0.1s;
}
#sys-tab-panel.htmx-request::after {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 6px,
rgba(0,0,0,0.03) 6px,
rgba(0,0,0,0.03) 12px
);
border-radius: 4px;
animation: sys-panel-shimmer 1s linear infinite;
background-size: 200% 200%;
}
@keyframes sys-panel-shimmer {
0% { background-position: 0 0; }
100% { background-position: 100% 100%; }
}
/* ── Log viewer ────────────────────────────────────────────────────────── */
.log-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2xs);
margin-bottom: var(--space-m);
}
.log-toolbar label {
font-size: var(--step--1);
color: var(--text-secondary);
}
.log-toolbar select {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
color: var(--text-primary);
border-radius: 4px;
padding: var(--space-3xs) var(--space-xs);
font-size: var(--step--1);
font-family: inherit;
}
.log-toolbar select:focus { outline: 2px solid var(--accent-primary); }
.log-meta {
font-size: var(--step--2);
color: var(--text-tertiary);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
margin-bottom: var(--space-xs);
display: flex;
gap: var(--space-m);
flex-wrap: wrap;
}
.log-meta span::before { content: attr(data-label) ": "; opacity: .6; }
.log-unavailable {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: var(--space-m);
color: var(--text-secondary);
font-size: var(--step--1);
}
.log-unavail-path {
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: var(--step--2);
margin-top: var(--space-3xs);
opacity: .7;
}
.log-unavail-dev {
margin-top: var(--space-2xs);
font-size: var(--step--2);
opacity: .7;
}
.log-empty {
color: var(--text-secondary);
font-size: var(--step--1);
padding: var(--space-s) 0;
}
.log-output {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: var(--space-s);
overflow-x: auto;
font-family: ui-monospace, "SFMono-Regular", Consolas, "Courier New", monospace;
font-size: var(--step--2);
line-height: 1.55;
max-height: 62vh;
overflow-y: auto;
position: relative;
}
.log-line {
display: block;
white-space: pre;
padding: var(--space-3xs) var(--space-3xs);
border-radius: 2px;
color: var(--text-primary);
}
.log-line + .log-line { border-top: 1px solid var(--border-primary); }
.log-crit { color: var(--sys-syntax-crit); background: var(--error-muted-bg); }
.log-error { color: var(--error); }
.log-warn { color: var(--warning); }
.log-notice { color: var(--sys-syntax-notice); }
.log-line::before {
content: attr(data-n);
display: inline-block;
min-width: 4ch;
margin-right: var(--space-xs);
opacity: .3;
text-align: right;
user-select: none;
}
.log-count-badge {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 3px;
font-size: var(--step--2);
padding: var(--space-3xs) var(--space-2xs);
color: var(--text-secondary);
font-family: ui-monospace, monospace;
}
.log-copy-btn {
position: absolute;
top: var(--space-2xs);
right: var(--space-2xs);
background: var(--bg-primary);
border: 1px solid var(--border-primary);
color: var(--text-secondary);
border-radius: 4px;
font-size: var(--step--2);
padding: var(--space-3xs) var(--space-2xs);
cursor: pointer;
font-family: inherit;
transition: color .15s, border-color .15s;
z-index: 2;
}
.log-copy-btn:hover {
color: var(--text-primary);
border-color: var(--accent-primary);
}
.log-copy-btn.copied {
color: var(--accent-green);
border-color: var(--accent-green);
}
.sys-refresh-note {
font-size: var(--step--2);
color: var(--text-secondary);
margin-bottom: var(--space-m);
}
.sys-refresh-note a {
color: var(--accent-primary);
text-decoration: none;
}
.sys-refresh-note a:hover { text-decoration: underline; }
/* ── Cache freshness badges ────────────────────────────────────────────── */
.sys-cache-badge {
display: inline-block;
font-size: var(--step--2);
font-weight: 400;
font-family: ui-monospace, monospace;
padding: var(--space-3xs) var(--space-3xs);
border-radius: 3px;
margin-left: var(--space-xs);
vertical-align: middle;
line-height: 1.6;
}
.sys-cache-badge--hit {
background: var(--warning-muted-bg);
color: var(--warning);
border: 1px solid var(--warning-muted-border);
}
.sys-cache-badge--miss {
background: var(--success-muted-bg);
color: var(--success);
border: 1px solid var(--success-muted-border);
}
/* ── Nginx config viewer ───────────────────────────────────────────────── */
.nginx-source-badge {
display: inline-block;
font-size: var(--step--2);
font-family: ui-monospace, monospace;
padding: var(--space-3xs) var(--space-2xs);
border-radius: 3px;
margin-left: var(--space-2xs);
vertical-align: middle;
}
.nginx-source-badge--live {
background: var(--success-muted-bg);
color: var(--success);
border: 1px solid var(--success-muted-border);
}
.nginx-source-badge--local {
background: var(--warning-muted-bg);
color: var(--warning);
border: 1px solid var(--warning-muted-border);
}
/* Nginx syntax highlight layers inside .log-output */
.nginx-comment { color: var(--sys-syntax-comment); font-style: italic; }
.nginx-directive { color: var(--sys-syntax-directive); }
.nginx-block { color: var(--sys-syntax-block); font-weight: 600; }
.nginx-value { color: var(--sys-syntax-value); }
.nginx-location { color: var(--sys-syntax-location); }

View File

@@ -0,0 +1,195 @@
/* ============================================================
TFE INDIVIDUAL PAGE (tfe.php)
============================================================ */
@import url("./variables.css");
.tfe-body {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg-primary);
}
.tfe-main {
flex: 1;
padding: var(--space-l) var(--space-m) var(--space-xl);
}
/* Two-column article layout */
.tfe-layout {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: var(--space-xl);
width: 100%;
max-width: 1200px;
align-items: start;
}
/* Left column — article header */
.tfe-left {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
/* Author (p) — above title */
.tfe-author {
font-family: var(--font-display);
font-size: var(--step-1);
font-weight: 400;
color: var(--text-primary);
margin: 0;
line-height: 1.3;
}
/* Title (h1) — primary heading, very large */
.tfe-title {
font-family: var(--font-display);
font-size: var(--step-3);
font-weight: 400;
color: var(--text-primary);
margin: 0;
line-height: 1.15;
letter-spacing: -0.01em;
}
/* Metadata description list — target <dl> directly inside article > header */
article dl {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
font-size: var(--step--1);
line-height: 1.4;
margin: 0;
}
/* Each dt/dd pair grouped in a <div> inside <dl> */
article dl > div {
display: flex;
gap: var(--space-3xs);
flex-wrap: wrap;
}
article dl dt {
color: var(--text-primary);
font-weight: 400;
flex-shrink: 0;
}
article dl dd {
color: var(--text-primary);
font-weight: 700;
margin: 0;
}
article dl dd a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
/* Note field: align dt/dd to top, value in italics */
.tfe-meta-note {
align-items: start;
}
.tfe-note-value {
font-style: italic;
}
/* Synopsis paragraph */
.tfe-synopsis-text {
font-size: var(--step--1);
line-height: 1.7;
color: var(--text-primary);
margin: 0;
}
/* Right column — aside (supplementary media) */
.tfe-right {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
/* Each file display unit — target <figure> directly inside <aside> */
aside figure {
overflow: hidden;
margin: 0;
border-radius: 8px;
}
aside figure img {
width: 100%;
height: auto;
display: block;
border-radius: 8px;
}
aside figure embed,
aside figure video {
width: 100%;
display: block;
border: none;
}
aside figure video {
max-height: 500px;
}
aside figure embed {
height: clamp(300px, 80vh, 700px);
}
/* figcaption under media — target <figcaption> inside <aside> */
aside figcaption {
font-size: var(--step--2);
color: var(--text-secondary);
margin: var(--space-3xs) 0 0;
font-style: italic;
}
/* PDF fallback download link */
.tfe-pdf-fallback {
font-size: var(--step--1);
margin: var(--space-3xs) 0 0;
}
.tfe-pdf-fallback a {
color: var(--text-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
/* "Not available" and "no files" notices */
.tfe-restricted,
.tfe-no-files {
font-size: var(--step--1);
color: var(--text-secondary);
font-style: italic;
padding: var(--space-s) 0;
margin: 0;
}
/* Responsive */
@media (max-width: 900px) {
.tfe-layout {
grid-template-columns: 1fr;
gap: var(--space-l);
}
.tfe-title {
font-size: var(--step-2);
}
}
@media (max-width: 600px) {
.tfe-main {
padding: var(--space-m) var(--space-s) var(--space-l);
}
.tfe-title {
font-size: var(--step-1);
}
}

View File

@@ -0,0 +1,121 @@
/* ============================================================
CSS VARIABLES (CUSTOM PROPERTIES)
============================================================ */
/* @link https://utopia.fyi/type/calculator?c=360,18,1.2,1240,20,1.25,5,2,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12 */
:root {
--step--2: clamp(0.7813rem, 0.7736rem + 0.0341vw, 0.8rem);
--step--1: clamp(0.9375rem, 0.9119rem + 0.1136vw, 1rem);
--step-0: clamp(1.125rem, 1.0739rem + 0.2273vw, 1.25rem);
--step-1: clamp(1.35rem, 1.2631rem + 0.3864vw, 1.5625rem);
--step-2: clamp(1.62rem, 1.4837rem + 0.6057vw, 1.9531rem);
--step-3: clamp(1.944rem, 1.7405rem + 0.9044vw, 2.4414rem);
--step-4: clamp(2.3328rem, 2.0387rem + 1.3072vw, 3.0518rem);
--step-5: clamp(2.7994rem, 2.384rem + 1.8461vw, 3.8147rem);
}
/* @link https://utopia.fyi/space/calculator?c=360,18,1.2,1240,20,1.25,5,2,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12 */
:root {
--space-3xs: clamp(0.3125rem, 0.3125rem + 0vw, 0.3125rem);
--space-2xs: clamp(0.5625rem, 0.5369rem + 0.1136vw, 0.625rem);
--space-xs: clamp(0.875rem, 0.8494rem + 0.1136vw, 0.9375rem);
--space-s: clamp(1.125rem, 1.0739rem + 0.2273vw, 1.25rem);
--space-m: clamp(1.6875rem, 1.6108rem + 0.3409vw, 1.875rem);
--space-l: clamp(2.25rem, 2.1477rem + 0.4545vw, 2.5rem);
--space-xl: clamp(3.375rem, 3.2216rem + 0.6818vw, 3.75rem);
--space-2xl: clamp(4.5rem, 4.2955rem + 0.9091vw, 5rem);
--space-3xl: clamp(6.75rem, 6.4432rem + 1.3636vw, 7.5rem);
/* One-up pairs */
--space-3xs-2xs: clamp(0.3125rem, 0.1847rem + 0.5682vw, 0.625rem);
--space-2xs-xs: clamp(0.5625rem, 0.4091rem + 0.6818vw, 0.9375rem);
--space-xs-s: clamp(0.875rem, 0.7216rem + 0.6818vw, 1.25rem);
--space-s-m: clamp(1.125rem, 0.8182rem + 1.3636vw, 1.875rem);
--space-m-l: clamp(1.6875rem, 1.3551rem + 1.4773vw, 2.5rem);
--space-l-xl: clamp(2.25rem, 1.6364rem + 2.7273vw, 3.75rem);
--space-xl-2xl: clamp(3.375rem, 2.7102rem + 2.9545vw, 5rem);
--space-2xl-3xl: clamp(4.5rem, 3.2727rem + 5.4545vw, 7.5rem);
/* Custom pairs */
--space-s-l: clamp(1.125rem, 0.5625rem + 2.5vw, 2.5rem);
}
:root {
/* Fonts */
--font-body: "BBBDMSans", sans-serif;
--font-display: "Ductus", sans-serif;
/* Backgrounds */
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #e8e8e8;
--bg-active: #d0d0d0;
/* Text */
--text-primary: #111111;
--text-secondary: #666666;
--text-tertiary: #999999;
/* Borders */
--border-primary: #dddddd;
--border-secondary: #cccccc;
/* Status */
--success: #5cd69d;
--error: #f25a5a;
--warning: #fbca51;
/* Accent */
--accent-primary: #9557b5;
--accent-secondary: #683d7f;
--accent-foreground: #ffffff;
--accent-muted: rgba(149, 87, 181, 0.12);
--accent-blue: #41adff;
--accent-green: #4caf50;
--accent-yellow: #f39c12;
--accent-red: #f25a5a;
/* Gradient (header) */
--gradient-1: #3c856c;
--gradient-2: #60ecb4;
--gradient-3: #e390ff;
--gradient-4: #9557b5;
/* Header decorative */
--header-gradient-fade: rgba(149, 87, 181, 0);
--header-shadow-strong: rgba(119, 70, 145, 1);
--header-shadow-soft: rgba(119, 70, 145, 0.8);
--header-nav-active-border: rgba(255, 255, 255, 0.6);
/* Search error block */
--search-error-bg: #fff0f0;
--search-error-border: #cc0000;
--search-error-color: #cc0000;
/* System page — log/config syntax highlight */
--sys-syntax-comment: #999999;
--sys-syntax-directive: #1a6fb5;
--sys-syntax-block: #7a2fa0;
--sys-syntax-value: #a05c00;
--sys-syntax-location: #1a7a6b;
--sys-syntax-notice: #3a6ea8;
--sys-syntax-crit: #c0392b;
/* Muted alpha overlays — derived from semantic tokens */
--success-muted-bg: rgba(92, 214, 157, 0.12);
--success-muted-border: rgba(92, 214, 157, 0.35);
--warning-muted-bg: rgba(251, 202, 81, 0.12);
--warning-muted-border: rgba(251, 202, 81, 0.35);
--error-muted-bg: rgba(242, 90, 90, 0.12);
--error-muted-border: rgba(242, 90, 90, 0.35);
--blue-muted-bg: rgba(65, 173, 255, 0.12);
--blue-muted-border: rgba(65, 173, 255, 0.30);
--blue-muted-bg-hover: rgba(65, 173, 255, 0.22);
--yellow-muted-bg: rgba(243, 156, 18, 0.12);
--yellow-muted-border: rgba(243, 156, 18, 0.30);
--yellow-muted-bg-hover: rgba(243, 156, 18, 0.22);
--green-muted-bg: rgba(76, 175, 80, 0.12);
--green-muted-border: rgba(76, 175, 80, 0.30);
--green-muted-bg-hover: rgba(76, 175, 80, 0.22);
--danger-border-muted: rgba(242, 90, 90, 0.35);
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 283.5 283.5">
<!-- Generator: Adobe Illustrator 30.0.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 123) -->
<defs>
<style>
.st0 {
fill: #009992;
}
.st1 {
fill: #bb59ff;
}
</style>
</defs>
<g>
<path class="st1" d="M86.6,135.9c-1.5,0-2.8-.3-4-1-1.3-.7-2.4-1.7-3-3l-23.5-39.1c-.3.8-.7,1.5-1.3,2.1l-30.3,38s0,0-.1.1c-1.3,1.5-3.1,2.4-5.2,2.7-.5,0-.9,0-1.3,0-1.9,0-3.5-.6-4.9-1.9-1.7-1.5-2.7-3.4-2.8-5.4-.2-2.3.4-4.3,1.8-5.9l29.9-37.4c0-.2.2-.3.3-.5,1.3-1.9,3.3-2.9,5.6-3.1.2,0,.5,0,.7,0,.3,0,.7,0,1,0L11.1,17.9c-1.1-1.9-1.4-3.8-.9-6.1.5-2.2,1.8-4,3.8-5.1,1.2-.7,2.5-1,3.9-1s1.3,0,2,.2c2,.5,3.8,1.7,5,3.6l23.6,39.3c.3-.7.7-1.4,1.3-2.1l30.3-38c1.5-1.8,3.4-2.8,5.5-3,.3,0,.5,0,.8,0,1.9,0,3.6.6,5.1,1.7,1.8,1.4,2.8,3.3,3,5.5.2,2.9-.9,4.7-1.9,5.8l-29.7,37.2c0,.2-.2.5-.4.7-1.3,1.9-3.3,2.9-5.6,3.1-.2,0-.5,0-.7,0s-.7,0-1,0l38.4,63.9c1.1,1.9,1.4,3.9.9,6-.5,2-1.7,3.7-3.5,4.9-1.2.8-2.6,1.2-4.2,1.2Z"/>
<path class="st0" d="M17.8,8.7c.4,0,.9,0,1.3.2,1.3.3,2.3,1.1,3.1,2.2l68.5,114.1c.7,1.2.8,2.4.5,3.8-.3,1.3-1.1,2.3-2.2,3.1-.7.5-1.5.7-2.4.7s-1.8-.2-2.5-.6c-.8-.4-1.4-1-1.7-1.7L13.7,16.3c-.7-1.2-.8-2.4-.5-3.9.3-1.4,1.1-2.4,2.2-3.1.8-.5,1.6-.7,2.4-.7M86.3,8.8c1.2,0,2.3.3,3.2,1,1,.8,1.7,1.9,1.8,3.4.1,1.4-.2,2.7-1.1,3.6l-30.4,38h.1c-.8,1.2-1.9,1.8-3.4,1.9-.2,0-.3,0-.5,0-1.2,0-2.3-.3-3.2-1-1-.8-1.6-1.9-1.8-3.4-.2-1.4.1-2.7,1-3.7l30.4-38c.9-1.1,2-1.7,3.4-1.8.2,0,.3,0,.5,0M48.5,84.8c1.2,0,2.3.3,3.2,1.1,1,.9,1.6,2,1.8,3.4.2,1.4-.1,2.7-1,3.7l-30.4,38c-.9,1-2,1.5-3.4,1.7-.3,0-.6,0-.8,0-1.1,0-2-.3-2.9-1.1-1-.9-1.7-2-1.8-3.4-.1-1.4.2-2.7,1.1-3.7l30.4-38h-.1c.8-1.1,1.9-1.7,3.4-1.8.2,0,.3,0,.5,0M17.8,2.6h0c-1.9,0-3.7.5-5.4,1.5-2.7,1.5-4.6,4-5.2,7.1-.6,3-.2,5.8,1.2,8.2,0,0,0,0,0,0l36,59.8c-1.9.7-3.6,2-4.8,3.7-.1.2-.3.4-.4.6l-29.7,37.1c-1.3,1.6-2.8,4.2-2.5,8,.2,2.8,1.5,5.4,3.8,7.5,1.9,1.7,4.3,2.7,6.9,2.7s1.1,0,1.7-.1c0,0,0,0,0,0,2.8-.4,5.2-1.7,7-3.7,0,0,.2-.2.2-.3l29.1-36.4,21,35c.9,1.8,2.4,3.3,4.2,4.2,1.6.9,3.5,1.3,5.4,1.3s4.2-.6,5.9-1.8c2.5-1.6,4.2-4,4.8-6.8.7-2.9.3-5.7-1.2-8.2,0,0,0,0,0,0l-36-59.9c1.9-.7,3.6-2,4.8-3.7.2-.3.4-.6.5-.9l29.4-36.8c1.4-1.5,2.9-4.1,2.6-8-.2-3-1.7-5.8-4.1-7.7-1.9-1.6-4.4-2.4-7-2.4s-.6,0-1,0c-3,.2-5.7,1.6-7.7,4.1,0,0,0,0,0,0l-29,36.3L27.5,8s0,0,0-.1c-1.6-2.5-4-4.3-6.8-4.9-.9-.2-1.8-.3-2.7-.3h0Z"/>
</g>
<g>
<path class="st1" d="M105.1,199.1c-.5,0-.9,0-1.4-.1,0,0-.1,0-.2,0-2.2-.5-3.9-1.7-5-3.7-1-1.8-1.4-3.7-1.1-5.8,0,0,0-.2,0-.3l26.6-114.1s0,0,0-.1c.6-2.1,1.8-3.7,3.6-4.9,0,0,0,0,.1,0,1.3-.8,2.7-1.2,4.1-1.2s1.2,0,1.9.2c2,.5,3.8,1.7,5,3.6,0,0,0,0,0,.1.3.5.5,1,.7,1.4.2-.6.5-1.1.8-1.6,1.2-1.9,2.9-3.1,5-3.6.6-.1,1.2-.2,1.9-.2,1.4,0,2.8.4,4.1,1.2,1.8,1,3.1,2.7,3.7,4.9,0,0,0,.1,0,.2l26.6,114.1c.3,1.4.3,2.7,0,4-.4,1.5-1.2,2.7-2,3.5-1.2,1.4-2.8,1.9-3.8,2.2,0,0,0,0,0,0-.6.1-1.2.2-1.7.2-1.1,0-2.1-.2-3.2-.7-.4,0-.7-.2-1-.4-1.9-1.1-3.2-2.9-3.7-5.1l-7.7-32.6c-1.1.5-2.3.8-3.6.8h-30.4c-1.3,0-2.5-.3-3.6-.8l-7.7,32.6c-.5,2.2-1.7,4-3.7,5.1-1.4.8-2.8,1.2-4.4,1.2ZM154.7,144.7l-15.2-64.9-15.2,64.9h30.4Z"/>
<path class="st0" d="M147.1,71.7c.9,0,1.7.2,2.6.7,1.2.7,1.9,1.8,2.3,3.2l26.6,114.1c.2.9.2,1.7,0,2.5-.2.8-.7,1.5-1.3,2.2-.6.7-1.4,1-2.2,1.2-.4,0-.7.1-1.1.1-1,0-1.9-.3-2.8-.8v.2c-1.1-.7-1.8-1.8-2.1-3.2l-9.2-39.1c0,1.3-.5,2.5-1.5,3.5-1,1-2.1,1.4-3.6,1.4h-30.4c-1.4,0-2.5-.5-3.6-1.4-1-.9-1.4-2.1-1.5-3.5l-9.2,39.1c-.3,1.4-1,2.4-2.2,3.2-.9.5-1.9.8-2.8.8s-.6,0-.9,0c-1.3-.3-2.3-1-3.1-2.2-.7-1.2-.9-2.4-.7-3.9l26.6-114.1c.4-1.4,1.2-2.4,2.3-3.2.9-.5,1.7-.7,2.6-.7s.8,0,1.2.1c1.3.3,2.3,1.1,3.1,2.2.7,1.2.9,2.4.7,3.7l-17,72.6c0,0,0,.2,0,.2v-.2c0,0,0,0,.1,0,.4-.8,1-1.4,1.8-1.8.8-.4,1.6-.7,2.5-.7h30.4c1.4,0,2.5.5,3.6,1.4,1,.9.7.8.9,1.2l-17-72.7c-.3-1.3,0-2.5.7-3.7.7-1.1,1.7-1.9,3.1-2.2.4,0,.8-.1,1.2-.1M147.1,65.6c-.8,0-1.7,0-2.5.3-1.9.4-3.7,1.4-5.1,2.8-1.4-1.3-3.1-2.2-5-2.7-.8-.2-1.7-.3-2.5-.3-2,0-3.9.5-5.7,1.6,0,0-.1,0-.2.1-2.4,1.6-4.1,3.8-4.9,6.6,0,0,0,.2,0,.3l-26.6,114.1c0,.2,0,.3,0,.5-.4,2.8,0,5.4,1.5,7.8,1.6,2.7,4,4.4,7,5.1.1,0,.3,0,.4,0,.6,0,1.3.1,1.9.1,2.1,0,4.1-.5,5.9-1.6,2.7-1.6,4.5-4,5.1-7.1l6.9-29.5c.5,0,.9,0,1.4,0h30.4c.5,0,.9,0,1.4,0l6.9,29.5c.7,3,2.5,5.5,5.1,7,.5.3,1.1.5,1.7.7,1.3.5,2.7.8,4.1.8s1.6,0,2.4-.3c0,0,.1,0,.2,0,.8-.2,3.4-.9,5.4-3.2.9-1.1,2.1-2.6,2.6-4.7.4-1.8.4-3.5,0-5.4,0,0,0,0,0,0l-26.6-114.1c0-.1,0-.3-.1-.4-1.1-3.7-3.4-5.7-5.1-6.6-1.8-1.1-3.7-1.6-5.7-1.6h0ZM128.2,141.7l11.3-48.4,11.3,48.4h-22.7Z"/>
</g>
<g>
<path class="st1" d="M266.3,279.3c-1.1,0-2.5,0-3.9-.7-1.1-.6-2.1-1.3-3-2.4-1.2-1.4-1.5-3-1.6-4.2l-11-111.3s-7.2,28.8-7.3,28.8c-.8,2.7-2.6,4.7-5.2,5.6,0,0,0,0-.1,0-.8.2-1.7.4-2.5.4s-1.3,0-1.9-.2c-1.3-.3-2.5-1-3.6-1.9-1.2-1-2-2.4-2.3-4.1l-7.3-28.9-11.1,111.4c0,2.2-1.1,4.1-2.9,5.6-1.5,1.2-3.3,1.8-5.2,1.8s-.4,0-.6,0c-2.2-.2-4.1-1.1-5.5-2.8-1.4-1.6-2.1-3.6-1.9-5.8l11.5-114.1c0-2.2,1.1-4.1,2.9-5.6,1.5-1.3,3.3-1.9,5.2-1.9s.3,0,.5,0c2.2,0,4.2,1.1,5.6,2.9.6.8,1.1,1.6,1.4,2.5.2-.5.4-1,.7-1.6,1.2-2.1,3.1-3.4,5.2-3.7.5,0,1-.1,1.4-.1,1.4,0,2.8.3,4.2,1,0,0,.1,0,.2,0,1.6,1,2.8,2.3,3.4,4,.8-2.2,2.3-3.5,3.5-4.1,1.3-.7,2.7-1.1,4.1-1.1s1.2,0,1.9.2c2.2.5,3.9,1.8,5,3.7.3.4.5.9.7,1.4.3-.9.8-1.7,1.5-2.5,1.5-1.6,3.3-2.6,5.4-2.7,0,0,.2,0,.2,0,1,0,2.1,0,3.4.5.2,0,.4.1.6.2,1.4.7,2.4,1.5,3.2,2.6.9,1.2,1.4,2.6,1.4,4.2l11.5,114c0,.1,0,.2,0,.3,0,1.5-.2,3.6-2,5.6-1.4,1.6-3.1,2.5-5.2,2.8-.2,0-.3,0-.5,0Z"/>
<path class="st0" d="M239.2,152.1c.4,0,.8,0,1.2.1,1.3.3,2.3,1,3.1,2.2.7,1.2.9,2.4.7,3.8l-7.6,30.4c-.5,1.7-1.5,3-3.3,3.6-.6.2-1.1.2-1.6.2s-.8,0-1.2-.1c-.8-.2-1.6-.6-2.3-1.2-.7-.6-1.1-1.4-1.3-2.4l-7.6-30.4c-.3-1.3,0-2.5.7-3.8.7-1.2,1.7-2,3.1-2.2.3,0,.7,0,1,0,1,0,1.9.2,2.8.7,1.2.7,1.9,1.7,2.2,3.1l2.7,10.7v.2c0,0,0,0,0,0v-.2c0,0,2.7-10.7,2.7-10.7.3-1.4,1-2.4,2.2-3.1.9-.5,1.7-.7,2.6-.7M209,152.1c.1,0,.3,0,.4,0,1.4,0,2.5.7,3.4,1.7.8,1,1.2,2.3,1,3.7l-11.3,114.1c0,1.4-.7,2.5-1.7,3.4-.9.7-2.1,1.1-3.3,1.1s-.3,0-.4,0c-1.4-.1-2.5-.7-3.4-1.7-.9-1-1.3-2.2-1.2-3.6l11.5-114.1c0-1.4.7-2.5,1.7-3.4,1-.8,2.1-1.2,3.3-1.2M253.9,152.1c.9,0,1.7,0,2.5.4h.1c.8.4,1.5.9,2,1.6.5.7.8,1.5.8,2.5l11.5,114.1c0,1.4-.3,2.5-1.2,3.6-.9,1-2,1.6-3.4,1.8-.9,0-1.7,0-2.5-.4-.8-.4-1.4-.9-2-1.6-.6-.7-.8-1.5-.9-2.5l-11.3-114.1c-.2-1.4.1-2.7,1-3.7.9-1,2-1.6,3.4-1.7M239.2,145.9h0c-1.9,0-3.8.5-5.5,1.5-.7.3-1.4.8-2.1,1.4-.6-.5-1.2-.9-1.8-1.3-.1,0-.2-.1-.3-.2-1.8-.9-3.6-1.3-5.5-1.3s-1.3,0-1.9.1c-2.1.3-4,1.3-5.5,2.7-1.9-1.8-4.3-2.9-6.9-2.9-.2,0-.4,0-.6,0-2.6,0-5,.9-7.1,2.6-2.5,2-3.9,4.7-4,7.7l-11.5,113.9s0,0,0,.1c-.2,2.9.7,5.8,2.7,8,1.9,2.3,4.6,3.7,7.6,3.9.3,0,.5,0,.8,0,2.6,0,5.1-.9,7.1-2.5,2.5-2,3.9-4.7,4-7.8l9.3-93.9,3,11.9c.5,2.3,1.6,4.3,3.3,5.7,1.4,1.2,3.1,2.1,4.8,2.5.9.2,1.8.3,2.6.3s2.2-.2,3.4-.5c0,0,.2,0,.3,0,3.5-1.2,6-3.9,7.1-7.6,0,0,0-.2,0-.2l2.9-11.7,9.3,93.8c.2,1.5.6,3.8,2.4,5.9,1.2,1.4,2.4,2.4,3.9,3.1,2.1,1.1,4,1.1,5.3,1.1s.6,0,.9,0c2.7-.4,5.1-1.7,7-3.8,1.9-2.1,2.8-4.6,2.8-7.7s0-.4,0-.6l-11.5-113.9c0-2.1-.7-4-1.9-5.7-1.1-1.5-2.5-2.6-4.3-3.5-.3-.2-.7-.3-1-.4-1.7-.7-3.3-.7-4.4-.7s-.3,0-.5,0c-2.5.2-4.7,1.2-6.6,2.9-1.4-1.3-3.1-2.2-5-2.6-.8-.2-1.7-.3-2.5-.3h0Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,10 @@
<!-- Favicon and icon links - add to your <head> section -->
<link rel="apple-touch-icon" sizes="152x152" href="/favicon/apple-touch-icon-152x152.png">
<link rel="apple-touch-icon" sizes="167x167" href="/favicon/apple-touch-icon-167x167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon-180x180.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png">
<link rel="manifest" href="/favicon/site.webmanifest">
<link rel="shortcut icon" href="/favicon/favicon.ico">
<meta name="msapplication-TileColor" content="\#ffffff">
<meta name="theme-color" content="\#ffffff">

View File

@@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
app/public/assets/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1005
app/public/assets/js/overtype.min.js vendored Normal file

File diff suppressed because one or more lines are too long

70
app/public/index.php Normal file
View File

@@ -0,0 +1,70 @@
<?php
// Load configuration
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Controllers/HomeController.php';
$controller = HomeController::create();
$vars = $controller->handle();
extract($vars);
?>
<?php include APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<?php if ($year): ?>
<p class="filter-info" role="status">
Année : <?= htmlspecialchars($year) ?>
<a href="index.php" class="clear-filter"><span aria-hidden="true">✕</span> Réinitialiser</a>
</p>
<?php elseif ($isDefaultView): ?>
<p class="home-section-label" role="status">Publication récente</p>
<?php endif; ?>
<main class="home-main" id="main-content">
<h1 class="sr-only">Mémoires de l'ERG</h1>
<ul class="cards-container">
<?php foreach ($itemsToLoad as $item): ?>
<li class="card">
<a href="tfe.php?id=<?= (int)$item["id"] ?>">
<?php
// Resolve thumbnail: banner_path → cover file → gradient placeholder
$thumb = null;
// 1. Banner path (dedicated home thumbnail)
if (!empty($item['banner_path'])) {
$thumb = $item['banner_path'];
}
// 2. Cover image from thesis_files (batch-loaded above)
if (!$thumb && isset($coverMap[$item['id']])) {
$thumb = $coverMap[$item['id']];
}
// 3. Fall through to gradient
?>
<?php if ($thumb): ?>
<figure>
<img src="/media.php?path=<?= urlencode($thumb) ?>"
alt="Couverture — <?= htmlspecialchars($item['title']) ?> par <?= htmlspecialchars($item['authors'] ?? '') ?>"
loading="lazy">
</figure>
<?php else: ?>
<div class="card__media--gradient"
aria-hidden="true">
<span class="card__gradient-author"><?= htmlspecialchars($item['authors'] ?? '') ?></span>
<span class="card__gradient-title"><?= htmlspecialchars($item['title']) ?></span>
</div>
<?php endif; ?>
<p><?= htmlspecialchars($item["authors"] ?? '') ?><?php if (!empty($item['authors']) && !empty($item['title'])): ?> <?php endif; ?><?= htmlspecialchars($item["title"]) ?><?php if (!empty($item['year'])): ?><span class="sr-only">, <?= (int)$item['year'] ?></span><?php endif; ?></p>
</a>
</li>
<?php endforeach; ?>
<?php if (empty($itemsToLoad)): ?>
<li class="cards-empty">Aucun mémoire trouvé.</li>
<?php endif; ?>
</ul>
<?php include APP_ROOT . '/templates/partials/pagination.php'; ?>
</main>
<?php include APP_ROOT . '/templates/footer.php'; ?>

48
app/public/licence.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php';
$currentNav = 'licence';
try {
$db = Database::getInstance();
$dbPage = $db->getPage('licenses');
$content = $dbPage ? $dbPage['content'] : '';
$licencePageTitle = $dbPage ? $dbPage['title'] : 'Licences';
} catch (Exception $e) {
error_log("Error loading licence page: " . $e->getMessage());
$content = '';
$licencePageTitle = 'Licences';
}
$pd = new Parsedown();
$pd->setSafeMode(true);
$html = $pd->text($content);
$pageTitle = $licencePageTitle . ' Posterg';
$metaDescription = 'Informations sur les licences d\'utilisation des mémoires publiés sur Posterg, le répertoire des TFE de l\'erg.';
$ogTags = [
'type' => 'website',
'title' => $pageTitle,
'description' => $metaDescription,
'url' => 'https://posterg.erg.be/licence.php',
'site_name' => 'Posterg ERG',
];
$extraCss = ['/assets/css/apropos.css'];
$bodyClass = 'apropos-body';
?>
<?php include APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main class="apropos-main" id="main-content">
<div class="prose apropos-single">
<?php if (!empty(trim($content))): ?>
<?= $html ?>
<?php else: ?>
<p>Contenu à venir.</p>
<?php endif; ?>
</div>
</main>
<?php include APP_ROOT . '/templates/footer.php'; ?>

View File

@@ -0,0 +1,55 @@
<?php
/**
* Simple live-reload endpoint for development.
* Polls file mtimes across all source directories and returns
* whether anything changed since last check.
*
* Only active when served by PHP's built-in CLI server.
*/
if (php_sapi_name() !== 'cli-server') {
http_response_code(403);
exit;
}
header('Content-Type: application/json');
$root = dirname(__DIR__);
$watchDirs = [
$root . '/public',
$root . '/src',
$root . '/config',
];
$watchExts = ['php', 'css', 'js', 'html'];
$hash = '';
foreach ($watchDirs as $dir) {
if (!is_dir($dir)) continue;
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($it as $file) {
if (in_array($file->getExtension(), $watchExts)) {
$hash .= $file->getMTime() . '|' . $file->getPathname() . "\n";
}
}
}
$fingerprint = md5($hash);
$stateFile = sys_get_temp_dir() . '/posterg-live-reload.txt';
$prev = file_exists($stateFile) ? file_get_contents($stateFile) : null;
// First visit: write baseline, don't fire a reload
if ($prev === null) {
file_put_contents($stateFile, $fingerprint);
$changed = false;
} else {
$changed = $fingerprint !== $prev;
if ($changed) {
file_put_contents($stateFile, $fingerprint);
}
}
echo json_encode(['changed' => $changed]);

View File

@@ -0,0 +1,59 @@
<?php
// This page is served directly by nginx / bootstrap when maintenance mode is active.
// It is also served by the public gate in bootstrap.php.
http_response_code(503);
header('Retry-After: 3600');
?><!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Maintenance Posterg</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d0d0d;
color: #e0e0e0;
font-family: 'Helvetica Neue', Arial, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.box {
max-width: 520px;
text-align: center;
}
.box__logo {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: #fff;
margin-bottom: 2.5rem;
}
.box__title {
font-size: 1.6rem;
font-weight: 300;
letter-spacing: .04em;
margin-bottom: 1rem;
}
.box__text {
font-size: .95rem;
color: #999;
line-height: 1.7;
}
</style>
</head>
<body>
<div class="box">
<div class="box__logo">POSTERG</div>
<h1 class="box__title">Maintenance en cours</h1>
<p class="box__text">
Le site est temporairement indisponible pour des raisons de maintenance.<br>
Merci de réessayer dans quelques instants.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,28 @@
# Route all partage requests to index.php
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</IfModule>
# Security headers
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
</IfModule>
# Prevent directory listing
Options -Indexes
# Protect dotfiles
<FilesMatch "^\.">
Require all denied
</FilesMatch>

View File

@@ -0,0 +1,590 @@
<?php
/**
* Partage — Entry point for shared student submission forms.
*
* Routes:
* /partage/<slug> — Render the share-link form (or password gate)
* /partage/<slug>/submit — POST endpoint for form submissions via share link
* /partage/thanks.php?id=N — Post-submission confirmation page
*/
require_once __DIR__ . '/../../bootstrap.php';
// Parse the requested path from REQUEST_URI
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
// Remove query string
$basePath = parse_url($requestUri, PHP_URL_PATH);
// Extract the part after /partage/
$path = trim(str_replace('/partage/', '', $basePath), '/');
// Split into parts: /partage/<slug>/<action>
$parts = explode('/', $path);
$slug = $parts[0] ?? '';
$action = $parts[1] ?? '';
// Validate slug format: YYYYMMDD-XXXXXXXX (17 chars)
if (!preg_match('#^\d{8}-[A-Z0-9+/]{8}$#', $slug)) {
App::boot();
$_SESSION['_flash_error'] = 'Ce lien de partage n\'est pas valide.';
header('Location: /');
exit;
}
// Boot for all requests: starts session, initialises DB, ensures CSRF token.
App::boot();
// ── POST: form submission ─────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'submit') {
handleShareLinkSubmission($slug);
exit;
}
// ── GET: render form ─────────────────────────────────────────────────────────
require_once APP_ROOT . '/src/ShareLink.php';
$shareLinkModel = new ShareLink(Database::getInstance());
$validationResult = $shareLinkModel->validateLink($slug);
if (!$validationResult['valid']) {
$reason = $validationResult['reason'];
if ($reason === 'not_found') {
$_SESSION['_flash_error'] = 'Ce lien de partage n\'existe pas ou a été supprimé.';
header('Location: /');
exit;
}
if ($reason === 'disabled') {
renderShareLinkError('Lien désactivé', 'Ce lien de partage a été désactivé par un administrateur.');
exit;
}
if ($reason === 'expired') {
renderShareLinkError('Lien expiré', 'Ce lien de partage a expiré.');
exit;
}
if ($reason === 'needs_password') {
// Show password gate
$link = $validationResult['link'];
requirePasswordGate($link, $slug);
exit;
}
$_SESSION['_flash_error'] = 'Erreur inattendue.';
header('Location: /');
exit;
}
// Link is valid — render the form
$link = $validationResult['link'];
renderShareLinkForm($slug, $link);
// ── Functions ─────────────────────────────────────────────────────────────────
/**
* Render a styled error page for invalid/expired/disabled share links.
*/
function renderShareLinkError(string $title, string $message): void
{
$pageTitle = htmlspecialchars($title);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $pageTitle ?></title>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/system.css') ?>">
<style>
.share-error {
max-width: 500px;
margin: 5rem auto;
padding: 2.5rem;
text-align: center;
background: #fff;
border: 2px solid #d00;
border-radius: 8px;
}
.share-error h1 {
color: #d00;
font-size: 1.5rem;
margin-bottom: 1rem;
}
.share-error p {
font-size: 1.1rem;
color: #444;
margin-bottom: 1.5rem;
}
.share-error a {
display: inline-block;
padding: 0.6rem 1.5rem;
background: #333;
color: white;
text-decoration: none;
border-radius: 4px;
}
.share-error a:hover {
background: #555;
}
</style>
</head>
<body>
<div class="share-error">
<h1><?= $pageTitle ?></h1>
<p><?= htmlspecialchars($message) ?></p>
<a href="/">← Retour à l'accueil</a>
</div>
</body>
</html>
<?php
}
function requirePasswordGate(array $link, string $slug): void
{
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['share_password'])) {
require_once APP_ROOT . '/src/ShareLink.php';
$shareLinkModel = new ShareLink(Database::getInstance());
if ($shareLinkModel->verifyPassword($link, $_POST['share_password'])) {
// Store verified status in session
$_SESSION['share_verified_' . $slug] = true;
// Redirect to clear POST data
header('Location: /partage/' . $slug);
exit;
} else {
$_SESSION['_flash_error'] = 'Mot de passe incorrect.';
header('Location: /partage/' . $slug);
exit;
}
}
$pageTitle = 'Accès protégé';
// Consume flash errors from wrong-password redirects
$flashError = $_SESSION['_flash_error'] ?? null;
unset($_SESSION['_flash_error']);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/system.css') ?>">
<style>
.password-gate {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
text-align: center;
}
.password-gate h1 { margin-bottom: 1.5rem; }
.password-gate input[type="password"] {
width: 100%;
padding: 0.5rem;
margin: 0.5rem 0 1rem;
font-size: 1rem;
}
.password-gate button {
padding: 0.75rem 2rem;
font-size: 1rem;
cursor: pointer;
}
.password-error {
color: red;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="password-gate">
<h1>🔒 Accès protégé</h1>
<?php if ($flashError): ?>
<p class="password-error"><?= htmlspecialchars($flashError) ?></p>
<?php endif; ?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
<label for="share_password">Ce lien est protégé par un mot de passe :</label>
<input type="password" id="share_password" name="share_password" required autofocus>
<br>
<button type="submit">Accéder au formulaire</button>
</form>
</div>
</body>
</html>
<?php
}
function renderShareLinkForm(string $slug, array $link): void
{
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
try {
$ctrl = ThesisCreateController::make();
extract($ctrl->loadFormData());
} catch (Exception $e) {
error_log('Failed to load form data: ' . $e->getMessage());
die('Erreur lors du chargement du formulaire.');
}
$formData = $_SESSION['form_data_share_' . $slug] ?? [];
unset($_SESSION['form_data_share_' . $slug]);
// Generate a CSRF token specific to this share link (stored in session)
$shareCsrfKey = 'share_csrf_' . $slug;
if (empty($_SESSION[$shareCsrfKey])) {
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32));
}
$shareCsrfToken = $_SESSION[$shareCsrfKey];
$pageTitle = 'Soumettre un TFE';
// Determine if previously verified by password
$isVerified = !empty($_SESSION['share_verified_' . $slug]);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/system.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/admin.css') ?>">
<style>
.student-body {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.licence-explanation {
background: #f9f9f9;
border-left: 4px solid #666;
padding: 1.5rem;
margin: 2rem 0;
}
.licence-info h3 { margin-top: 1.5rem; }
.licence-degree { margin: 1.5rem 0; padding: 1rem; background: white; border: 1px solid #ddd; border-radius: 4px; }
.licence-note { color: #666; font-size: 0.9rem; margin-top: 0.5rem; }
.licence-generalites { margin-top: 2rem; }
.form-footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 2px solid #333;
}
.share-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #e0f0ff;
border: 1px solid #99c;
border-radius: 3px;
font-size: 0.85rem;
margin-bottom: 1rem;
}
h1 { margin-bottom: 0.5rem; }
.mode-toggle { font-size: 0.9rem; }
</style>
</head>
<body class="admin-body student-body">
<main id="main-content">
<div class="thesis-add-header">
<h1>Soumettre un TFE</h1>
<?php if ($isVerified): ?>
<span class="share-badge">🔓 Accès partagé</span>
<?php endif; ?>
</div>
<?php
// Show flash messages from error redirect
$flashError = $_SESSION['_flash_error'] ?? null;
$flashSuccess = $_SESSION['_flash_success'] ?? null;
unset($_SESSION['_flash_error'], $_SESSION['_flash_success']);
?>
<?php if ($flashError): ?>
<div class="flash-error" role="alert"><?= htmlspecialchars($flashError) ?></div>
<?php endif; ?>
<?php if ($flashSuccess): ?>
<div class="flash-success" role="alert"><?= htmlspecialchars($flashSuccess) ?></div>
<?php endif; ?>
<form action="/partage/<?= urlencode($slug) ?>/submit" method="post" enctype="multipart/form-data" class="admin-form">
<input type="hidden" name="share_link_token" value="<?= htmlspecialchars($shareCsrfToken) ?>">
<!-- ═══════════════════ Informations du TFE ═══════════════════ -->
<fieldset>
<legend>Informations du TFE</legend>
<?php $name = 'titre'; $label = 'Titre :'; $value = old($formData, 'titre'); $required = true; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'subtitle'; $label = 'Sous-titre (si applicable) :'; $value = old($formData, 'subtitle'); $required = false; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'auteurice'; $label = 'Auteur·ice(s) :'; $value = old($formData, 'auteurice'); $required = true; $attrs = ['autocomplete' => 'name']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'mail'; $label = 'Contact(s) (optionnel) [mail/site/insta/etc.] :'; $value = old($formData, 'mail'); $attrs = ['autocomplete' => 'email']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<div class="admin-form-group">
<label class="admin-checkbox-label">
<input type="checkbox" name="contact_public" value="1"
<?= isset($formData['contact_public']) ? 'checked' : '' ?>>
Je veux que mon contact soit accessible à toustes depuis la plateforme xamxam
</label>
<small>Si cette case est cochée, votre contact apparaîtra sur la page publique de votre TFE.</small>
</div>
<div>
<label for="synopsis">Synopsis :</label>
<textarea id="synopsis" name="synopsis" rows="7" required><?= old($formData, 'synopsis') ?></textarea>
</div>
</fieldset>
<!-- ═══════════════════ Composition du jury ═══════════════════ -->
<fieldset>
<legend>Composition du jury</legend>
<?php $name = 'jury_president'; $label = 'Président·e du jury :'; $value = old($formData, 'jury_president'); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'jury_promoteur'; $label = 'Promoteur·ice :'; $value = old($formData, 'jury_promoteur'); $hintCheckbox = ['name' => 'jury_promoteur_ext', 'label' => 'Externe à l\'erg', 'checked' => !empty($formData['jury_promoteur_ext'])]; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<div class="jury-additional">
<p>Lecteurs·rices (optionnel) :</p>
<?php for ($i = 0; $i < 4; $i++): ?>
<div class="jury-additional-row">
<input type="text"
name="jury_lecteurs[]"
placeholder="<?= $i === 0 ? 'Nom du lecteur·ice' : ''; ?>"
value="<?= old($formData, "jury_lecteurs:$i") ?>">
<label class="admin-checkbox-label">
<input type="checkbox" name="jury_lecteurs_ext[<?= $i ?>]" value="1"
<?= !empty($formData["jury_lecteurs_ext:$i"]) ? 'checked' : '' ?>>
Externe
</label>
</div>
<?php endfor; ?>
</div>
</fieldset>
<!-- ═══════════════════ Cadre académique ═══════════════════ -->
<fieldset>
<legend>Cadre académique</legend>
<?php
$name = 'année'; $label = 'Année :'; $value = old($formData, 'année'); $required = true;
$type = 'number';
$placeholder = date('Y');
$attrs = ['min' => 2000, 'max' => date('Y') + 1];
include APP_ROOT . '/templates/partials/form/text-field.php';
?>
<?php $name = 'orientation'; $label = 'Orientation :'; $options = $orientations; $selected = isset($formData['orientation']) ? $formData['orientation'] : ''; $required = true; $placeholder = ''; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'ap'; $label = 'Atelier pluridisciplinaire :'; $options = $apPrograms; $selected = isset($formData['ap']) ? $formData['ap'] : ''; $required = true; $placeholder = ''; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'finality'; $label = 'Finalité du master :'; $options = $finalityTypes; $selected = isset($formData['finality']) ? $formData['finality'] : ''; $required = true; $placeholder = ''; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'languages'; $label = 'Langue(s) :'; $options = $languages; $checked = $formData['languages'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'formats'; $label = 'Format(s) :'; $options = $formatTypes; $checked = $formData['formats'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'tag'; $label = 'Mots-clés :'; $value = old($formData, 'tag'); $placeholder = 'sociologie, anthropologie, ...'; $hint = 'Séparez par des virgules. Max 10 mots-clés.'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
</fieldset>
<!-- ═══════════════════ Fichiers ═══════════════════ -->
<fieldset>
<legend>Fichiers</legend>
<?php $name = 'couverture'; $label = 'Image de couverture :'; $accept = 'image/jpeg,image/png'; $hint = 'JPG, PNG. Taille max : 10 MB.'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
<?php $name = 'banner'; $label = 'Image bannière (accueil) :'; $accept = 'image/jpeg,image/png,image/webp'; $hint = 'JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB.'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
<?php $name = 'files'; $label = 'Fichiers du TFE :'; $accept = '.pdf,.jpg,.jpeg,.png,.mp4,.zip,.vtt'; $hint = 'PDF, JPG, PNG, MP4, ZIP. Max 50 MB par fichier. Pour les vidéos, un fichier .vtt de sous-titres peut être joint (il sera associé automatiquement à la vidéo correspondante).'; $multiple = true; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
</fieldset>
<!-- ═══════════════════ Métadonnées complémentaires ═══════════════════ -->
<fieldset>
<legend>Métadonnées complémentaires</legend>
<?php $name = 'license_id'; $label = 'Licence :'; $options = $licenseTypes; $selected = isset($formData['license_id']) ? $formData['license_id'] : ''; $placeholder = '— Inconnue —'; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'duration_info'; $label = 'Durée / Taille :'; $value = old($formData, 'duration_info'); $placeholder = 'Ex : 84 pages'; $hint = 'Durée (minutes) ou nombre de pages.'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'lien'; $label = 'Lien (site / ressource) :'; $value = old($formData, 'lien'); $type = 'url'; $placeholder = 'https://...'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php
$accessOptions = array_map(function($at) {
return ['id' => $at['id'], 'name' => $at['name']];
}, $enabledAccessTypes);
$defaultAccessType = 2;
$selectedAccessType = isset($formData['access_type_id'])
? (int)$formData['access_type_id']
: $defaultAccessType;
$name = 'access_type_id';
$label = 'Visibilité / Accès :';
$options = $accessOptions;
$selected = $selectedAccessType;
$placeholder = null;
$required = true;
$attrs = [];
include APP_ROOT . '/templates/partials/form/select-field.php';
?>
</fieldset>
<!-- ═══════════════════ Degrés d'ouverture ═══════════════════ -->
<fieldset class="licence-explanation">
<legend>Degrés d'ouverture et licences</legend>
<div class="licence-info">
<h3>Je veux que mon TFE soit disponible sous les conditions suivantes :</h3>
<div class="licence-degree">
<h4>🔓 Libre</h4>
<p>Mon TFE est en libre accès à tout le monde sur la plateforme des TFE ainsi que dans la bibliothèque de l'erg. Je suis conscient·e des responsabilités et obligations légales qui viennent avec une diffusion externe et acquiesce avoir lu la documentation prévue à cet effet par l'erg, ainsi qu'avoir discuté des enjeux d'une publication avec l'équipe pédagogique. J'accepte de partager mes droits de diffusion avec l'erg, ce uniquement dans le cadre d'une diffusion sur la plateforme xamxam.</p>
<ul>
<li><label><input type="checkbox" name="cc4r" value="1"> J'accepte les conditions collectives de réutilisation (CC4r) <em class="hint">(pas obligatoire)</em></label></li>
<li><label><input type="checkbox" name="specific_license" value="1"> Je souhaite appliquer une licence spécifique à mon travail <em class="hint">(pas obligatoire)</em></label></li>
</ul>
<p class="licence-note"><em>Au moins une des deux cases doit être cochée pour le degré Libre.</em></p>
</div>
<div class="licence-degree">
<h4>🔒 Interne</h4>
<p>Mon TFE et ma note d'intention ne sont accessibles que sur place en physique ainsi que sur la plateforme xamxam par la communauté erg. Une note descriptive est disponible sur le site à toustes. J'autorise une (ré-)utilisation et diffusion dans un contexte académique et didactique au sein de l'erg.</p>
<p class="licence-note"><em>La diffusion limitée est protégée par le cadre académique/didactique, le travail pourrait donc être diffusé en interne et être cité par d'autres étudiant·es sans implications légales pour l'auteur·ice ni pour l'école.</em></p>
<ul>
<li><label><input type="checkbox" name="cc4r" value="1"> J'accepte les conditions collectives de réutilisation (CC4r) <em class="hint">(pas obligatoire)</em></label></li>
<li><label><input type="checkbox" name="specific_license" value="1"> Je souhaite appliquer une licence spécifique à mon travail <em class="hint">(pas obligatoire)</em></label></li>
</ul>
<p class="licence-note"><em>Au moins une des deux cases doit être cochée.</em></p>
</div>
<div class="licence-degree">
<h4>🚫 Interdit</h4>
<p>Mon TFE n'est pas disponible en physique ni sur le site. Une note descriptive est disponible sur le site.</p>
</div>
</div>
<div class="licence-generalites">
<h3>Généralités</h3>
<ul>
<li>L'auteur·ice peut décider entre trois degrés de partage de son travail : <strong>libre</strong>, <strong>interne</strong>, <strong>interdit</strong>.</li>
<li>L'auteur·ice peut, à tout moment, décider de <strong>restreindre</strong> le degré d'accès à son travail. Il ne peut néanmoins pas l'ouvrir davantage.</li>
<li>Le choix effectué dans ce formulaire sera d'application <strong>une semaine après la soutenance orale</strong> de l'auteur·ice. Celui-ci peut donc décider de restreindre ce choix avant sa publication (mais pas l'ouvrir).</li>
<li>L'erg se réserve le droit de restreindre le degré d'ouverture du TFE ce en accord avec le règlement.</li>
<li>Dans tous les cas, l'auteur·ice garde les droits d'auteurs, de diffusion, d'utilisation, etc. de son travail sauf si la licence choisie restreindrait ses droits.</li>
<li>La diffusion « xamxam » est indépendante de la diffusion à la BAIU.</li>
</ul>
</div>
</fieldset>
<div class="form-footer">
<button type="submit" name="go">Soumettre</button>
</div>
</form>
</main>
</body>
</html>
<?php
}
/**
* Handle share link form submission.
*/
function handleShareLinkSubmission(string $slug): void
{
// Session already started by App::boot() on the GET path; start here
// only if somehow not yet active (e.g. direct POST without prior GET).
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once APP_ROOT . '/src/ShareLink.php';
require_once APP_ROOT . '/src/RateLimit.php';
$shareLinkModel = new ShareLink(Database::getInstance());
$link = $shareLinkModel->findBySlug($slug);
if ($link === null || !$link['is_active'] || ($link['expires_at'] !== null && strtotime($link['expires_at']) < time())) {
$_SESSION['_flash_error'] = 'Ce lien n\'est plus valide.';
header('Location: /partage/' . urlencode($slug));
exit;
}
// ── Rate limiting ────────────────────────────────────────────────────────
// 5 submissions per IP per 10 minutes, keyed per share link.
$rateLimitCacheDir = STORAGE_ROOT . '/cache/rate_limit';
$shareRateLimitId = 'share_' . $slug . '_' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$rateLimit = new RateLimit(5, 600, $rateLimitCacheDir);
if (!$rateLimit->checkKey($shareRateLimitId)) {
$_SESSION['_flash_error'] = 'Trop de tentatives. Veuillez réessayer plus tard.';
header('Location: /partage/' . urlencode($slug));
exit;
}
// ── End rate limiting ────────────────────────────────────────────────────
// Check password verification if link has a password
if ($link['password_hash'] !== null && empty($_SESSION['share_verified_' . $slug])) {
if (isset($_POST['share_password_submit'])) {
if ($shareLinkModel->verifyPassword($link, $_POST['share_password_submit'])) {
$_SESSION['share_verified_' . $slug] = true;
} else {
$_SESSION['_flash_error'] = 'Mot de passe incorrect.';
header('Location: /partage/' . urlencode($slug));
exit;
}
} else {
$_SESSION['_flash_error'] = 'Vous devez entrer le mot de passe avant de soumettre le formulaire.';
header('Location: /partage/' . urlencode($slug));
exit;
}
}
// Validate share-link CSRF token
$shareCsrfKey = 'share_csrf_' . $slug;
if (!isset($_POST['share_link_token'], $_SESSION[$shareCsrfKey])
|| !hash_equals($_SESSION[$shareCsrfKey], $_POST['share_link_token'])) {
error_log('Share link CSRF validation failed for ' . $slug);
$_SESSION['_flash_error'] = 'Token de sécurité invalide.';
header('Location: /partage/' . urlencode($slug));
exit;
}
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
try {
$ctrl = ThesisCreateController::make();
$thesisId = $ctrl->submit($_POST, $_FILES);
// Mark the link as used
$shareLinkModel = new ShareLink(Database::getInstance());
$shareLinkModel->incrementUsage($link['id']);
// Clean up share-specific session data
unset($_SESSION[$shareCsrfKey]);
unset($_SESSION['share_verified_' . $slug]);
// Redirect to thanks page
header('Location: /partage/thanks.php?id=' . urlencode((string)$thesisId));
exit();
} catch (Exception $e) {
error_log('Share link submission error: ' . $e->getMessage());
$_SESSION['_flash_error'] = $e->getMessage();
$_SESSION['form_data_share_' . $slug] = $_POST;
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token
// Redirect back to the form
header('Location: /partage/' . urlencode($slug));
exit();
}
}
/**
* Helper to retrieve old form data (with support for array keys via : delimiter)
*/
function old(array $data, string $key, string $default = ''): string {
// Support nested keys like "jury_lecteurs:0"
$parts = explode(':', $key);
$value = $data;
foreach ($parts as $part) {
if (is_array($value) && isset($value[$part])) {
$value = $value[$part];
} else {
$value = $default;
break;
}
}
return is_array($value) ? htmlspecialchars(json_encode($value)) : htmlspecialchars((string)$value);
}

View File

@@ -0,0 +1,87 @@
<?php
/**
* Thanks page for share-link submissions.
* Displays a centered confirmation with a link to create another thesis via the same link.
*/
require_once __DIR__ . '/../../bootstrap.php';
App::boot();
$thesisId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($thesisId <= 0) {
http_response_code(400);
die('ID de TFE invalide.');
}
// Verify the thesis exists
$db = Database::getInstance();
$thesis = $db->getThesis($thesisId);
if (!$thesis) {
http_response_code(404);
die('TFE introuvable.');
}
// Get the share link slug from the referer path
$pathParts = explode('/', trim($_SERVER['REQUEST_URI'] ?? '', '/'));
$slug = count($pathParts) >= 2 ? $pathParts[0] : null;
$pageTitle = 'Merci — TFE enregistré';
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/system.css') ?>">
<style>
.thanks-center {
max-width: 600px;
margin: 4rem auto;
padding: 2rem;
text-align: center;
}
.thanks-center h1 {
margin-bottom: 0.5rem;
font-size: 2rem;
}
.thanks-center p {
font-size: 1.1rem;
color: #555;
margin: 1rem 0 2rem;
}
.thanks-center .thesis-title {
font-weight: bold;
font-size: 1.2rem;
color: #333;
margin-bottom: 2rem;
}
.thanks-center .btn-add-another {
display: inline-block;
padding: 0.75rem 2rem;
background: #333;
color: white;
text-decoration: none;
border-radius: 4px;
font-size: 1rem;
transition: background 0.2s;
}
.thanks-center .btn-add-another:hover {
background: #555;
}
</style>
</head>
<body>
<div class="thanks-center">
<h1>✅ Merci !</h1>
<p>Votre TFE a bien été enregistré sur la plateforme.</p>
<?php if ($thesis): ?>
<div class="thesis-title"><?= htmlspecialchars($thesis['title']) ?> — <?= htmlspecialchars($thesis['authors'] ?? '') ?></div>
<?php endif; ?>
<?php if ($slug): ?>
<a href="/partage/<?= urlencode($slug) ?>" class="btn-add-another">+ Soumettre un autre TFE</a>
<?php endif; ?>
</div>
</body>
</html>

21
app/public/repertoire.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Controllers/SearchController.php';
// Build controller (performs rate-limit check; exits with HTTP 429 if exceeded)
$ctrl = SearchController::create();
// Collect all view variables for the répertoire index page
extract($ctrl->handleRepertoire());
?>
<?php include APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main class="search-main" id="main-content">
<h1 class="sr-only">Répertoire</h1>
<span id="rep-indicator" class="rep-indicator htmx-indicator" aria-hidden="true"></span>
<?php include APP_ROOT . '/templates/partials/repertoire-index.php'; ?>
</main>
<script src="/assets/js/htmx.min.js"></script>
<?php include APP_ROOT . '/templates/footer.php'; ?>

82
app/public/search.php Normal file
View File

@@ -0,0 +1,82 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Controllers/SearchController.php';
// Build controller (performs rate-limit check; exits with HTTP 429 if exceeded)
$ctrl = SearchController::create();
// Collect all view variables for the search results page
extract($ctrl->handleSearch());
?>
<?php include APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<?php if ($validationError): ?>
<div class="search-error">⚠ <?= htmlspecialchars($validationError) ?></div>
<?php endif; ?>
<!-- Filter controls -->
<form class="search-controls" method="GET" action="search.php">
<input type="hidden" name="query" value="<?= htmlspecialchars($_GET['query'] ?? '') ?>">
<label class="search-filter-label" for="filter-year">Année
<select class="search-filter-select" name="year" id="filter-year">
<option value="">Toutes</option>
<?php foreach ($years as $y): ?>
<option value="<?= (int)$y ?>" <?= (isset($_GET['year']) && $_GET['year'] == $y) ? 'selected' : '' ?>>
<?= (int)$y ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-orientation">Orientation
<select class="search-filter-select" name="orientation" id="filter-orientation">
<option value="">Toutes</option>
<?php foreach ($orientations as $o): ?>
<option value="<?= htmlspecialchars($o['name']) ?>"
<?= (isset($_GET['orientation']) && $_GET['orientation'] == $o['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($o['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-ap">AP
<select class="search-filter-select" name="ap_program" id="filter-ap">
<option value="">Tous</option>
<?php foreach ($apPrograms as $ap): ?>
<option value="<?= htmlspecialchars($ap['name']) ?>"
<?= (isset($_GET['ap_program']) && $_GET['ap_program'] == $ap['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($ap['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<button type="submit" class="search-apply-btn">Filtrer</button>
<a href="search.php?query=<?= urlencode($_GET['query'] ?? '') ?>" class="search-reset-link">Réinitialiser</a>
</form>
<main class="search-main" id="main-content">
<output class="search-results-header" role="status"><?= $totalItems ?> résultat<?= $totalItems > 1 ? 's' : '' ?></output>
<?php if (!empty($results)): ?>
<ul class="results-grid">
<?php foreach ($results as $item): ?>
<li><a href="tfe.php?id=<?= (int)$item['id'] ?>" class="result-card">
<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>
</a></li>
<?php endforeach; ?>
</ul>
<?php include APP_ROOT . '/templates/partials/pagination.php'; ?>
<?php else: ?>
<p class="search-empty">Aucun résultat pour cette recherche.</p>
<?php endif; ?>
</main>
<?php include APP_ROOT . '/templates/footer.php'; ?>

263
app/public/tfe.php Normal file
View File

@@ -0,0 +1,263 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Controllers/TfeController.php';
// Build controller (loads thesis, enforces visibility, builds OG tags; redirects on 404)
$ctrl = TfeController::create();
// Collect all view variables
extract($ctrl->handle());
?>
<?php include APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<main class="tfe-main" id="main-content">
<article class="tfe-layout">
<!-- LEFT: info — article header -->
<header class="tfe-left">
<!-- Author above title -->
<p class="tfe-author"><?= htmlspecialchars($data['authors'] ?? 'Auteur inconnu') ?></p>
<h1 class="tfe-title">
<?= htmlspecialchars($data['title']) ?>
<?php if (!empty($data['subtitle'])): ?>
<?= htmlspecialchars($data['subtitle']) ?>
<?php endif; ?>
</h1>
<dl>
<?php if (!empty($data['orientation'])): ?>
<div>
<dt>Orientation :</dt>
<dd><a href="/repertoire.php?or[]=<?= urlencode($data['orientation']) ?>"><?= htmlspecialchars($data['orientation']) ?></a></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['ap_program'])): ?>
<div>
<dt>Atelier pluridisciplinaire :</dt>
<dd><a href="/repertoire.php?ap[]=<?= urlencode($data['ap_program']) ?>"><?= htmlspecialchars($data['ap_program']) ?></a></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['year'])): ?>
<div>
<dt>Date :</dt>
<dd><a href="/repertoire.php?fy[]=<?= urlencode($data['year']) ?>"><?= htmlspecialchars($data['year']) ?></a></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['languages'])): ?>
<div>
<dt>Langue :</dt>
<dd><?php
$langs = array_map('trim', explode(',', $data['languages']));
$langLinks = array_map(fn($l) => '<a href="/search.php?query=' . urlencode($l) . '">' . htmlspecialchars($l) . '</a>', $langs);
echo implode(', ', $langLinks);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['formats'])): ?>
<div>
<dt>Format :</dt>
<dd><?php
$fmts = array_map('trim', explode(',', $data['formats']));
$fmtLinks = array_map(fn($f) => '<a href="/search.php?query=' . urlencode($f) . '">' . htmlspecialchars($f) . '</a>', $fmts);
echo implode(', ', $fmtLinks);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['file_size_info'])): ?>
<div>
<dt>Durée :</dt>
<dd><?= htmlspecialchars($data['file_size_info']) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['keywords'])): ?>
<div>
<dt>Mots-clés :</dt>
<dd><?php
$kws = array_map('trim', explode(',', $data['keywords']));
$kwLinks = array_map(fn($k) => '<a href="/repertoire.php?kw[]=' . urlencode($k) . '">' . htmlspecialchars($k) . '</a>', $kws);
echo implode(', ', $kwLinks);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($promoteursInternes)): ?>
<div>
<dt>Promoteur·ice interne :</dt>
<dd><?php
$links = array_map(fn($n) => '<a href="/search.php?query=' . urlencode($n) . '">' . htmlspecialchars($n) . '</a>', $promoteursInternes);
echo implode(', ', $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($promoteursExternes)): ?>
<div>
<dt>Promoteur·ice externe :</dt>
<dd><?php
$links = array_map(fn($n) => '<a href="/search.php?query=' . urlencode($n) . '">' . htmlspecialchars($n) . '</a>', $promoteursExternes);
echo implode(', ', $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($juryPresidents)): ?>
<div>
<dt>Président·e du jury :</dt>
<dd><?php
$links = array_map(fn($n) => '<a href="/search.php?query=' . urlencode($n) . '">' . htmlspecialchars($n) . '</a>', $juryPresidents);
echo implode(', ', $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($juryLecteurs)): ?>
<div>
<dt>Lecteur·ices :</dt>
<dd><?php
$links = array_map(fn($n) => '<a href="/search.php?query=' . urlencode($n) . '">' . htmlspecialchars($n) . '</a>', $juryLecteurs);
echo implode(', ', $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['access_type'])): ?>
<div>
<dt>Accès :</dt>
<dd><?= htmlspecialchars($data['access_type']) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['license_type'])): ?>
<div>
<dt>Licence :</dt>
<dd><?= htmlspecialchars($data['license_type']) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['context_note'])): ?>
<div class="tfe-meta-note">
<dt>Note :</dt>
<dd class="tfe-note-value"><?= nl2br(htmlspecialchars($data['context_note'])) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data['author_email']) && !empty($data['author_show_contact'])): ?>
<div>
<dt>Contact :</dt>
<dd>
<?php
$_contact = $data['author_email'];
$_isUrl = filter_var($_contact, FILTER_VALIDATE_URL) !== false;
$_isEmail = !$_isUrl && str_contains($_contact, '@');
if ($_isUrl):
?>
<a href="<?= htmlspecialchars($_contact) ?>" target="_blank" rel="noopener">
<?= htmlspecialchars(preg_replace('#^https?://#i', '', rtrim($_contact, '/'))) ?>
<span class="sr-only">(ouvre dans un nouvel onglet)</span>
</a>
<?php elseif ($_isEmail): ?>
<a href="mailto:<?= htmlspecialchars($_contact) ?>"><?= htmlspecialchars($_contact) ?></a>
<?php else: ?>
<?= htmlspecialchars($_contact) ?>
<?php endif; ?>
</dd>
</div>
<?php endif; ?>
<?php if (!empty($data['baiu_link'])): ?>
<?php
$_baiuHref = htmlspecialchars($data['baiu_link']);
$_baiuLabel = preg_replace('#^https?://#i', '', rtrim($data['baiu_link'], '/'));
?>
<div>
<dt>Lien :</dt>
<dd>
<a href="<?= $_baiuHref ?>" target="_blank" rel="noopener">
<?= htmlspecialchars($_baiuLabel) ?>
<span class="sr-only">(ouvre dans un nouvel onglet)</span>
</a>
</dd>
</div>
<?php endif; ?>
</dl>
<?php if (!empty($data['synopsis'])): ?>
<p class="tfe-synopsis-text">
<?= nl2br(htmlspecialchars($data['synopsis'])) ?>
</p>
<?php endif; ?>
</header>
<!-- RIGHT: media — supplementary aside -->
<aside class="tfe-right">
<?php
// $isInterdit and $captionFiles are resolved by TfeController::handle()
$_videoIndex = 0;
?>
<?php if ($isInterdit): ?>
<p class="tfe-restricted">
Ce TFE n'est pas disponible en ligne.
</p>
<?php elseif (!empty($data['files'])): ?>
<?php foreach ($data['files'] as $file): ?>
<?php
$ext = strtolower(pathinfo($file['file_path'], PATHINFO_EXTENSION));
// VTT caption files are consumed inline by <video>; skip standalone rendering.
if ($ext === 'vtt') continue;
?>
<figure>
<?php if ($ext === 'pdf'): ?>
<embed src="/media.php?path=<?= urlencode($file['file_path']) ?>"
type="application/pdf" width="100%" height="700px">
<p class="tfe-pdf-fallback">
<a href="/media.php?path=<?= urlencode($file['file_path']) ?>&download=1">
Télécharger le PDF
</a>
</p>
<?php elseif (in_array($ext, ['jpg','jpeg','png','gif','bmp','webp'])): ?>
<img src="/media.php?path=<?= urlencode($file['file_path']) ?>"
alt="<?= htmlspecialchars(
!empty($file['description'])
? $file['description']
: ($data['title'] . ' — ' . ($data['authors'] ?? ''))
) ?>">
<?php elseif ($ext === 'mp4'): ?>
<?php
// Pair this video with the N-th VTT file (if one was uploaded).
$_vttPath = $captionFiles[$_videoIndex] ?? null;
$_videoIndex++;
?>
<video width="100%" controls>
<source src="/media.php?path=<?= urlencode($file['file_path']) ?>" type="video/mp4">
<?php if ($_vttPath): ?>
<track kind="captions"
src="/media.php?path=<?= urlencode($_vttPath) ?>"
srclang="fr"
label="Sous-titres"
default>
<?php endif; ?>
</video>
<?php endif; ?>
<?php if (!empty($file['description'])): ?>
<figcaption><?= htmlspecialchars($file['description']) ?></figcaption>
<?php endif; ?>
</figure>
<?php endforeach; ?>
<?php else: ?>
<p class="tfe-no-files">Aucun fichier disponible pour ce TFE.</p>
<?php endif; ?>
</aside>
</article>
</main>
<?php include APP_ROOT . '/templates/footer.php'; ?>