refactor: combined duration (pages + minutes), has_annexes checkbox, remove Mo

- Remove Mo option from duration, keep only pages and minutes
- Redesign duration fieldset: separate Pages input + Durée h:m inputs, both can be set together
- Fix minutes input visibility: wider inputs (6ch), proper CSS layout
- Add has_annexes checkbox to fichiers fragment + DB column + controllers
- Display duration on admin backoffice recap page
- Display duration on public partage recap page
- Update public TFE page for new combined duration format
- Migration 041: add duration_pages, duration_minutes, has_annexes columns; migrate data; recreate views
This commit is contained in:
Pontoporeia
2026-07-03 15:58:10 +02:00
parent 42c4e78df6
commit 4fa57d592a
20 changed files with 335 additions and 173 deletions
+7 -4
View File
@@ -45,6 +45,9 @@ $formData = array_merge($formData, [
'lien' => $thesis['baiu_link'] ?? '',
'contact_public' => $contactPublic ?? false,
'contact_interne' => $contactInterne ?? '',
'duration_pages' => $currentRaw['duration_pages'] ?? null,
'duration_minutes' => $currentRaw['duration_minutes'] ?? null,
'has_annexes' => $currentRaw['has_annexes'] ?? false,
]);
// Build jury arrays
@@ -144,8 +147,7 @@ extract(FormBootstrap::adminFormVariables(
'currentFiles' => $currentFiles ?? [],
'currentContextNote' => $currentContextNote ?? null,
'currentContactVisible' => $currentContactVisible ?? null,
'currentDurationValue' => $currentDurationValue ?? null,
'currentDurationUnit' => $currentDurationUnit ?? 'pages',
'contactInterne' => $contactInterne ?? null,
'contactPublic' => $contactPublic ?? false,
'showCoverPreview' => true,
@@ -161,8 +163,9 @@ $formData['license_custom'] = $currentRaw['license_custom'] ?? '';
$formData['cc2r'] = $currentRaw['cc2r'] ?? false;
// Duration variables for the form template
$durationValue = $currentDurationValue ?? null;
$durationUnit = $currentDurationUnit ?? 'pages';
$durationPages = $currentRaw['duration_pages'] ?? null;
$durationMins = $currentRaw['duration_minutes'] ?? null;
$hasAnnexes = $currentRaw['has_annexes'] ?? false;
// Asset arrays and page chrome
$isAdmin = true;
+4
View File
@@ -61,6 +61,10 @@ if ($thesisId) {
$existingFilesJsonForTfe = $buildQueueFilesJson($currentFiles, 'tfe');
$existingFilesJsonForAnnexe = $buildQueueFilesJson($currentFiles, 'annexe');
// Load has_annexes flag for the checkbox
$rawRow = $db->getThesisRawFields($thesisId);
$_POST['has_annexes'] = !empty($rawRow['has_annexes']) ? '1' : null;
$_POST['edit_mode'] = '1';
}
+23 -10
View File
@@ -2160,18 +2160,36 @@ th.admin-ap-col {
100% { transform: scaleX(0); transform-origin: right; }
}
/* ── Sidebar TOC (matches public .page-content alignment pattern) ────────── */
/* ── Sidebar TOC (matches public .page-content grid layout) ────────────── */
.admin-main--toc {
display: flex;
display: grid;
grid-template-columns: 180px 1fr;
gap: var(--space-2xl);
align-items: start;
padding: var(--space-xl) var(--space-m) var(--space-2xl);
}
.admin-main--toc > article {
flex: 1;
min-width: 0;
/* Desktop: article in second column */
@media (min-width: 768px) {
.admin-main--toc > article {
grid-column: 2;
min-width: 0;
max-width: 100%;
}
}
/* Mobile: single column */
@media (max-width: 767px) {
.admin-main--toc {
grid-template-columns: 1fr;
gap: var(--space-l);
padding: var(--space-m) var(--space-s) var(--space-xl);
}
.admin-main--toc > article {
grid-column: 1;
}
}
/* Align first child of article with TOC heading (same as public) */
@@ -2188,11 +2206,6 @@ th.admin-ap-col {
}
/* Admin TOC: same <details class="toc"> as public pages, positioned sticky */
#admin-toc {
width: 180px;
flex-shrink: 0;
}
#admin-toc .toc-list a {
font-size: var(--step--1);
}
+10 -1
View File
@@ -352,7 +352,11 @@
border-radius: var(--radius);
}
.duration-time-inputs {
.duration-field {
margin-bottom: var(--space-xs);
}
.duration-time-field {
display: flex;
align-items: flex-end;
gap: var(--space-xs);
@@ -364,6 +368,11 @@
gap: var(--space-3xs);
}
.duration-time-fields input[type="number"] {
width: 6ch;
min-width: 6ch;
}
.duration-time-fields span {
font-size: var(--step--1);
color: var(--text-secondary);
+51 -9
View File
@@ -45,21 +45,63 @@
items.push({ section: sec, link: a });
});
// Generate a spread of thresholds for smooth IntersectionObserver firing.
function buildThresholds() {
var t = [];
for (var i = 0; i <= 20; i++) t.push(i / 20);
return t;
}
var observer = new IntersectionObserver(
(entries) => {
var best = null,
bestRatio = 0;
entries.forEach((e) => {
if (e.intersectionRatio > bestRatio) {
bestRatio = e.intersectionRatio;
best = e.target;
() => {
// Active section = whose top is closest to the midpoint of the
// viewport from above. At page bottom, the last section wins
// even if its heading can't reach 50%.
var line = window.innerHeight * 0.5;
var best = null;
var bestDist = Infinity;
// Pass 1: sections whose top is above/at the line
items.forEach((item) => {
var top = item.section.getBoundingClientRect().top;
if (top <= line && (line - top) < bestDist) {
bestDist = line - top;
best = item;
}
});
// Pass 2: no section above the line — pick closest from below
if (!best) {
items.forEach((item) => {
var top = item.section.getBoundingClientRect().top;
var dist = top - line;
if (dist >= 0 && dist < bestDist) {
bestDist = dist;
best = item;
}
});
}
// Pass 3: at page bottom, if the last section is visible but
// its heading never crossed 50%, promote it anyway.
if (best) {
var lastItem = items[items.length - 1];
if (lastItem !== best) {
var lastTop = lastItem.section.getBoundingClientRect().top;
var atBottom = (window.innerHeight + window.scrollY + 5)
>= document.documentElement.scrollHeight;
if (atBottom && lastTop > line && lastTop < window.innerHeight) {
best = lastItem;
}
}
}
if (!best && items.length > 0) best = items[0];
items.forEach((item) => {
item.link.classList.toggle('toc-active', item.section === best);
item.link.classList.toggle('toc-active', item === best);
});
},
{ rootMargin: '-10% 0px -70% 0px', threshold: [0, 0.25, 0.5, 0.75, 1] }
{ threshold: buildThresholds() }
);
items.forEach((item) => {
@@ -1,51 +1,26 @@
/**
* form-duration-toggle.js — Duration unit toggle on TFE form.
* form-duration-toggle.js — Combined duration helper for TFE form.
*
* Switches between integer input (pages/mo) and h/m/s time inputs (durée).
* Updates hidden #duration_value on change.
* Computes duration_minutes from h + m fields on form submit so the server
* receives a single integer (total minutes).
*/
(() => {
var unit = document.getElementById('duration_unit');
var hidden = document.getElementById('duration_value');
var intWrap = document.getElementById('duration-value-integer');
var intInput = document.getElementById('duration_value_int');
var intLabel = document.getElementById('duration-value-label');
var timeWrap = document.getElementById('duration-value-time');
var hInput = document.getElementById('duration_h');
var mInput = document.getElementById('duration_m');
var sInput = document.getElementById('duration_s');
var form = document.querySelector('form.admin-form');
if (!form) return;
var LABELS = { pages: 'Nombre :', mo: 'Taille :', durée: 'Durée :' };
if (!unit || !hidden) return;
form.addEventListener('submit', function() {
var h = parseInt(document.getElementById('duration_h')?.value, 10) || 0;
var m = parseInt(document.getElementById('duration_m')?.value, 10) || 0;
var total = h * 60 + m;
function updateHidden() {
if (unit.value === 'durée') {
var h = parseInt(hInput.value, 10) || 0;
var m = parseInt(mInput.value, 10) || 0;
var s = parseInt(sInput.value, 10) || 0;
var total = h + m / 60 + s / 3600;
hidden.value = total > 0 ? total.toFixed(6) : '';
} else {
hidden.value = intInput.value;
var hidden = document.getElementById('duration_minutes_hidden');
if (!hidden) {
hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'duration_minutes';
hidden.id = 'duration_minutes_hidden';
form.appendChild(hidden);
}
}
function toggleFields() {
if (unit.value === 'durée') {
intWrap.style.display = 'none';
timeWrap.style.display = '';
} else {
timeWrap.style.display = 'none';
intWrap.style.display = '';
if (intLabel) intLabel.textContent = LABELS[unit.value] || 'Valeur :';
}
updateHidden();
}
unit.addEventListener('change', toggleFields);
if (intInput) intInput.addEventListener('input', updateHidden);
if (hInput) hInput.addEventListener('input', updateHidden);
if (mInput) mInput.addEventListener('input', updateHidden);
if (sInput) sInput.addEventListener('input', updateHidden);
toggleFields();
hidden.value = total > 0 ? String(total) : '';
});
})();
+16
View File
@@ -137,6 +137,22 @@ $pageTitle = 'Merci — TFE enregistré';
<dd><?= htmlspecialchars($thesis['keywords']) ?></dd>
<?php endif; ?>
<?php
$_hasPages2 = isset($thesis['duration_pages']) && $thesis['duration_pages'] !== null;
$_hasMins2 = isset($thesis['duration_minutes']) && $thesis['duration_minutes'] !== null;
if ($_hasPages2 || $_hasMins2):
$_parts2 = [];
if ($_hasPages2) $_parts2[] = $thesis['duration_pages'] . ' pages';
if ($_hasMins2) {
$_h2 = (int)floor($thesis['duration_minutes'] / 60);
$_m2 = $thesis['duration_minutes'] % 60;
$_parts2[] = $_h2 . 'h' . str_pad((string)$_m2, 2, '0', STR_PAD_LEFT);
}
?>
<dt>Durée</dt>
<dd><?= htmlspecialchars(implode(', ', $_parts2)) ?></dd>
<?php unset($_hasPages2, $_hasMins2, $_parts2, $_h2, $_m2); endif; ?>
<?php if (!empty($thesis['jury_promoteurs'])): ?>
<dt>Promoteur·ice(s) interne</dt>
<dd><?= htmlspecialchars($thesis['jury_promoteurs']) ?></dd>