mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
Drops the session-backed HTMX incremental upload system in favour of a single JS module that manages `File` objects client-side and injects them into `FormData` on submit. Key changes: * `file-upload-queue.js`: client-side queues with validation, reorder (SortableJS), removal, dirty-state tracking, and fetch-based submit with manual redirect handling * `fichiers-fragment.php`: empty queue containers for JS-managed queues; HTMX format switching still works with queue rehydration after swap; annexe uploads now support multiple files * Form UI cleanup: moved existing files and cover preview into the `Fichiers` fieldset (edit mode); removed redundant queue labels while keeping labels for single-file inputs (`couverture`, `note d'intention`); added delete buttons for existing files * `ThesisFileHandler.php`: added `handleTfeQueueFiles()`/`handleAnnexeQueueFiles()` reading from `$_FILES['queue_file']`; introduced `extractFilesSubArray()` for nested upload arrays; removed session-based queue handling * `ThesisCreateController.php` & `ThesisEditController.php`: switched to extracted `['queue_file']` uploads * `beforeunload-guard.js`: now also watches `window.__xamxamDirty` * Deleted obsolete PHP upload/remove/reorder queue endpoints for `partage` and `admin` * Cleaned up route dispatch in `partage/index.php` * Misc form and styling updates in templates/CSS * Added `docs/cms-migration-plan.html`
1267 lines
60 KiB
HTML
1267 lines
60 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>XAMXAM — CMS Migration Plan</title>
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
:root {
|
||
--bg: #f8f7f4;
|
||
--surface: #ffffff;
|
||
--border: #e2dfd9;
|
||
--text: #1a1a1a;
|
||
--muted: #6b6b6b;
|
||
--accent: #2563eb;
|
||
--accent-bg: #eff6ff;
|
||
--warn: #d97706;
|
||
--warn-bg: #fffbeb;
|
||
--danger: #dc2626;
|
||
--danger-bg: #fef2f2;
|
||
--ok: #16a34a;
|
||
--ok-bg: #f0fdf4;
|
||
--radius: 8px;
|
||
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04);
|
||
}
|
||
|
||
body {
|
||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
line-height: 1.65;
|
||
font-size: 15px;
|
||
}
|
||
|
||
/* ── Layout ── */
|
||
.page-header {
|
||
background: var(--text);
|
||
color: #fff;
|
||
padding: 3rem 2rem 2.5rem;
|
||
}
|
||
.page-header h1 { font-size: 2rem; font-weight: 700; letter-spacing: -.03em; margin-bottom: .4rem; }
|
||
.page-header .subtitle { color: #aaa; font-size: .95rem; }
|
||
.page-header .meta { margin-top: 1rem; display: flex; gap: 1.5rem; flex-wrap: wrap; font-size: .85rem; color: #888; }
|
||
.page-header .meta span strong { color: #ccc; }
|
||
|
||
.toc {
|
||
background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: .75rem 2rem;
|
||
font-size: .85rem;
|
||
display: flex; gap: 1rem; flex-wrap: wrap;
|
||
position: sticky; top: 0; z-index: 10;
|
||
}
|
||
.toc a { color: var(--accent); text-decoration: none; white-space: nowrap; }
|
||
.toc a:hover { text-decoration: underline; }
|
||
|
||
main { max-width: 1060px; margin: 0 auto; padding: 2.5rem 2rem 5rem; }
|
||
|
||
section { margin-bottom: 3.5rem; }
|
||
section:first-child { margin-top: .5rem; }
|
||
|
||
h2 {
|
||
font-size: 1.35rem; font-weight: 700; letter-spacing: -.02em;
|
||
border-bottom: 2px solid var(--border);
|
||
padding-bottom: .5rem; margin-bottom: 1.5rem;
|
||
scroll-margin-top: 60px;
|
||
}
|
||
h3 { font-size: 1.05rem; font-weight: 600; margin: 1.5rem 0 .6rem; }
|
||
h4 { font-size: .9rem; font-weight: 600; margin: 1rem 0 .4rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
|
||
|
||
p { margin-bottom: .8rem; }
|
||
ul, ol { padding-left: 1.4rem; margin-bottom: .8rem; }
|
||
li { margin-bottom: .35rem; }
|
||
|
||
/* ── Cards / callouts ── */
|
||
.card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 1.25rem 1.5rem;
|
||
box-shadow: var(--shadow);
|
||
margin-bottom: 1rem;
|
||
}
|
||
.callout {
|
||
border-radius: var(--radius);
|
||
padding: 1rem 1.25rem;
|
||
margin-bottom: 1rem;
|
||
border-left: 4px solid;
|
||
}
|
||
.callout.info { background: var(--accent-bg); border-color: var(--accent); }
|
||
.callout.warn { background: var(--warn-bg); border-color: var(--warn); }
|
||
.callout.danger{ background: var(--danger-bg); border-color: var(--danger); }
|
||
.callout.ok { background: var(--ok-bg); border-color: var(--ok); }
|
||
.callout-title { font-weight: 700; margin-bottom: .3rem; font-size: .85rem; text-transform: uppercase; letter-spacing: .06em; }
|
||
.callout.info .callout-title { color: var(--accent); }
|
||
.callout.warn .callout-title { color: var(--warn); }
|
||
.callout.danger .callout-title { color: var(--danger); }
|
||
.callout.ok .callout-title { color: var(--ok); }
|
||
|
||
/* ── CMS comparison grid ── */
|
||
.cms-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
|
||
gap: 1rem;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
.cms-card {
|
||
background: var(--surface);
|
||
border: 2px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 1.5rem;
|
||
position: relative;
|
||
}
|
||
.cms-card.recommended {
|
||
border-color: var(--accent);
|
||
}
|
||
.badge {
|
||
display: inline-block;
|
||
font-size: .7rem; font-weight: 700; letter-spacing: .06em;
|
||
text-transform: uppercase; padding: .2rem .55rem;
|
||
border-radius: 99px; margin-bottom: .75rem;
|
||
}
|
||
.badge.recommended { background: var(--accent); color: #fff; }
|
||
.badge.alt { background: #e5e7eb; color: #374151; }
|
||
.badge.not-suitable{ background: #fee2e2; color: #991b1b; }
|
||
.cms-card h3 { margin-top: 0; font-size: 1.1rem; }
|
||
.cms-card .tagline { color: var(--muted); font-size: .85rem; margin-bottom: .75rem; }
|
||
.pros-cons { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem 1rem; margin-top: .75rem; }
|
||
.pros-cons h4 { margin: 0 0 .3rem; font-size: .78rem; }
|
||
.pros-cons ul { font-size: .82rem; padding-left: 1.1rem; margin: 0; }
|
||
.pros-cons li { margin-bottom: .25rem; }
|
||
|
||
/* ── Feature mapping table ── */
|
||
.feature-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||
.feature-table th {
|
||
background: #f1f0ed; text-align: left;
|
||
padding: .55rem .85rem; font-size: .78rem; font-weight: 600;
|
||
text-transform: uppercase; letter-spacing: .05em;
|
||
border-bottom: 2px solid var(--border);
|
||
}
|
||
.feature-table td {
|
||
padding: .55rem .85rem;
|
||
border-bottom: 1px solid var(--border);
|
||
vertical-align: top;
|
||
}
|
||
.feature-table tr:last-child td { border-bottom: none; }
|
||
.feature-table tr:hover td { background: #fafafa; }
|
||
.tag {
|
||
display: inline-block; font-size: .72rem; font-weight: 600;
|
||
padding: .15rem .45rem; border-radius: 4px; white-space: nowrap;
|
||
}
|
||
.tag.native { background: var(--ok-bg); color: var(--ok); }
|
||
.tag.plugin { background: var(--accent-bg); color: var(--accent); }
|
||
.tag.custom { background: var(--warn-bg); color: var(--warn); }
|
||
.tag.drop { background: var(--danger-bg); color: var(--danger); }
|
||
|
||
/* ── Timeline ── */
|
||
.timeline { position: relative; padding-left: 2rem; }
|
||
.timeline::before {
|
||
content: ''; position: absolute; left: .55rem; top: .5rem; bottom: 0;
|
||
width: 2px; background: var(--border);
|
||
}
|
||
.phase {
|
||
position: relative; margin-bottom: 1.75rem;
|
||
}
|
||
.phase::before {
|
||
content: ''; position: absolute; left: -1.67rem; top: .45rem;
|
||
width: 12px; height: 12px; border-radius: 50%;
|
||
border: 2px solid var(--accent); background: var(--surface);
|
||
}
|
||
.phase-header { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .4rem; }
|
||
.phase-num { font-size: .75rem; font-weight: 700; text-transform: uppercase; color: var(--accent); letter-spacing: .07em; }
|
||
.phase-duration { font-size: .8rem; color: var(--muted); }
|
||
.phase h3 { font-size: 1rem; margin: 0; }
|
||
.phase ul { font-size: .88rem; margin-top: .4rem; }
|
||
|
||
/* ── Effort table ── */
|
||
.effort-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||
.effort-table th {
|
||
background: #f1f0ed; text-align: left;
|
||
padding: .5rem .85rem; font-size: .78rem; font-weight: 600;
|
||
text-transform: uppercase; letter-spacing: .05em;
|
||
border-bottom: 2px solid var(--border);
|
||
}
|
||
.effort-table td { padding: .5rem .85rem; border-bottom: 1px solid var(--border); }
|
||
.effort-table tfoot td { font-weight: 700; background: #f8f7f4; }
|
||
.effort-table .bar-cell { width: 140px; }
|
||
.bar-wrap { background: #e5e7eb; border-radius: 99px; height: 8px; }
|
||
.bar { height: 8px; border-radius: 99px; background: var(--accent); }
|
||
|
||
/* ── Architecture diagram (ASCII) ── */
|
||
.arch-diagram {
|
||
background: #1a1a2e; color: #a8d8ea;
|
||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||
font-size: .8rem; line-height: 1.55;
|
||
border-radius: var(--radius); padding: 1.5rem 2rem;
|
||
overflow-x: auto; white-space: pre;
|
||
}
|
||
|
||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||
@media (max-width: 640px) { .two-col { grid-template-columns: 1fr; } .pros-cons { grid-template-columns: 1fr; } }
|
||
|
||
/* ── Invoice ── */
|
||
.invoice {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
overflow: hidden;
|
||
font-size: .88rem;
|
||
}
|
||
.invoice-header {
|
||
background: var(--text); color: #fff;
|
||
padding: 1.5rem 2rem;
|
||
display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap;
|
||
}
|
||
.invoice-header .from { font-size: .8rem; color: #aaa; line-height: 1.7; }
|
||
.invoice-header .from strong { color: #fff; display: block; font-size: 1rem; margin-bottom: .2rem; }
|
||
.invoice-header .inv-meta { text-align: right; font-size: .8rem; color: #aaa; line-height: 1.7; }
|
||
.invoice-header .inv-meta strong { color: #fff; display: block; font-size: 1rem; margin-bottom: .2rem; }
|
||
.invoice-to { padding: 1.25rem 2rem; border-bottom: 1px solid var(--border); background: #fafaf8; }
|
||
.invoice-to .label { font-size: .72rem; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: .3rem; }
|
||
.invoice-to p { margin: 0; line-height: 1.6; color: var(--text); }
|
||
.invoice-lines { width: 100%; border-collapse: collapse; }
|
||
.invoice-lines th {
|
||
background: #f1f0ed; text-align: left;
|
||
padding: .55rem 1.25rem; font-size: .75rem; font-weight: 600;
|
||
text-transform: uppercase; letter-spacing: .06em;
|
||
border-bottom: 2px solid var(--border);
|
||
}
|
||
.invoice-lines th.num { text-align: right; }
|
||
.invoice-lines td { padding: .6rem 1.25rem; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||
.invoice-lines td.num { text-align: right; white-space: nowrap; }
|
||
.invoice-lines tr:last-child td { border-bottom: none; }
|
||
.invoice-lines tr.section-head td {
|
||
background: #f8f7f4; font-weight: 600; font-size: .78rem;
|
||
text-transform: uppercase; letter-spacing: .05em; color: var(--muted);
|
||
padding: .4rem 1.25rem;
|
||
}
|
||
.invoice-totals { border-top: 2px solid var(--border); }
|
||
.invoice-totals table { width: 100%; border-collapse: collapse; }
|
||
.invoice-totals td { padding: .5rem 1.25rem; }
|
||
.invoice-totals td:first-child { color: var(--muted); }
|
||
.invoice-totals td:last-child { text-align: right; font-weight: 600; white-space: nowrap; }
|
||
.invoice-totals tr.grand td { background: #1a1a1a; color: #fff; font-size: 1rem; font-weight: 700; }
|
||
.invoice-totals tr.grand td:last-child { color: #fff; }
|
||
.invoice-footer { padding: 1rem 1.25rem; color: var(--muted); font-size: .78rem; border-top: 1px solid var(--border); line-height: 1.7; }
|
||
|
||
code { font-family: monospace; background: #f1f0ed; padding: .1em .4em; border-radius: 3px; font-size: .88em; }
|
||
strong { font-weight: 600; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="page-header">
|
||
<h1>XAMXAM — CMS Migration Plan</h1>
|
||
<p class="subtitle">Implementation plan for rebuilding the ERG thesis repository on a commercially available CMS</p>
|
||
<div class="meta">
|
||
<span><strong>Date</strong> May 2026</span>
|
||
<span><strong>Scope</strong> Full feature-equivalent rebuild</span>
|
||
<span><strong>Recommended stack</strong> Strapi v5 + Next.js 15</span>
|
||
<span><strong>Estimated timeline</strong> 18–24 weeks</span>
|
||
</div>
|
||
</header>
|
||
|
||
<nav class="toc">
|
||
<strong style="color:#888;font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;align-self:center">Jump to →</strong>
|
||
<a href="#overview">Overview</a>
|
||
<a href="#cms-choice">CMS Choice</a>
|
||
<a href="#architecture">Architecture</a>
|
||
<a href="#features">Feature Map</a>
|
||
<a href="#requirements">Requirements</a>
|
||
<a href="#timeline">Timeline</a>
|
||
<a href="#effort">Effort & Cost</a>
|
||
<a href="#invoice">Invoice example</a>
|
||
<a href="#tradeoffs">Trade-offs</a>
|
||
<a href="#not-included">Not included</a>
|
||
</nav>
|
||
|
||
<main>
|
||
|
||
<!-- ─────────────────────────────────── OVERVIEW ─────────────────────────────────── -->
|
||
<section id="overview">
|
||
<h2>1. Project Overview</h2>
|
||
|
||
<p>
|
||
<strong>XAMXAM</strong> (formerly Posterg) is the thesis/TFE (<em>Travail de Fin d'Études</em>) repository
|
||
for the <strong>ERG — École de Recherche Graphique</strong> in Brussels. It is a purpose-built PHP 8.4 +
|
||
SQLite3 application deployed behind nginx.
|
||
</p>
|
||
|
||
<div class="two-col">
|
||
<div class="card">
|
||
<h3>Current tech stack</h3>
|
||
<ul>
|
||
<li>PHP 8.4, SQLite 3, nginx</li>
|
||
<li>Custom MVC (no framework)</li>
|
||
<li>HTMX for partial rendering</li>
|
||
<li>No npm / no build step</li>
|
||
<li>rsync deployment (no git on server)</li>
|
||
<li>Single-server, single-file database</li>
|
||
</ul>
|
||
</div>
|
||
<div class="card">
|
||
<h3>What the system does</h3>
|
||
<ul>
|
||
<li>Public browseable + searchable thesis catalogue</li>
|
||
<li>Admin panel: create / edit / publish / export theses</li>
|
||
<li>Student submission via one-time share links</li>
|
||
<li>Controlled file serving (PDFs, images, video, audio)</li>
|
||
<li>PeerTube video upload integration</li>
|
||
<li>SMTP email notifications with encrypted credentials</li>
|
||
<li>Audit log, maintenance mode, CSV/DB export</li>
|
||
<li>Access control: open / restricted (cookie token) / forbidden</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="callout info">
|
||
<div class="callout-title">Scope of this plan</div>
|
||
The goal is a <strong>feature-equivalent rebuild</strong> using an off-the-shelf CMS — meaning the ERG
|
||
team would no longer need to maintain bespoke PHP controllers, database migrations, or a custom admin panel.
|
||
Custom code is minimised to integrations that no CMS ships out-of-the-box (share links, restricted
|
||
file access, PeerTube upload).
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ──────────────────────────────── CMS CHOICE ─────────────────────────────────── -->
|
||
<section id="cms-choice">
|
||
<h2>2. CMS Choice</h2>
|
||
|
||
<p>Three commercially available CMS products were evaluated against XAMXAM's requirements.</p>
|
||
|
||
<div class="cms-grid">
|
||
|
||
<div class="cms-card recommended">
|
||
<span class="badge recommended">Recommended</span>
|
||
<h3>Strapi v5</h3>
|
||
<p class="tagline">Headless Node.js CMS — self-hosted, open-source core</p>
|
||
<div class="pros-cons">
|
||
<div>
|
||
<h4>Pros</h4>
|
||
<ul>
|
||
<li>Rich relation fields (authors, jury, files, tags) built-in</li>
|
||
<li>Admin panel generated from content-type schema</li>
|
||
<li>Role-based access control (admin vs. student roles)</li>
|
||
<li>Media library with upload providers</li>
|
||
<li>REST + GraphQL API out-of-the-box</li>
|
||
<li>Lifecycle hooks for custom logic</li>
|
||
<li>Plugin system for custom routes</li>
|
||
<li>SQLite supported (small team, single server)</li>
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<h4>Cons</h4>
|
||
<ul>
|
||
<li>JavaScript/Node.js — different from current PHP stack</li>
|
||
<li>Separate frontend required (Next.js or similar)</li>
|
||
<li>Share-link submission portal = custom plugin</li>
|
||
<li>File-access tokens = custom plugin</li>
|
||
<li>PeerTube upload = custom plugin</li>
|
||
<li>More moving parts than current single-PHP-server setup</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cms-card">
|
||
<span class="badge alt">Alternative</span>
|
||
<h3>Kirby CMS 4</h3>
|
||
<p class="tagline">Flat-file PHP CMS — familiar language, no database required</p>
|
||
<div class="pros-cons">
|
||
<div>
|
||
<h4>Pros</h4>
|
||
<ul>
|
||
<li>PHP — same language as today</li>
|
||
<li>Flexible content blueprints</li>
|
||
<li>Custom routes and hooks</li>
|
||
<li>Panel (admin UI) with custom sections</li>
|
||
<li>Lightweight, no extra runtime</li>
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<h4>Cons</h4>
|
||
<ul>
|
||
<li>Flat-file (YAML/Markdown) — poor fit for relational thesis data</li>
|
||
<li>No built-in user roles for student submission</li>
|
||
<li>Search, pagination, and filtering need custom code</li>
|
||
<li>Virtually all the complex logic still needs writing</li>
|
||
<li>Comercial licence required ($199/site)</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cms-card">
|
||
<span class="badge not-suitable">Not suitable</span>
|
||
<h3>WordPress</h3>
|
||
<p class="tagline">PHP CMS + plugin ecosystem</p>
|
||
<div class="pros-cons">
|
||
<div>
|
||
<h4>Pros</h4>
|
||
<ul>
|
||
<li>Familiar to many users</li>
|
||
<li>Large plugin ecosystem</li>
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<h4>Cons</h4>
|
||
<ul>
|
||
<li>Content model too blog-centric for thesis metadata</li>
|
||
<li>Custom post types + ACF = heavyweight approach</li>
|
||
<li>Student submission portal not in any standard plugin</li>
|
||
<li>Security attack surface is substantial</li>
|
||
<li>Performance tuning overhead for file serving</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="callout ok">
|
||
<div class="callout-title">Verdict</div>
|
||
<strong>Strapi v5</strong> is the best match. Its content-type system maps naturally to the thesis data
|
||
model (relations, media, nested metadata). The admin panel is generated automatically from the schema.
|
||
Custom plugins cover the three features not provided by any CMS out-of-the-box: share links, restricted
|
||
file access, and PeerTube upload.
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ─────────────────────────────── ARCHITECTURE ────────────────────────────────── -->
|
||
<section id="architecture">
|
||
<h2>3. Proposed Architecture</h2>
|
||
|
||
<pre class="arch-diagram">
|
||
┌──────────────────────────────────────────────────────────────────────┐
|
||
│ nginx (reverse proxy) │
|
||
│ / → Next.js (port 3000) │
|
||
│ /admin → Strapi admin UI (port 1337/admin) │
|
||
│ /api → Strapi REST API (port 1337/api) │
|
||
│ /partage/:slug → Next.js (student submission route) │
|
||
└──────────────────────────────────────────────────────────────────────┘
|
||
│ │
|
||
▼ ▼
|
||
┌──────────────────┐ ┌──────────────────────────────────────────┐
|
||
│ Next.js 15 │ │ Strapi v5 (Node.js) │
|
||
│ (public site + │ │ │
|
||
│ student portal) │◄──►│ Content Types: Thesis, Author, │
|
||
│ │ │ Supervisor, Tag, Format, Language, │
|
||
│ - Home page │ │ ShareLink, AccessToken, SiteSettings │
|
||
│ - TFE detail │ │ │
|
||
│ - Search │ │ Plugins: │
|
||
│ - /partage form │ │ • share-link (custom routes + logic) │
|
||
│ - File proxy │ │ • file-access (token + cookie auth) │
|
||
└──────────────────┘ │ • peertube (upload integration) │
|
||
│ │
|
||
│ Media: local upload provider │
|
||
│ Database: SQLite (or PostgreSQL) │
|
||
└──────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌──────────────────────────┐
|
||
│ Storage (outside webroot) │
|
||
│ /var/www/xamxam/uploads/ │
|
||
│ (TFE PDFs, covers, │
|
||
│ annexes, video, audio) │
|
||
└──────────────────────────┘
|
||
</pre>
|
||
|
||
<h3>Key decisions</h3>
|
||
<ul>
|
||
<li><strong>SQLite is kept</strong> — Strapi supports it natively. Migration to PostgreSQL is a one-line config change if needed later.</li>
|
||
<li><strong>Files are never directly web-accessible</strong> — Next.js <code>/api/media/[...path]</code> proxies file requests, enforcing access-type checks (same as current <code>media.php</code>).</li>
|
||
<li><strong>Strapi admin panel replaces the custom PHP admin</strong> — thesis CRUD, jury management, share-link management, settings, and audit log all live there.</li>
|
||
<li><strong>Next.js App Router</strong> handles the public site and the <code>/partage/:slug</code> student submission portal.</li>
|
||
<li><strong>Single server deployment</strong> — both processes (<code>strapi start</code>, <code>next start</code>) run behind nginx, same as current setup.</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<!-- ──────────────────────────────── FEATURE MAP ────────────────────────────────── -->
|
||
<section id="features">
|
||
<h2>4. Feature Map</h2>
|
||
|
||
<p>
|
||
Tags: <span class="tag native">native</span> = ships out-of-the-box with the CMS,
|
||
<span class="tag plugin">plugin</span> = available via an existing Strapi plugin or npm package,
|
||
<span class="tag custom">custom</span> = needs bespoke code,
|
||
<span class="tag drop">drop</span> = intentionally not reproduced (replaced or unnecessary).
|
||
</p>
|
||
|
||
<table class="feature-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Feature (current XAMXAM)</th>
|
||
<th>CMS equivalent</th>
|
||
<th>Coverage</th>
|
||
<th>Notes</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<!-- Public site -->
|
||
<tr>
|
||
<td colspan="4" style="background:#f8f7f4;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#6b6b6b">Public site</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Home page — latest year, random order, year filter</td>
|
||
<td>Next.js page fetching Strapi <code>/api/theses</code></td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td>Strapi REST supports sort, filter, populate. Random order via <code>RANDOM()</code> is a custom sort plugin or a shuffle in Next.js.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Paginated browse (24/page)</td>
|
||
<td>Strapi pagination params (<code>pagination[page]</code>)</td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Full-text search (title, author, keyword, year, orientation)</td>
|
||
<td>Strapi filters + Next.js search page</td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td>Strapi's <code>$containsi</code> operator covers basic search. For true FTS, add the <code>strapi-plugin-fuzzy-search</code> package.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Rate limiting on search</td>
|
||
<td>nginx <code>limit_req_zone</code> or Strapi middleware</td>
|
||
<td><span class="tag plugin">plugin</span></td>
|
||
<td><code>koa-ratelimit</code> as a Strapi middleware.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>TFE detail page with all metadata</td>
|
||
<td>Next.js dynamic route <code>/tfe/[id]</code></td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td>Use <code>populate[]=authors&populate[]=jury&populate[]=files</code> etc.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>OG / Twitter Card meta tags</td>
|
||
<td>Next.js <code>generateMetadata()</code></td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Email obfuscation for author contact</td>
|
||
<td>Client-side JS decode or Next.js server component</td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>~10 lines of logic. No CMS ships this.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Maintenance mode</td>
|
||
<td>Next.js middleware redirect + flag env var</td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>Simple; <code>MAINTENANCE=1</code> → middleware returns 503 page.</td>
|
||
</tr>
|
||
|
||
<!-- File serving -->
|
||
<tr>
|
||
<td colspan="4" style="background:#f8f7f4;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#6b6b6b">File serving & access control</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Controlled file proxy (PDFs, images, video, audio)</td>
|
||
<td>Next.js API route <code>/api/media/[...path]</code></td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>Replicates current <code>media.php</code>. Streams file, validates MIME, enforces access type.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Access types: open / restricted / forbidden</td>
|
||
<td>Strapi <code>access_type</code> relation + media proxy</td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>Strapi stores the type; the proxy enforces it.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Cookie-based restricted access tokens</td>
|
||
<td>Custom <code>file-access</code> Strapi plugin</td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>Generates signed tokens, stores in DB, sets <code>HttpOnly</code> cookies. Same logic as current <code>FileAccessController.php</code>.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>File labels, sort order per thesis</td>
|
||
<td>Strapi relation with pivot fields</td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td>Strapi allows custom fields on many-to-many pivot tables (v5 "dynamic zones" or custom join table).</td>
|
||
</tr>
|
||
|
||
<!-- Admin panel -->
|
||
<tr>
|
||
<td colspan="4" style="background:#f8f7f4;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#6b6b6b">Admin panel</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Thesis CRUD (create / edit / delete)</td>
|
||
<td>Strapi admin content manager</td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td>All fields configured in content-type schema. No PHP to write.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Publish / unpublish thesis</td>
|
||
<td>Strapi draft & publish system</td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td>Toggle on the content entry. Matches current <code>is_published</code> flag.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Author management (name, email, contact visibility)</td>
|
||
<td>Strapi <code>Author</code> content type with relation</td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td>Authors are a separate content type, reusable across theses.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Jury management (role, interne/externe/ULB)</td>
|
||
<td>Strapi <code>Supervisor</code> type + pivot role field</td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td>Role dropdown (president / promoteur / lecteur) on the relation. ULB flag as boolean.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>File upload queue with drag-reorder</td>
|
||
<td>Strapi Media Library + custom admin plugin UI</td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>Strapi's built-in media library handles upload. Drag-reorder with a custom "sort_order" component needs a small admin UI plugin (~1–2 days).</td>
|
||
</tr>
|
||
<tr>
|
||
<td>PeerTube video/audio upload</td>
|
||
<td>Custom <code>peertube</code> Strapi plugin</td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>Replicates <code>PeerTubeService.php</code>. OAuth2 password grant, credential encryption, token refresh on 401. ~3–5 days.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Share-link management (create, toggle, archive)</td>
|
||
<td>Custom <code>share-link</code> Strapi plugin</td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>Custom content type + admin panel list view. Slug generation, optional password hash, expiry, usage count. ~3–4 days.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>CSV export</td>
|
||
<td>Custom Strapi controller or admin plugin action</td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>~1 day. Strapi lifecycle hooks make CSV generation straightforward.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Database / file archive export</td>
|
||
<td>Custom Strapi controller (zip SQLite + storage dir)</td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>~1 day. Identical logic to current <code>ExportController.php</code>.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Audit log</td>
|
||
<td>Strapi <code>strapi-plugin-audit-log</code> or custom</td>
|
||
<td><span class="tag plugin">plugin</span></td>
|
||
<td>Community plugin <code>strapi-audit-log</code> covers create/update/delete. Custom entries for share-link events.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Admin login (htpasswd + PHP session)</td>
|
||
<td>Strapi native JWT-based admin auth</td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td>nginx <code>auth_basic</code> layer can remain as a second factor if desired.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Site settings (SMTP, PeerTube, help text)</td>
|
||
<td>Strapi Single-Type <code>SiteSettings</code></td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td>Single-Type entries allow one-off config records. Encrypted fields via Strapi lifecycle hooks.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>SMTP send with encrypted password</td>
|
||
<td>Custom Strapi service + <code>nodemailer</code></td>
|
||
<td><span class="tag plugin">plugin</span></td>
|
||
<td><code>@strapi/provider-email-nodemailer</code> for delivery. AES encryption of the stored password via a lifecycle hook.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Inline form help text (configurable per field)</td>
|
||
<td>Strapi Single-Type <code>FormHelp</code> + admin UI</td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td>Store help blocks as a Single-Type with one entry per field. Renders in the Next.js form.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Import from CSV / legacy data</td>
|
||
<td>Custom Strapi import script (run once)</td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>One-time migration. ~3–5 days depending on data quality.</td>
|
||
</tr>
|
||
|
||
<!-- Student submission -->
|
||
<tr>
|
||
<td colspan="4" style="background:#f8f7f4;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#6b6b6b">Student submission portal (/partage)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Share-link access (slug + optional password)</td>
|
||
<td>Next.js <code>/partage/[slug]</code> + Strapi <code>/api/share-links/:slug/validate</code></td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>Custom Strapi route validates slug, checks password, expiry, usage count. Issues a JWT session for the student.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Student TFE submission form</td>
|
||
<td>Next.js form + Strapi <code>/api/share-links/:slug/submit</code></td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>Replicates the full <code>/partage/index.php</code> form. All validation server-side in Strapi. ~4–6 days.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Incremental file upload queue with reorder</td>
|
||
<td>Next.js client-side queue + Strapi upload endpoint</td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>Client-side JS queue (as planned in <code>TODO.md</code>). Submits FormData on final form submit. Same plan as the current in-progress refactor.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Email confirmation on submission</td>
|
||
<td>Strapi lifecycle hook → nodemailer</td>
|
||
<td><span class="tag plugin">plugin</span></td>
|
||
<td>On thesis creation event, fire email via the nodemailer provider.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Summary / recap page</td>
|
||
<td>Next.js <code>/partage/[slug]/recap</code></td>
|
||
<td><span class="tag custom">custom</span></td>
|
||
<td>Renders after successful submission. ~0.5 days.</td>
|
||
</tr>
|
||
|
||
<!-- Misc -->
|
||
<tr>
|
||
<td colspan="4" style="background:#f8f7f4;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#6b6b6b">Lookup data & taxonomy</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Orientations, AP programs, finality types, languages, formats, license types</td>
|
||
<td>Strapi content types (or Enumeration fields)</td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td>All managed via Strapi admin. Relations from the Thesis type.</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Tags / keywords (free-text, multi-value)</td>
|
||
<td>Strapi <code>Tag</code> content type with many-to-many</td>
|
||
<td><span class="tag native">native</span></td>
|
||
<td>Autocomplete in admin via Strapi's default relation field.</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<!-- ─────────────────────────────── REQUIREMENTS ─────────────────────────────────── -->
|
||
<section id="requirements">
|
||
<h2>5. Requirements</h2>
|
||
|
||
<div class="two-col">
|
||
<div>
|
||
<h3>Functional requirements</h3>
|
||
<ol>
|
||
<li>Public catalogue: browse, paginate, filter by year / orientation / keyword</li>
|
||
<li>Full-text search across title, author, synopsis, keywords</li>
|
||
<li>TFE detail page: all metadata, files (gated by access type), jury composition</li>
|
||
<li>Three file access tiers: open, restricted (cookie token), forbidden</li>
|
||
<li>Admin: full thesis lifecycle (draft → published → archived)</li>
|
||
<li>Admin: manage all lookup tables (orientations, formats, languages, licences…)</li>
|
||
<li>Admin: share-link CRUD (create, toggle, set password, set expiry, archive)</li>
|
||
<li>Admin: audit log of all content changes</li>
|
||
<li>Admin: CSV export and full database/file archive download</li>
|
||
<li>Admin: configurable SMTP relay with encrypted credential storage</li>
|
||
<li>Admin: configurable PeerTube integration (instance URL, credentials, channel, privacy)</li>
|
||
<li>Admin: inline form help text, configurable per field</li>
|
||
<li>Student portal: access via share link, optional password gate</li>
|
||
<li>Student portal: multi-file upload queue with reorder and removal</li>
|
||
<li>Student portal: full thesis submission form with inline validation</li>
|
||
<li>Student portal: email confirmation on successful submission</li>
|
||
<li>Maintenance mode toggle (admin-accessible while public site is blocked)</li>
|
||
<li>OG / Twitter Card meta tags on all public pages</li>
|
||
<li>WCAG 2.1 AA accessibility on public pages and student form</li>
|
||
</ol>
|
||
</div>
|
||
<div>
|
||
<h3>Non-functional requirements</h3>
|
||
<ol>
|
||
<li><strong>Security:</strong> no uploaded file is ever directly web-accessible; all served via authenticated proxy</li>
|
||
<li><strong>Security:</strong> SMTP and PeerTube passwords encrypted at rest (AES-256)</li>
|
||
<li><strong>Security:</strong> CSRF protection on all forms</li>
|
||
<li><strong>Security:</strong> rate limiting on public search and share-link validation endpoints</li>
|
||
<li><strong>Performance:</strong> cover images batch-loaded; ISR (Next.js Incremental Static Regen) for public pages</li>
|
||
<li><strong>Deployment:</strong> single VPS, two processes (Strapi + Next.js) behind nginx</li>
|
||
<li><strong>Database:</strong> SQLite for parity with current setup; PostgreSQL upgrade path must be supported</li>
|
||
<li><strong>Backups:</strong> existing <code>just deploy-db</code> + rsync workflow preserved or equivalent provided</li>
|
||
<li><strong>Internationalisation:</strong> UI in French; content supports multilingual entries (fr, nl, en)</li>
|
||
<li><strong>Maintainability:</strong> no custom admin UI beyond what Strapi's plugin API supports; avoid forks of the CMS core</li>
|
||
</ol>
|
||
|
||
<h3>Dependencies & integrations</h3>
|
||
<ul>
|
||
<li>PeerTube instance (existing, ERG-hosted)</li>
|
||
<li>SMTP relay (existing ERG mail server)</li>
|
||
<li>nginx (existing, config updated for new port routing)</li>
|
||
<li>Existing SQLite database (migrated via one-time import script)</li>
|
||
<li>Existing file storage directory (copied to new upload path)</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ─────────────────────────────────── TIMELINE ─────────────────────────────────── -->
|
||
<section id="timeline">
|
||
<h2>6. Delivery Timeline</h2>
|
||
|
||
<p>Estimated at <strong>1 developer, full-time</strong>. Reduce scope or add a second dev to compress.</p>
|
||
|
||
<div class="timeline">
|
||
|
||
<div class="phase">
|
||
<div class="phase-header">
|
||
<span class="phase-num">Phase 1</span>
|
||
<h3>Foundation & Data Model</h3>
|
||
<span class="phase-duration">3 weeks</span>
|
||
</div>
|
||
<ul>
|
||
<li>Initialise Strapi v5 project with SQLite</li>
|
||
<li>Define all content types: <code>Thesis</code>, <code>Author</code>, <code>Supervisor</code>, <code>ThesisFile</code>, <code>Tag</code>, <code>Orientation</code>, <code>ApProgram</code>, <code>FinalityType</code>, <code>Language</code>, <code>FormatType</code>, <code>LicenseType</code>, <code>AccessType</code>, <code>SiteSettings</code>, <code>FormHelp</code></li>
|
||
<li>Configure relations and pivot fields (jury role, author order, file sort_order, file label)</li>
|
||
<li>Enable Draft & Publish on <code>Thesis</code></li>
|
||
<li>Write data migration script: read SQLite → POST to Strapi API</li>
|
||
<li>Migrate all files to Strapi upload directory</li>
|
||
<li>Validate migrated data in admin panel</li>
|
||
<li><strong>Deliverable:</strong> Strapi running locally with full existing dataset</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="phase">
|
||
<div class="phase-header">
|
||
<span class="phase-num">Phase 2</span>
|
||
<h3>Public Site (Next.js)</h3>
|
||
<span class="phase-duration">4 weeks</span>
|
||
</div>
|
||
<ul>
|
||
<li>Initialise Next.js 15 App Router project</li>
|
||
<li>Home page: latest year random grid + year filter + pagination</li>
|
||
<li>Search page: full-text across all fields, filter controls</li>
|
||
<li>TFE detail page: all metadata sections, jury composition, file list</li>
|
||
<li>File proxy route <code>/api/media/[...path]</code> with access-type enforcement</li>
|
||
<li>OG / Twitter meta tags via <code>generateMetadata()</code></li>
|
||
<li>CSS port from current design (reuse existing <code>public.css</code>, <code>tfe.css</code>)</li>
|
||
<li>Email obfuscation component</li>
|
||
<li>Maintenance mode middleware</li>
|
||
<li><strong>Deliverable:</strong> public site feature-complete, pixel-comparable to current</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="phase">
|
||
<div class="phase-header">
|
||
<span class="phase-num">Phase 3</span>
|
||
<h3>Restricted Access + File Access Tokens</h3>
|
||
<span class="phase-duration">1 week</span>
|
||
</div>
|
||
<ul>
|
||
<li>Strapi <code>file-access</code> plugin: custom route <code>POST /api/file-access/request</code></li>
|
||
<li>Token generation, storage in <code>file_access_tokens</code> table, cookie issuance</li>
|
||
<li>File proxy reads cookie, validates token against DB</li>
|
||
<li>Access request form in Next.js TFE detail page</li>
|
||
<li>Admin panel: view / revoke tokens per thesis</li>
|
||
<li><strong>Deliverable:</strong> restricted file access parity with current</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="phase">
|
||
<div class="phase-header">
|
||
<span class="phase-num">Phase 4</span>
|
||
<h3>Student Submission Portal</h3>
|
||
<span class="phase-duration">4 weeks</span>
|
||
</div>
|
||
<ul>
|
||
<li>Strapi <code>share-link</code> plugin: content type + slug generation + validate/submit routes</li>
|
||
<li>Next.js <code>/partage/[slug]</code>: slug gate, optional password form</li>
|
||
<li>Student submission form: all fields (title, subtitle, authors, jury, synopsis, keywords, formats, languages, licence, files)</li>
|
||
<li>Client-side file upload queue (TFE, annexes, cover, video, audio) with drag-reorder, MIME/size validation</li>
|
||
<li>Server-side validation replicating current <code>ThesisCreateController</code> logic</li>
|
||
<li>Email confirmation via nodemailer on submission</li>
|
||
<li>Recap / summary page</li>
|
||
<li>Admin panel: share-link list with create / toggle / archive / set-password actions</li>
|
||
<li><strong>Deliverable:</strong> student portal fully functional end-to-end</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="phase">
|
||
<div class="phase-header">
|
||
<span class="phase-num">Phase 5</span>
|
||
<h3>Admin Completions</h3>
|
||
<span class="phase-duration">3 weeks</span>
|
||
</div>
|
||
<ul>
|
||
<li>PeerTube plugin: settings form, OAuth2 token management, upload action on thesis edit</li>
|
||
<li>CSV export endpoint</li>
|
||
<li>Database + files archive download endpoint</li>
|
||
<li>Audit log plugin (or configure <code>strapi-audit-log</code>)</li>
|
||
<li>Admin file-reorder UI plugin (drag handles on thesis media list)</li>
|
||
<li>SMTP settings + test-send + encrypted storage via lifecycle hook</li>
|
||
<li>FormHelp single-type admin UI + rendering in student form</li>
|
||
<li><strong>Deliverable:</strong> all admin features present, team can self-manage</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="phase">
|
||
<div class="phase-header">
|
||
<span class="phase-num">Phase 6</span>
|
||
<h3>QA, Hardening & Cutover</h3>
|
||
<span class="phase-duration">3 weeks</span>
|
||
</div>
|
||
<ul>
|
||
<li>Security review: headers, rate limiting, CSRF, upload validation, path traversal</li>
|
||
<li>WCAG 2.1 AA audit on public pages and student form</li>
|
||
<li>Performance: ISR on TFE pages, cover image optimisation (Next.js <code>Image</code>)</li>
|
||
<li>nginx config update for new port routing</li>
|
||
<li>Staging deploy: run both old and new in parallel; team acceptance testing</li>
|
||
<li>Production cutover: deploy new stack, migrate DB + files, update DNS if needed</li>
|
||
<li>Post-cutover monitoring (1 week watch period)</li>
|
||
<li><strong>Deliverable:</strong> production running on new stack</li>
|
||
</ul>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="card" style="margin-top:1rem">
|
||
<strong>Total: 18 weeks (approx. 4.5 months)</strong> at 1 full-time developer.
|
||
Add 4–6 weeks buffer for stakeholder review cycles, scope additions, or part-time availability.
|
||
Realistic end-to-end: <strong>22–24 weeks from kickoff to production</strong>.
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ─────────────────────────────────── EFFORT ─────────────────────────────────── -->
|
||
<section id="effort">
|
||
<h2>7. Effort Breakdown</h2>
|
||
|
||
<table class="effort-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Area</th>
|
||
<th>Est. days</th>
|
||
<th class="bar-cell">Relative effort</th>
|
||
<th>Main tasks</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>Data model & migration</td>
|
||
<td>10</td>
|
||
<td class="bar-cell"><div class="bar-wrap"><div class="bar" style="width:55%"></div></div></td>
|
||
<td>Schema definition, migration script, data validation</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Public site (Next.js)</td>
|
||
<td>15</td>
|
||
<td class="bar-cell"><div class="bar-wrap"><div class="bar" style="width:83%"></div></div></td>
|
||
<td>All public pages, file proxy, metadata, CSS port</td>
|
||
</tr>
|
||
<tr>
|
||
<td>File access control plugin</td>
|
||
<td>5</td>
|
||
<td class="bar-cell"><div class="bar-wrap"><div class="bar" style="width:28%"></div></div></td>
|
||
<td>Token generation, cookie auth, admin revoke view</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Student submission portal</td>
|
||
<td>18</td>
|
||
<td class="bar-cell"><div class="bar-wrap"><div class="bar" style="width:100%"></div></div></td>
|
||
<td>Share-link plugin, student form, file queue, email confirm</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Admin completions</td>
|
||
<td>14</td>
|
||
<td class="bar-cell"><div class="bar-wrap"><div class="bar" style="width:78%"></div></div></td>
|
||
<td>PeerTube plugin, exports, audit log, SMTP, file reorder UI</td>
|
||
</tr>
|
||
<tr>
|
||
<td>QA, security & cutover</td>
|
||
<td>13</td>
|
||
<td class="bar-cell"><div class="bar-wrap"><div class="bar" style="width:72%"></div></div></td>
|
||
<td>Security review, WCAG, performance, staging, production deploy</td>
|
||
</tr>
|
||
</tbody>
|
||
<tfoot>
|
||
<tr>
|
||
<td>Total</td>
|
||
<td>75 days (~15 weeks)</td>
|
||
<td class="bar-cell"></td>
|
||
<td>+ 3–4 weeks buffer = 18–19 weeks</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
|
||
<div class="callout warn" style="margin-top:1.25rem">
|
||
<div class="callout-title">Cost note</div>
|
||
Strapi Community Edition is free / open-source. Strapi Cloud (hosted) starts at $29/month.
|
||
For self-hosted (current VPS setup), <strong>no licence fee applies</strong>.
|
||
Next.js is MIT. The only paid dependency is a Vercel deployment (optional — self-hosted Node.js is free).
|
||
Total additional infrastructure cost: <strong>$0/month on existing VPS</strong>.
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ──────────────────────────────── INVOICE ─────────────────────────────────────── -->
|
||
<section id="invoice">
|
||
<h2>8. Invoice Example — Junior Solo Developer</h2>
|
||
|
||
<p>
|
||
The figures below illustrate what a realistic invoice from a junior freelance developer might look like
|
||
for this project. Assumptions: <strong>€350/day</strong> (junior solo rate, Western Europe),
|
||
75 billable days of work spread across 18–19 weeks, with a 10% contingency buffer added as a separate
|
||
line item. VAT at 21% (Belgian standard rate, adjust for your jurisdiction).
|
||
</p>
|
||
|
||
<div class="invoice">
|
||
<div class="invoice-header">
|
||
<div class="from">
|
||
<strong>Dev Freelance — Jane Doe</strong>
|
||
Rue de l'Exemple 12<br>
|
||
1000 Bruxelles<br>
|
||
TVA: BE 0123.456.789<br>
|
||
jane@example.dev
|
||
</div>
|
||
<div class="inv-meta">
|
||
<strong>FACTURE / INVOICE</strong>
|
||
N° 2026-042<br>
|
||
Date: 10 mai 2026<br>
|
||
Échéance: 10 juin 2026<br>
|
||
Réf client: ERG / XAMXAM
|
||
</div>
|
||
</div>
|
||
|
||
<div class="invoice-to">
|
||
<div class="label">Facturé à / Billed to</div>
|
||
<p>
|
||
<strong>École de Recherche Graphique (ERG)</strong><br>
|
||
Rue du Page 87, 1050 Bruxelles<br>
|
||
TVA: BE 0400.000.000
|
||
</p>
|
||
</div>
|
||
|
||
<table class="invoice-lines">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:45%">Description</th>
|
||
<th class="num" style="width:12%">Jours</th>
|
||
<th class="num" style="width:15%">Taux jour</th>
|
||
<th class="num" style="width:14%">Total HT</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr class="section-head"><td colspan="4">Phase 1 — Data model & migration</td></tr>
|
||
<tr>
|
||
<td>Définition des content-types Strapi (Thesis, Author, Supervisor, Tag, etc.) + relations et champs pivot</td>
|
||
<td class="num">5</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 750 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Script de migration SQLite → Strapi API + transfert des fichiers, validation des données</td>
|
||
<td class="num">5</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 750 €</td>
|
||
</tr>
|
||
|
||
<tr class="section-head"><td colspan="4">Phase 2 — Site public (Next.js)</td></tr>
|
||
<tr>
|
||
<td>Page d'accueil, filtres par année, pagination (24/page)</td>
|
||
<td class="num">3</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 050 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Page de recherche (filtres titre / auteur / mot-clé / année / orientation)</td>
|
||
<td class="num">3</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 050 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Page de détail TFE (métadonnées, jury, fichiers, OG tags)</td>
|
||
<td class="num">4</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 400 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Proxy de fichiers <code>/api/media/[…path]</code> + contrôle d'accès + port CSS</td>
|
||
<td class="num">5</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 750 €</td>
|
||
</tr>
|
||
|
||
<tr class="section-head"><td colspan="4">Phase 3 — Accès restreint aux fichiers</td></tr>
|
||
<tr>
|
||
<td>Plugin Strapi <code>file-access</code> (tokens, cookies, révocation admin)</td>
|
||
<td class="num">5</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 750 €</td>
|
||
</tr>
|
||
|
||
<tr class="section-head"><td colspan="4">Phase 4 — Portail étudiant (/partage)</td></tr>
|
||
<tr>
|
||
<td>Plugin Strapi <code>share-link</code> (génération slug, validation, routes submit)</td>
|
||
<td class="num">5</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 750 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Formulaire de dépôt étudiant (Next.js) — tous les champs, validation serveur</td>
|
||
<td class="num">6</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">2 100 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>File upload queue côté client (tri, suppression, validation MIME/taille)</td>
|
||
<td class="num">4</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 400 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Email de confirmation (nodemailer) + page de récapitulatif</td>
|
||
<td class="num">3</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 050 €</td>
|
||
</tr>
|
||
|
||
<tr class="section-head"><td colspan="4">Phase 5 — Completions admin</td></tr>
|
||
<tr>
|
||
<td>Plugin PeerTube (OAuth2, upload vidéo/audio, chiffrement credentials)</td>
|
||
<td class="num">4</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 400 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Export CSV + archive DB/fichiers</td>
|
||
<td class="num">2</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">700 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Audit log, SMTP chiffré, FormHelp, UI tri fichiers</td>
|
||
<td class="num">4</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 400 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Gestion share-links dans le panel admin (liste, toggle, archive, mot de passe)</td>
|
||
<td class="num">4</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 400 €</td>
|
||
</tr>
|
||
|
||
<tr class="section-head"><td colspan="4">Phase 6 — QA, sécurité & mise en production</td></tr>
|
||
<tr>
|
||
<td>Audit sécurité (headers, rate-limiting, CSRF, upload, path traversal)</td>
|
||
<td class="num">3</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 050 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Audit accessibilité WCAG 2.1 AA (site public + formulaire étudiant)</td>
|
||
<td class="num">2</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">700 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Configuration nginx, déploiement staging, tests d'acceptation, mise en production</td>
|
||
<td class="num">4</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 400 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Monitoring post-lancement (1 semaine), corrections mineures incluses</td>
|
||
<td class="num">4</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">1 400 €</td>
|
||
</tr>
|
||
|
||
<tr class="section-head"><td colspan="4">Réserve</td></tr>
|
||
<tr>
|
||
<td>Contingence 10 % — imprévus, retours client, ajustements de scope</td>
|
||
<td class="num">7.5</td>
|
||
<td class="num">350 €</td>
|
||
<td class="num">2 625 €</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<div class="invoice-totals">
|
||
<table>
|
||
<tr>
|
||
<td>Sous-total HT (75 jours × 350 €)</td>
|
||
<td>26 250 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Contingence 10 % (7.5 jours × 350 €)</td>
|
||
<td>2 625 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Total HT</td>
|
||
<td>28 875 €</td>
|
||
</tr>
|
||
<tr>
|
||
<td>TVA 21 %</td>
|
||
<td>6 063.75 €</td>
|
||
</tr>
|
||
<tr class="grand">
|
||
<td>TOTAL TTC</td>
|
||
<td>34 938.75 €</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="invoice-footer">
|
||
<strong>Conditions de paiement :</strong> 30 jours net. Paiement par virement bancaire.<br>
|
||
<strong>Jalons de facturation suggérés :</strong> 30 % à la signature — 30 % à la livraison de la phase 2 (site public) — 30 % à la livraison de la phase 4 (portail étudiant) — 10 % à la mise en production.<br>
|
||
<strong>Note :</strong> Ce devis est indicatif. Le tarif journalier d'un développeur junior en Belgique se situe typiquement entre <strong>300 € et 450 €/jour</strong>. Un profil senior (550–850 €/jour) réduirait la durée d'environ 20–30 %. Les licences logicielles (Strapi Community, Next.js, Node.js) sont gratuites. Aucun coût d'hébergement supplémentaire si le VPS existant est conservé.
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ──────────────────────────────── TRADE-OFFS ──────────────────────────────────── -->
|
||
<section id="tradeoffs">
|
||
<h2>9. Trade-offs vs. Current Custom Build</h2>
|
||
|
||
<div class="two-col">
|
||
<div class="card">
|
||
<h3 style="color:var(--ok)">What you gain</h3>
|
||
<ul>
|
||
<li><strong>No bespoke admin panel maintenance</strong> — Strapi generates the CRUD UI from schema; adding a field to a thesis takes 2 minutes in the GUI, not 30 lines of PHP</li>
|
||
<li><strong>Schema changes are safe</strong> — Strapi manages migrations automatically; no hand-written <code>.sql</code> files</li>
|
||
<li><strong>REST + GraphQL API out-of-the-box</strong> — a future mobile app, integration, or external catalogue can consume the API immediately</li>
|
||
<li><strong>Media Library UI</strong> — upload, rename, and organise files through a proper UI with thumbnails</li>
|
||
<li><strong>Role-based access control built-in</strong> — adding a new admin user takes seconds; no <code>htpasswd</code> management</li>
|
||
<li><strong>Community plugins</strong> for audit log, search, email — maintained by others</li>
|
||
<li><strong>Upgrade path to PostgreSQL</strong> — one config line; no schema rewrite</li>
|
||
<li><strong>TypeScript end-to-end</strong> — type safety from API response to rendered component</li>
|
||
</ul>
|
||
</div>
|
||
<div class="card">
|
||
<h3 style="color:var(--danger)">What you lose / accept</h3>
|
||
<ul>
|
||
<li><strong>Operational simplicity</strong> — two processes (Strapi + Next.js) instead of one PHP server; slightly more to monitor and deploy</li>
|
||
<li><strong>PHP familiarity</strong> — custom plugins are JavaScript/TypeScript; the team needs to learn (or hire for) Node.js</li>
|
||
<li><strong>Smaller codebase for simple tasks</strong> — a 20-line PHP controller becomes a Strapi plugin with boilerplate. The abstraction is a net win at scale, but adds friction for tiny changes</li>
|
||
<li><strong>Strapi CMS upgrades</strong> — major version upgrades (v4→v5 was breaking) require effort; the team is now on a CMS release cadence</li>
|
||
<li><strong>Node.js on the server</strong> — higher baseline memory (~150MB for Strapi) vs. PHP's per-request model (~20MB)</li>
|
||
<li><strong>HTMX hypermedia patterns lost</strong> — replaced by conventional React client-side interactivity; not a functional loss, but a philosophical one for the current codebase's author</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ──────────────────────────────── NOT INCLUDED ────────────────────────────────── -->
|
||
<section id="not-included">
|
||
<h2>10. What Is Not Included (Out of Scope)</h2>
|
||
|
||
<div class="callout warn">
|
||
<div class="callout-title">Intentionally deferred or dropped</div>
|
||
<ul style="margin-bottom:0">
|
||
<li><strong>LDAP integration</strong> — documented in <code>LDAP_SPEC.md</code> but not currently implemented; deferred to a post-launch phase. Strapi's Users & Permissions plugin supports LDAP via a third-party provider when ready.</li>
|
||
<li><strong>Redesign</strong> — this plan reproduces the current design and UX faithfully. No visual redesign is included.</li>
|
||
<li><strong>Multi-tenancy / multi-school</strong> — not a current requirement. Strapi supports this but it would be a separate project.</li>
|
||
<li><strong>Full-text search indexing (Elasticsearch / Meilisearch)</strong> — Strapi's built-in filter-based search is sufficient at ERG's dataset size (~hundreds of theses). Meilisearch integration can be added as a plugin later if needed.</li>
|
||
<li><strong>Video player / streaming</strong> — current app embeds PeerTube's existing player via URL; that behaviour is preserved. No custom video player is built.</li>
|
||
<li><strong>Strapi Cloud deployment</strong> — plan assumes self-hosted on the existing ERG VPS. Cloud hosting is a drop-in option at any point.</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<h3>Migration data quality risks</h3>
|
||
<div class="callout danger">
|
||
<div class="callout-title">Data migration risks to verify before Phase 1</div>
|
||
<ul style="margin-bottom:0">
|
||
<li>SQLite views (<code>v_theses_full</code>, <code>v_theses_public</code>) aggregate denormalised strings (comma-separated authors etc.) — the migration script must split these back into proper relations</li>
|
||
<li>Banner/cover path consolidation (<code>027_drop_banner_path.sql</code> pending) — resolve pending migrations before migrating data</li>
|
||
<li>Legacy artefacts in <code>oui/non</code> fields (<code>025_fix_oui_non_artefacts.sql</code> pending) — same</li>
|
||
<li>Encrypted SMTP password — must be decrypted during migration (requires the current <code>Crypto.php</code> key) and re-encrypted by Strapi lifecycle hook</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
</main>
|
||
</body>
|
||
</html>
|