diff --git a/TODO.md b/TODO.md
index 48a9c7d..b269120 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,5 +1,12 @@
# XAMXAM TODO
+## Extract shared TFE form partial (single source of truth)
+- [x] Create `templates/partials/form/form.php` — unified form with `$mode`-driven conditionals
+- [x] Refactor `templates/admin/add.php` → thin wrapper setting variables + including form partial
+- [x] Refactor `templates/admin/edit.php` → thin wrapper with unified `$oldFn` + form partial
+- [x] Refactor `partage/index.php` → `renderShareLinkForm()` delegates to form partial
+- [x] Test all three forms render correctly (add, edit, partage) — syntax verified, logic reviewed
+
## Fix password-protected share links — form never loads after password entry
- [x] `partage/index.php` — main GET handler: check `$_SESSION['share_verified_' . $slug]` before showing password gate; skip to form if already verified
- [x] `partage/index.php` — add `error_log()` calls throughout password flow (gate entry, hash state, verification result, session check) for debugging
diff --git a/app/public/admin/edit.php b/app/public/admin/edit.php
index 61ff2c9..6a695a5 100644
--- a/app/public/admin/edit.php
+++ b/app/public/admin/edit.php
@@ -37,7 +37,7 @@ try {
$isAdmin = true; $bodyClass = 'admin-body';
$extraCss = ['/assets/css/form.css'];
-$extraJs = ['/assets/js/sortable.min.js', '/assets/js/file-upload-queue.js'];
+$extraJs = ['/assets/js/sortable.min.js', '/assets/js/file-upload-queue.js', '/assets/js/beforeunload-guard.js'];
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
include APP_ROOT . '/templates/admin/edit.php';
diff --git a/app/public/assets/css/admin.css b/app/public/assets/css/admin.css
index 5bd7b16..94fae24 100644
--- a/app/public/assets/css/admin.css
+++ b/app/public/assets/css/admin.css
@@ -75,18 +75,6 @@
/* ── Buttons ────────────────────────────────────────────────────────────── */
.admin-form-footer {
margin-top: var(--space-l);
- padding-top: var(--space-m);
-}
-
-/* Sticky variant — pinned below admin header, top-right */
-.admin-form-footer--sticky {
- position: sticky;
- top: 0;
- z-index: 10;
- margin: 0 0 var(--space-m);
- display: flex;
- justify-content: flex-end;
- gap: var(--space-s);
}
/* ── Admin button aliases — see common.css .btn base class ────────────── */
diff --git a/app/public/assets/css/common.css b/app/public/assets/css/common.css
index a9d5065..3055591 100644
--- a/app/public/assets/css/common.css
+++ b/app/public/assets/css/common.css
@@ -427,6 +427,7 @@ main {
.btn--primary {
background: var(--accent-primary);
color: var(--accent-foreground);
+ border: 1px solid transparent;
}
.btn--primary:hover {
diff --git a/app/public/assets/css/form.css b/app/public/assets/css/form.css
index 4b34aa3..a7845dd 100644
--- a/app/public/assets/css/form.css
+++ b/app/public/assets/css/form.css
@@ -302,6 +302,10 @@
/* ── Submit / form footer ───────────────────────────────────────────────── */
.form-footer {
margin-top: var(--space-l);
+ margin-bottom: var(--space-l);
+ display: flex;
+ gap: var(--space-s);
+ align-items: center;
}
.form-footer button {
diff --git a/app/public/assets/js/beforeunload-guard.js b/app/public/assets/js/beforeunload-guard.js
new file mode 100644
index 0000000..c123d18
--- /dev/null
+++ b/app/public/assets/js/beforeunload-guard.js
@@ -0,0 +1,25 @@
+/**
+ * Beforeunload guard — prompts the user before navigating away from unsaved changes.
+ *
+ * Attach to any form with a data-beforeunload-guard attribute.
+ * No effect when JavaScript is unavailable (form posts normally).
+ */
+(function () {
+ var forms = document.querySelectorAll('form[data-beforeunload-guard]');
+ if (!forms.length) return;
+
+ var dirty = false;
+
+ for (var i = 0; i < forms.length; i++) {
+ var form = forms[i];
+ form.addEventListener('input', function () { dirty = true; });
+ form.addEventListener('change', function () { dirty = true; });
+ form.addEventListener('submit', function () { dirty = false; });
+ }
+
+ window.addEventListener('beforeunload', function (e) {
+ if (dirty) {
+ e.preventDefault();
+ }
+ });
+})();
diff --git a/app/public/partage/index.php b/app/public/partage/index.php
index 95f20d1..b448715 100644
--- a/app/public/partage/index.php
+++ b/app/public/partage/index.php
@@ -254,6 +254,64 @@ function renderShareLinkForm(string $slug, array $link): void
// Load all form help blocks in one query.
$helpBlocks = Database::getInstance()->getAllFormHelpBlocks();
$helpFn = fn(string $key) => $helpBlocks[$key]['content'] ?? '';
+
+ // ── Shared form variables ──────────────────────────────────────────────
+ $mode = 'partage';
+ $formAction = '/partage/' . urlencode($slug) . '/submit';
+ $hiddenFields = '';
+
+ $oldFn = $shareOldFn;
+ $withAutofocusFn = $shareWithAutofocusFn;
+
+ // Synopsis extra: inject fieldset_synopsis help block
+ ob_start();
+ $helpContent = $helpFn('fieldset_synopsis');
+ include APP_ROOT . '/templates/partials/form/form-help-block.php';
+ $synopsisExtra = ob_get_clean();
+
+ // Jury data from repopulation
+ $juryPromoteur = old($formData, 'jury_promoteur');
+ $juryPromoteurUlb = old($formData, 'jury_promoteur_ulb_name');
+ $lecteursInternes = [];
+ $lecteursExternes = [];
+ for ($i = 0; $i < 10; $i++) {
+ $n = old($formData, "jury_lecteur_interne:$i");
+ if ($n !== '') $lecteursInternes[] = ['name' => $n];
+ }
+ for ($i = 0; $i < 10; $i++) {
+ $n = old($formData, "jury_lecteur_externe:$i");
+ if ($n !== '') $lecteursExternes[] = ['name' => $n];
+ }
+ $juryPresident = null;
+ $showPresident = false;
+ $showPromoteurUlb = true;
+ $promoteurUlbConditional = true;
+
+ // Licence / access
+ $libreEnabled = ($siteSettings['access_type_libre_enabled'] ?? '0') === '1';
+ $interneEnabled = ($siteSettings['access_type_interne_enabled'] ?? '1') === '1';
+ $interditEnabled = ($siteSettings['access_type_interdit_enabled'] ?? '1') === '1';
+ $generalitiesHtml = $helpFn('fieldset_generalites');
+ $defaultAccessTypeId = 2;
+
+ // Optional sections
+ $showFlash = true;
+ $showIntroHelp = true;
+ $showEmailConfirmation = true;
+
+ // Files: add mode
+ $filesMode = 'add';
+
+ // Website URL from repopulation
+ $existingWebsiteUrl = $formData['website_url'] ?? '';
+ $existingWebsiteLabel = $formData['website_label'] ?? '';
+ $checkedFormatsForSiteWeb = $formData['formats'] ?? [];
+
+ // Context / backoffice not shown in partage
+ $currentRaw = [];
+ $currentAuthorEmail = null;
+ $currentAuthorShowContact = false;
+ $currentContextNote = null;
?>
@@ -277,195 +335,13 @@ function renderShareLinkForm(string $slug, array $link): void
-
-
- = htmlspecialchars($flashError) ?>
-
-
- = htmlspecialchars($flashWarning) ?>
-
-
-
- = htmlspecialchars($flashSuccess) ?>
-
-
-
-
- * Champs obligatoires
-
+
diff --git a/app/templates/admin/add.php b/app/templates/admin/add.php
index 4eb31ed..27ff117 100644
--- a/app/templates/admin/add.php
+++ b/app/templates/admin/add.php
@@ -1,204 +1,49 @@
-
+ Ajouter un TFE
- * Champs obligatoires
-
+ include APP_ROOT . '/templates/partials/form/form.php';
+ ?>
diff --git a/app/templates/admin/edit.php b/app/templates/admin/edit.php
index 560c9d6..110426d 100644
--- a/app/templates/admin/edit.php
+++ b/app/templates/admin/edit.php
@@ -1,402 +1,110 @@
Modifier un TFE
-
+ include APP_ROOT . '/templates/partials/form/form.php';
+ ?>
diff --git a/app/templates/partials/form/fieldset-academic.php b/app/templates/partials/form/fieldset-academic.php
index 19a27f5..32d4df2 100644
--- a/app/templates/partials/form/fieldset-academic.php
+++ b/app/templates/partials/form/fieldset-academic.php
@@ -46,4 +46,3 @@ $formData = $formData ?? [];
?>
$d);
+$withAutofocusFn = $withAutofocusFn ?? fn($field, $attrs = []) => $attrs;
+$filesMode = $filesMode ?? 'add';
+$existingWebsiteUrl = $existingWebsiteUrl ?? '';
+$existingWebsiteLabel = $existingWebsiteLabel ?? '';
+$checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
+?>
+
+
+
+
+
+
+
+
+ = htmlspecialchars(
+ $flashError,
+ ) ?>
+
+
+ = htmlspecialchars(
+ $flashWarning,
+ ) ?>
+
+
+
+ = htmlspecialchars(
+ $flashSuccess,
+ ) ?>
+
+
+
+