feat: licence page, admin pages editor, license types, gradient card placeholders, latest-year home view

- Feature 1: public /licence.php fetches 'licenses' page from DB, renders Markdown
- Feature 1: nav.php adds 'Licence' link with active state
- Feature 2: Database::getPage(), savePage(), getAllPages() methods
- Feature 2: bundled src/Parsedown.php (MIT, zero-dependency)
- Feature 2: apropos.php now renders 'about' page content from DB via Parsedown
- Feature 2: admin/pages.php (list) + admin/pages-edit.php (EasyMDE editor)
- Feature 2: admin/actions/page.php (auth+CSRF+validation+save)
- Feature 2: admin/head.php adds 'Pages statiques' nav link
- Feature 3: storage/schema.sql seeds 8 CC license types
- Feature 3: storage/migrations/003_seed_license_types.sql (applied to live DB)
- Feature 3: Database::getLicenseTypes() / getAllLicenseTypes()
- Feature 3: admin/add.php + formulaire.php: license_id field on add form
- Feature 3: admin/edit.php: license_id field on edit form with raw FK lookup
- Feature 3: tfe.php: shows 'Licence :' meta row when non-null
- Feature 6: main.css: .card__media--gradient styles
- Feature 6: index.php: deterministic HSL gradient placeholder cards
- Feature 6: Database::getLatestYearTheses() + getLatestPublishedYear()
- Feature 6: index.php default home = random latest-year theses with info label
This commit is contained in:
Pontoporeia
2026-03-24 13:12:48 +01:00
parent 86a2082edc
commit d87348c388
20 changed files with 2553 additions and 152 deletions

View File

@@ -105,6 +105,9 @@ try {
// Formats (optional, multiple selection)
$formatIds = isset($_POST["formats"]) ? array_map('intval', $_POST["formats"]) : [];
// License
$licenseId = filter_var($_POST['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
// External link
$lien = $_POST["lien"] ?? '';
if (!empty($lien)) {
@@ -135,9 +138,9 @@ try {
identifier, title, subtitle, year,
orientation_id, ap_program_id, finality_id,
synopsis, file_size_info,
baiu_link,
baiu_link, license_id,
submitted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
");
$stmt->execute([
@@ -150,7 +153,8 @@ try {
$finalityId,
$synopsis,
!empty($durationInfo) ? $durationInfo : null,
!empty($lien) ? $lien : null
!empty($lien) ? $lien : null,
$licenseId
]);
$thesisId = $pdo->lastInsertId();

View File

@@ -0,0 +1,35 @@
<?php
require_once __DIR__ . "/../../config/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.");
}
$allowedSlugs = ['about', 'licenses', 'charte', 'contact'];
$slug = $_POST['slug'] ?? '';
if (!in_array($slug, $allowedSlugs)) {
die("Slug invalide.");
}
$content = $_POST['content'] ?? '';
if (strlen($content) > 65535) {
die("Contenu trop long (max 65 535 caractères).");
}
require_once __DIR__ . '/../../src/Database.php';
try {
$db = new Database();
$db->savePage($slug, $content);
$_SESSION['success'] = "Page «" . $slug . "» mise à jour avec succès.";
} catch (Exception $e) {
error_log("Page save error: " . $e->getMessage());
die("Erreur lors de la sauvegarde : " . htmlspecialchars($e->getMessage()));
}
header('Location: /admin/pages.php');
exit;

View File

@@ -18,6 +18,7 @@ try {
$finalityTypes = $db->getAllFinalityTypes();
$languages = $db->getAllLanguages();
$formatTypes = $db->getAllFormatTypes();
$licenseTypes = $db->getAllLicenseTypes();
} catch (Exception $e) {
error_log("Failed to load form data: " . $e->getMessage());
die("Erreur lors du chargement du formulaire.");
@@ -191,6 +192,20 @@ function wasSelected($key, $value) {
rows="7" required><?= old('synopsis') ?></textarea>
</div>
<!-- Licence -->
<div class="admin-form-row">
<label class="admin-label" for="license_id">Licence :</label>
<select class="admin-select" id="license_id" name="license_id">
<option value="">— Inconnue —</option>
<?php foreach ($licenseTypes as $lt): ?>
<option value="<?= htmlspecialchars($lt['id']) ?>"
<?= wasSelected('license_id', $lt['id']) ? 'selected' : '' ?>>
<?= htmlspecialchars($lt['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<!-- Durée/Taille -->
<div class="admin-form-row">
<label class="admin-label" for="duration_info">Durée / Taille :</label>

View File

@@ -36,6 +36,8 @@ try {
$db->beginTransaction();
// Update thesis basic info
$editLicenseId = filter_var($_POST['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
$stmt = $pdo->prepare("
UPDATE theses SET
title = ?,
@@ -47,6 +49,7 @@ try {
synopsis = ?,
file_size_info = ?,
baiu_link = ?,
license_id = ?,
is_published = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
@@ -62,6 +65,7 @@ try {
trim($_POST['synopsis']),
!empty($_POST['duration_info']) ? trim($_POST['duration_info']) : null,
!empty($_POST['lien']) ? trim($_POST['lien']) : null,
$editLicenseId,
isset($_POST['is_published']) ? 1 : 0,
$thesisId
]);
@@ -164,7 +168,13 @@ try {
$finalityTypes = $db->getAllFinalityTypes();
$languages = $db->getAllLanguages();
$formatTypes = $db->getAllFormatTypes();
$licenseTypes = $db->getAllLicenseTypes();
// Fetch raw license_id FK (view only exposes license_type name string)
$licenseStmt = $pdo->prepare("SELECT license_id FROM theses WHERE id = ?");
$licenseStmt->execute([$thesisId]);
$currentLicenseId = $licenseStmt->fetchColumn();
// Set page title for header
$pageTitle = "Éditer TFE - " . htmlspecialchars($thesis['title']);
@@ -247,6 +257,19 @@ try {
value="<?= htmlspecialchars($thesis['supervisors'] ?? '') ?>">
</div>
<div class="admin-form-row">
<label class="admin-label" for="license_id">Licence :</label>
<select class="admin-select" id="license_id" name="license_id">
<option value="">— Inconnue —</option>
<?php foreach ($licenseTypes as $lt): ?>
<option value="<?= $lt['id'] ?>"
<?= ($currentLicenseId == $lt['id']) ? 'selected' : '' ?>>
<?= htmlspecialchars($lt['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="admin-form-row">
<label class="admin-label" for="titre">Titre :</label>
<input class="admin-input" type="text" id="titre" name="titre"

View File

@@ -0,0 +1,67 @@
<?php
require_once __DIR__ . "/../../config/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';
$allowedSlugs = ['about', 'licenses', 'charte', 'contact'];
$slug = $_GET['slug'] ?? '';
if (!in_array($slug, $allowedSlugs)) {
header('Location: /admin/pages.php');
exit;
}
try {
$db = new Database();
$page = $db->getPage($slug);
if (!$page) {
die("Page introuvable.");
}
} catch (Exception $e) {
die("Erreur: " . htmlspecialchars($e->getMessage()));
}
$pageTitle = "Éditer : " . htmlspecialchars($page['title']);
?>
<?php require_once APP_ROOT . '/templates/admin/head.php'; ?>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<main class="admin-main">
<h1 class="admin-page-title">Éditer : <?= htmlspecialchars($page['title']) ?></h1>
<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($slug) ?>">
<div class="admin-form-row" style="align-items:start;">
<label class="admin-label" for="content">Contenu (Markdown) :</label>
<textarea class="admin-textarea" id="content" name="content"
rows="20"><?= htmlspecialchars($page['content'] ?? '') ?></textarea>
</div>
<div class="admin-submit-wrap">
<button type="submit" class="admin-btn">Enregistrer</button>
<a href="/admin/pages.php" class="admin-btn-secondary" style="margin-left:.75rem;">Annuler</a>
</div>
</form>
</main>
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script>
var easyMDE = new EasyMDE({
element: document.getElementById('content'),
spellChecker: false,
status: ['lines', 'words'],
minHeight: '400px',
toolbarTips: true
});
</script>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

55
public/admin/pages.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once __DIR__ . '/../../src/Database.php';
$pageTitle = "Pages statiques";
try {
$db = new Database();
$pages = $db->getAllPages();
} catch (Exception $e) {
error_log("Error loading pages: " . $e->getMessage());
die("Erreur lors du chargement des pages.");
}
$success = $_SESSION['success'] ?? null;
unset($_SESSION['success']);
?>
<?php require_once APP_ROOT . '/templates/admin/head.php'; ?>
<main class="admin-main">
<h1 class="admin-page-title">Pages statiques</h1>
<?php if ($success): ?>
<div class="admin-alert admin-alert--success">✓ <?= htmlspecialchars($success) ?></div>
<?php endif; ?>
<table class="admin-table">
<thead>
<tr>
<th>Slug</th>
<th>Titre</th>
<th>Mis à jour</th>
<th>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/pages-edit.php?slug=<?= urlencode($p['slug']) ?>"
class="admin-btn" style="font-size:.8rem;padding:.3rem .75rem;">Éditer</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -1,6 +1,29 @@
<?php
require_once __DIR__ . '/../config/bootstrap.php';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php';
$currentNav = 'apropos';
// Fallback static content used when DB content is the placeholder
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 anciens é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.");
try {
$db = Database::getInstance();
$aboutPage = $db->getPage('about');
$rawContent = $aboutPage ? $aboutPage['content'] : '';
// Use static fallback if content is placeholder
if (empty(trim($rawContent)) || trim($rawContent) === 'Contenu à venir') {
$rawContent = APROPOS_STATIC_CONTENT;
}
} catch (Exception $e) {
error_log("Error loading about page: " . $e->getMessage());
$rawContent = APROPOS_STATIC_CONTENT;
}
$pd = new Parsedown();
$pd->setSafeMode(true);
$aboutHtml = $pd->text($rawContent);
?>
<!DOCTYPE html>
<html lang="fr">
@@ -30,16 +53,10 @@ $currentNav = 'apropos';
<main class="apropos-main">
<div class="apropos-layout">
<!-- LEFT: main text -->
<!-- LEFT: main text (from DB, Markdown-rendered) -->
<div class="apropos-left">
<div class="apropos-description">
<p>Ce site POSTERG a été créé pour répertorier et valoriser les mémoires de l'erg École de Recherches Graphique de Bruxelles.</p>
<p>L'objectif est à la fois d'offrir une vitrine aux projets des anciens é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.</p>
</div>
<div class="apropos-licences">
<h2>Licences</h2>
<p>Les contenus de ce site sont publiés avec l'accord des auteur·ices et de l'erg. La reproduction des œuvres est soumise à l'autorisation de leurs auteur·ices respectif·ives.</p>
<div class="apropos-description apropos-page-content">
<?= $aboutHtml ?>
</div>
</div>

View File

@@ -93,6 +93,42 @@ html, body {
line-height: 1.6;
}
/* Markdown-rendered page content */
.apropos-page-content p {
margin: 0 0 1.2em 0;
}
.apropos-page-content h1,
.apropos-page-content h2,
.apropos-page-content h3 {
font-family: "Courier New", Courier, monospace;
font-weight: 400;
margin: 1.5em 0 0.5em 0;
}
.apropos-page-content h1 { font-size: 1.55rem; }
.apropos-page-content h2 { font-size: 1.3rem; }
.apropos-page-content h3 { font-size: 1.1rem; }
.apropos-page-content a {
color: var(--purple);
text-underline-offset: 2px;
}
.apropos-page-content ul,
.apropos-page-content ol {
padding-left: 1.5rem;
margin-bottom: 1rem;
}
.apropos-page-content li {
margin-bottom: .3em;
}
.apropos-page-content strong { font-weight: 700; }
.apropos-page-content em { font-style: italic; }
.apropos-page-content code {
font-family: "Courier New", Courier, monospace;
font-size: 0.9em;
background: rgba(0,0,0,.06);
padding: .1em .3em;
border-radius: 2px;
}
/* Licences section */
.apropos-licences {
margin-top: 2rem;

View File

@@ -91,6 +91,41 @@ html, body {
font-size: 2rem;
}
.card__media--gradient {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem;
text-align: center;
box-sizing: border-box;
}
.card__gradient-author {
color: #fff;
font-size: 0.75rem;
opacity: .85;
margin-bottom: .25rem;
display: block;
}
.card__gradient-title {
color: #fff;
font-size: 0.85rem;
font-weight: 600;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Latest-year label */
.home-latest-label {
font-style: italic;
}
.card__info {
padding: 0.55rem 0.5rem 0.65rem;
font-size: 0.88rem;

View File

@@ -3,9 +3,12 @@
require_once __DIR__ . '/../config/bootstrap.php';
require_once APP_ROOT . '/src/Database.php';
$page = isset($_GET["page"]) ? intval($_GET["page"]) : 1;
$page = isset($_GET["page"]) ? max(1, intval($_GET["page"])) : 1;
$year = isset($_GET["year"]) ? intval($_GET["year"]) : null;
$itemsPerPage = 24; // bigger grid
$itemsPerPage = 24;
// Default home view: random theses from latest year (no year filter, no explicit page)
$isDefaultView = (!$year && $page === 1);
try {
$db = Database::getInstance();
@@ -14,19 +17,25 @@ try {
if ($year) {
$itemsToLoad = $db->searchTheses(['year' => $year], $itemsPerPage, $offset);
$totalItems = $db->countSearchResults(['year' => $year]);
$totalItems = $db->countSearchResults(['year' => $year]);
} elseif ($isDefaultView) {
$latestYear = $db->getLatestPublishedYear();
$itemsToLoad = $db->getLatestYearTheses($itemsPerPage);
$totalItems = count($itemsToLoad); // no pagination on default view
} else {
$itemsToLoad = $db->getPublishedTheses($itemsPerPage, $offset);
$totalItems = $db->countPublishedTheses();
$totalItems = $db->countPublishedTheses();
}
$totalPages = ceil($totalItems / $itemsPerPage);
$totalPages = $isDefaultView ? 1 : (int)ceil($totalItems / $itemsPerPage);
} catch (Exception $e) {
error_log("Error loading theses: " . $e->getMessage());
$itemsToLoad = [];
$totalPages = 0;
$totalPages = 0;
$availableYears = [];
$totalItems = 0;
$totalItems = 0;
$latestYear = null;
$isDefaultView = false;
}
$currentNav = '';
@@ -61,6 +70,10 @@ $currentNav = '';
Année : <?= htmlspecialchars($year) ?>
<a href="index.php" class="clear-filter">✕ Réinitialiser</a>
</div>
<?php elseif ($isDefaultView && !empty($latestYear)): ?>
<div class="filter-info home-latest-label">
Découvrez les TFE de <?= (int)$latestYear ?> — sélection aléatoire
</div>
<?php endif; ?>
<main class="home-main">
@@ -70,28 +83,31 @@ $currentNav = '';
<div class="card">
<div class="card__media">
<?php
// Use first image/video file as thumbnail
// Resolve thumbnail: banner_path → cover file → gradient placeholder
$thumb = null;
if (!empty($item['files'])) {
foreach ($item['files'] as $f) {
$ext = strtolower(pathinfo($f['file_path'], PATHINFO_EXTENSION));
if (in_array($ext, ['jpg','jpeg','png','gif','webp'])) {
$thumb = $f['file_path'];
break;
}
}
}
// Also check cover image
if (!$thumb && !empty($item['cover_image'])) {
$thumb = $item['cover_image'];
// 1. Banner path (dedicated home thumbnail)
if (!empty($item['banner_path'])) {
$thumb = $item['banner_path'];
}
// 2. Cover image from thesis_files (not returned by view — skip for now)
// 3. Fall through to gradient
?>
<?php if ($thumb): ?>
<img src="/media.php?path=<?= urlencode($thumb) ?>"
alt="<?= htmlspecialchars($item['title']) ?>"
loading="lazy">
<?php else: ?>
<div class="card__media--placeholder">◻</div>
<?php
$hue = ($item['id'] * 47 + 160) % 360;
$hue2 = ($hue + 40) % 360;
?>
<div class="card__media--gradient"
style="background:linear-gradient(135deg,hsl(<?= $hue ?>,60%,65%),hsl(<?= $hue2 ?>,55%,45%));">
<span class="card__gradient-author"><?= htmlspecialchars($item['authors'] ?? '') ?></span>
<span class="card__gradient-title"><?= htmlspecialchars($item['title']) ?></span>
</div>
<?php endif; ?>
</div>
<div class="card__info">

64
public/licence.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
require_once __DIR__ . '/../config/bootstrap.php';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php';
$currentNav = 'licence';
try {
$db = Database::getInstance();
$page = $db->getPage('licenses');
$content = $page ? $page['content'] : '';
$pageTitle = $page ? $page['title'] : 'Licences';
} catch (Exception $e) {
error_log("Error loading licence page: " . $e->getMessage());
$content = '';
$pageTitle = 'Licences';
}
$pd = new Parsedown();
$pd->setSafeMode(true);
$html = $pd->text($content);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle) ?> Posterg</title>
<link rel="icon" type="image/svg+xml" href="/assets/admin_favicon.svg">
<link rel="stylesheet" href="assets/modern-normalize.min.css">
<link rel="stylesheet" href="assets/common.css">
<link rel="stylesheet" href="assets/apropos.css">
<?php if (php_sapi_name() === 'cli-server'): ?>
<script>
(function poll(){
fetch('/live-reload.php').then(r=>r.json()).then(d=>{
if(d.changed) location.reload(); else setTimeout(poll,1000);
}).catch(()=>setTimeout(poll,2000));
})();
</script>
<?php endif; ?>
</head>
<body class="apropos-body">
<?php include APP_ROOT . '/templates/nav.php'; ?>
<?php include APP_ROOT . '/templates/search-bar.php'; ?>
<main class="apropos-main">
<div class="apropos-layout">
<div class="apropos-left">
<div class="apropos-description apropos-page-content">
<?php if (!empty(trim($content))): ?>
<?= $html ?>
<?php else: ?>
<p>Contenu à venir.</p>
<?php endif; ?>
</div>
</div>
<div class="apropos-right"></div>
</div>
</main>
</body>
</html>

View File

@@ -114,6 +114,13 @@ $currentNav = '';
</div>
<?php endif; ?>
<?php if (!empty($data['license_type'])): ?>
<div class="tfe-meta-item">
<span class="label">Licence :</span>
<span class="value"><?= htmlspecialchars($data['license_type']) ?></span>
</div>
<?php endif; ?>
<?php if (!empty($data['baiu_link'])): ?>
<div class="tfe-meta-item">
<span class="label">Contact :</span>