mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
Refactor apropos/charte/licence pages: shared layout, TOC anchors, and UI polish
Unify the three public pages (à propos, charte, licence) onto a single grid layout (.page-content) with sticky TOC sidebar, replacing the old separate / / markup. - Merge about.php, charte.php, licence.php templates into shared .page-content / .content-section structure - Add CommonMark HeadingPermalinkExtension for stable heading anchors - Use SlugNormalizer for TOC links so they match rendered heading IDs - Standardize link styling across content blocks: bold black, accent on hover (consistent with global link style) - Fix code block wrapping: use pre-wrap instead of pre, constrain grid columns with min-width:0, auto scrollbar - Fix apropos page grid placement: force content-section into column 2 so contacts and credits stay in the content area, not the sidebar Also includes accumulated WIP changes: - Header gradient: hardcoded purple-to-green (replaces CSS variables) - Search placeholder font - Duration field: replace minutes/sec/heures with h:m:s time inputs - TFE file optional for formats 1,4,6 with client-side JS toggle - Licence form: em-dash to hyphen, details/summary classes - Pill search: block Enter key form submission when no results - Draft autosave: remove CSRF rotation (broke concurrent FilePond uploads) - Language pill: clear hints for excluded main languages - Search results: gradient placeholder cards for items without covers - TFE display: format durée values as XhYm instead of decimal
This commit is contained in:
File diff suppressed because one or more lines are too long
22
TODO.md
22
TODO.md
@@ -1,19 +1,33 @@
|
||||
# TODO
|
||||
|
||||
> Last updated: 2026-06-15
|
||||
> Context: Multiple fixes for upload flow: CSRF staleness, adminOld return type, PHP upload limits, FormData crash, soft-deleted languages
|
||||
> Last updated: 2026-06-19
|
||||
> Context: Analyse OverType editors on /admin/contenus-edit.php: concurrency safety, save reliability, content truncation bugs
|
||||
|
||||
## In Progress
|
||||
- [ ] #overtype-analysis Analyse and fix OverType editor reliability on contenus-edit.php
|
||||
|
||||
## Pending
|
||||
|
||||
- [ ] #apropos-toc-confirm Visually confirm charte + licence TOC layout renders correctly in browser
|
||||
- [x] #tfe-optional-formats Make TFE file optional when format is Site web (1), Performance (4) or Installation (6) — fixed incorrect format IDs [3→1,4,6] + added client-side JS toggle for TFE required/asterisk. Note d'intention remains required. 🎯 `(fichiers-fragment.php, file-upload-filepond.js)` ✓
|
||||
- [x] #typography-weight-300 Set search placeholder + apropos/charte/licence <p> content to BBBDMSans weight 300 `(search.css, apropos.css)` ✓
|
||||
- [x] #toc-parts-uppercase Hardcode "PARTIES" uppercase + black bottom border on TOC label `(about.php, charte.php, licence.php, apropos.css)` ✓
|
||||
- [x] #apropos-overflow Prevent #apropos-intro and content-section children from overflowing `(apropos.css)` ✓
|
||||
- [x] #toc-navigation Fix TOC links not navigating to headings — enable `heading_permalink` extension in CommonMark with `id_prefix: ''`, `insert: 'before'`, `aria_hidden: true` + register extension on environment; use CommonMark's SlugNormalizer in extractToc; hide permalink anchors with CSS; add `min-width: 0` to `.content` to prevent grid overflow `(CharteController.php, LicenceController.php, MarkdownHelper.php, apropos.css)` ✓
|
||||
|
||||
- [x] #apropos-toc-style Fix TOC "Parties" label: Ductus font + lowercase, remove border-left from links, match global link style; rename .apropos-content → section.content, .apropos-section → .content-section, remove .prose wrapper `(apropos.css, about.php, charte.php, licence.php)` ✓
|
||||
|
||||
- [ ] #apropos-toc-confirm Visually confirm charte + licence TOC layout renders correctly in browser (dup after #apropos-toc-style)
|
||||
- [ ] #aria-test-manual Test WCAG changes with VoiceOver and NVDA on full add/edit/partage form flows
|
||||
- [ ] #nojs-upload-test Test end-to-end: submit partage form with JS disabled, verify files arrive via `$_FILES`
|
||||
- [ ] #csp-media-iframe-deploy Deploy nginx config fix to server, test PDF iframe on /tfe?id=221
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] #filepond-csrf-stale Fix FilePond CSRF token going stale when autosave rotates it `(file-upload-filepond.js)` ✓
|
||||
- [x] #csrf-rotation-race Stop CSRF token rotation in draft.php + remove hx-post from <form> — both broke FilePond uploads and form submission `(admin/actions/draft.php, partage/fragments/draft.php, FormBootstrap.php, pill-search.js)` ✓
|
||||
- [x] ~~#filepond-csrf-stale~~ (superseded by #csrf-rotation-race)
|
||||
- [x] #adminold-return-type Fix adminOld closure return type from `:string` to `:string|array` `(FormBootstrap.php)` ✓
|
||||
- [x] #duration-integer-units Make duration field: integer for pages/Mo, dedicated h/m/s time inputs `(form.php, ThesisCreateController.php, tfe.php, form-base.css)` ✓
|
||||
- [x] #licence-svg-fix Fix licence details/summary SVG: width 1rem, inline-flex layout `(fieldset-licence-explanation.php, form-base.css)` ✓
|
||||
- [x] #restore-languages Un-soft-delete anglais (id=2) and néerlandais (id=71) in dev DB ✓
|
||||
- [x] #php-upload-limits Increase PHP upload_max_filesize to 8G, post_max_size to 8.5G `(.user.ini)` ✓
|
||||
- [x] #formdata-fieldset-crash Remove leftover debug console.log that called new FormData(fieldset) `(admin/footer.php)` ✓
|
||||
|
||||
@@ -81,13 +81,17 @@ foreach ($_POST as $key => $value) {
|
||||
|
||||
$_SESSION[$draftKey] = $draft;
|
||||
|
||||
// Rotate CSRF after mutation
|
||||
$newToken = bin2hex(random_bytes(32));
|
||||
$_SESSION['csrf_token'] = $newToken;
|
||||
// NOTE: Do NOT rotate the CSRF token here.
|
||||
// Rotating it breaks concurrent requests:
|
||||
// 1. FilePond uploads in flight use the old token (from <meta name="csrf-token">)
|
||||
// and fail when the server session already has the new token.
|
||||
// 2. Overlapping autosave requests hit CSRF mismatch.
|
||||
// 3. HTMX fragment requests (pill-search, language-autre) can't use the old token.
|
||||
// The CSRF token already rotates on page load and form submit — that's sufficient.
|
||||
// Autosave is a background persistence mechanism and does not need token rotation.
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'csrf_token' => $newToken,
|
||||
]);
|
||||
exit;
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
/* ============================================================
|
||||
À PROPOS PAGE (apropos.php)
|
||||
Root class: .apropos-main
|
||||
À PROPOS / CHARTE / LICENCE pages
|
||||
Root class: .page-content
|
||||
============================================================ */
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Page shell */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.apropos-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-xl) var(--space-l) var(--space-2xl);
|
||||
/* Override body overflow:hidden — these pages use the viewport scrollbar
|
||||
so that anchor navigation (#fragment) works natively. */
|
||||
.apropos-body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Two-column layout: sticky TOC nav | content */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.apropos-layout {
|
||||
.page-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
scroll-behavior: smooth;
|
||||
padding: var(--space-xl) var(--space-l) var(--space-2xl);
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: var(--space-2xl);
|
||||
width: 100%;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -36,13 +30,13 @@
|
||||
}
|
||||
|
||||
.apropos-toc-label {
|
||||
font-family: var(--font-body);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step--2);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-2xs) 0;
|
||||
padding-bottom: var(--space-2xs);
|
||||
border-bottom: 1px solid var(--text-primary);
|
||||
}
|
||||
|
||||
.apropos-toc ul {
|
||||
@@ -62,13 +56,10 @@
|
||||
display: block;
|
||||
padding: var(--space-3xs) 0;
|
||||
transition: color 0.15s;
|
||||
border-left: 2px solid transparent;
|
||||
padding-left: var(--space-2xs);
|
||||
}
|
||||
|
||||
.apropos-toc ul a:hover {
|
||||
color: var(--accent-primary);
|
||||
border-left-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.apropos-toc-link:first-of-type {
|
||||
@@ -96,19 +87,115 @@
|
||||
/* Right — main content area */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.apropos-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
.page-content > .content,
|
||||
.page-content > .content-section {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.apropos-section {
|
||||
/* Shared typography for about-page sections and charte/licence content */
|
||||
.content,
|
||||
.content-section {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.content *,
|
||||
.content-section * {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.content p,
|
||||
.content-section p {
|
||||
margin: 0 0 1em 0;
|
||||
}
|
||||
|
||||
.content p:last-child,
|
||||
.content-section p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.content :where(h1, h2, h3),
|
||||
.content-section :where(h1, h2, h3) {
|
||||
margin: 1.5em 0 0.5em 0;
|
||||
}
|
||||
|
||||
.content a,
|
||||
.content-section a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content a:hover,
|
||||
.content-section a:hover {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.content ul,
|
||||
.content ol,
|
||||
.content-section ul,
|
||||
.content-section ol {
|
||||
padding-left: var(--space-m);
|
||||
margin-bottom: var(--space-s);
|
||||
}
|
||||
|
||||
.content li,
|
||||
.content-section li {
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.content strong,
|
||||
.content-section strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content em,
|
||||
.content-section em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.content :where(pre, pre code, code),
|
||||
.content-section :where(pre, pre code, code) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 0.88em;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0.5em 0.75em;
|
||||
border-radius: var(--radius);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
#apropos-intro *,
|
||||
#apropos-contacts *,
|
||||
#apropos-credits * {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Section separators (about page only — .content-section adds dividers) */
|
||||
.page-content > .content-section {
|
||||
padding-bottom: var(--space-xl);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.apropos-section:last-child {
|
||||
.page-content > .content-section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
@@ -118,7 +205,7 @@
|
||||
/* Section titles */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.apropos-section-title {
|
||||
.content-section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-3);
|
||||
font-weight: 400;
|
||||
@@ -127,59 +214,9 @@
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Intro prose — Markdown-rendered content */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.prose {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin: 0 0 1em 0;
|
||||
}
|
||||
|
||||
.prose p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose :where(h1, h2, h3) {
|
||||
margin: 1.5em 0 0.5em 0;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
padding-left: var(--space-m);
|
||||
margin-bottom: var(--space-s);
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.prose strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
.prose em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 0.88em;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: var(--radius);
|
||||
/* Hide CommonMark heading permalink anchors (needed only for their id attr) */
|
||||
.heading-permalink {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -220,15 +257,15 @@
|
||||
|
||||
.apropos-contact-card a {
|
||||
font-size: var(--step--1);
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: opacity 0.15s;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.apropos-contact-card a:hover {
|
||||
color: var(--accent-primary);
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -284,7 +321,7 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.apropos-layout {
|
||||
.page-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-l);
|
||||
}
|
||||
@@ -309,32 +346,27 @@
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.apropos-toc ul a {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.apropos-toc-link {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.prose {
|
||||
.content-section {
|
||||
font-size: var(--step-0);
|
||||
}
|
||||
|
||||
.apropos-section-title {
|
||||
.content-section-title {
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.apropos-main {
|
||||
.page-content {
|
||||
padding: var(--space-m) var(--space-s) var(--space-xl);
|
||||
}
|
||||
|
||||
.prose {
|
||||
.content-section {
|
||||
font-size: var(--step-0);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,10 +38,10 @@
|
||||
--accent-red: #f25a5a;
|
||||
|
||||
/* Gradient (header) */
|
||||
--gradient-1: #3c856c;
|
||||
--gradient-2: #60ecb4;
|
||||
--gradient-3: #e390ff;
|
||||
--gradient-4: #9557b5;
|
||||
--gradient-1: #42963f;
|
||||
--gradient-2: #65e478;
|
||||
--gradient-3: #57abc7;
|
||||
--gradient-4: #db53ed;
|
||||
|
||||
/* Header decorative */
|
||||
--header-gradient-fade: rgba(149, 87, 181, 0);
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
header {
|
||||
vertical-align: center;
|
||||
flex-shrink: 0;
|
||||
background: #9557B5;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--gradient-1) 0%,
|
||||
var(--gradient-2) 33%,
|
||||
var(--gradient-3) 66%,
|
||||
var(--gradient-4) 100%
|
||||
0deg,
|
||||
rgba(149, 87, 181, 1) 0%,
|
||||
rgba(192, 93, 225, 1) 25%,
|
||||
rgba(51, 191, 135, 1) 75%,
|
||||
rgba(60, 133, 108, 1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +61,6 @@ header nav ul a[aria-current="page"] {
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
|
||||
/* ── Logo ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.nav-logo {
|
||||
@@ -130,8 +130,12 @@ header nav ul a[aria-current="page"] {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.navicon::before { top: -7px; }
|
||||
.navicon::after { bottom: -7px; }
|
||||
.navicon::before {
|
||||
top: -7px;
|
||||
}
|
||||
.navicon::after {
|
||||
bottom: -7px;
|
||||
}
|
||||
|
||||
/* ── Mobile (≤ 640px) ───────────────────────────────────────────────── */
|
||||
|
||||
@@ -160,11 +164,14 @@ header nav ul a[aria-current="page"] {
|
||||
}
|
||||
|
||||
header nav[aria-label="Navigation principale"]
|
||||
.nav-left-links li:not(:first-child) {
|
||||
.nav-left-links
|
||||
li:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-icon { display: flex; }
|
||||
.menu-icon {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
header nav[aria-label="Navigation principale"] .nav-mobile-links {
|
||||
display: block;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
.header-search-wrap {
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(180deg, var(--gradient-4) 0%, #ffffffee 100%);
|
||||
background: linear-gradient(180deg, #9557B5 0%, #ffffffee 100%);
|
||||
}
|
||||
|
||||
.header-search-form { width: 100%; }
|
||||
@@ -40,4 +40,6 @@
|
||||
|
||||
.header-search-input-wrap input::placeholder {
|
||||
color: var(--accent-primary) !important;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@@ -352,6 +352,34 @@
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.duration-time-inputs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.duration-time-fields {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3xs);
|
||||
}
|
||||
|
||||
.duration-time-fields span {
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.licence-details {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.licence-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.licence-degree h4 {
|
||||
margin: 0 0 var(--space-2xs);
|
||||
font-weight: 600;
|
||||
|
||||
@@ -111,8 +111,8 @@
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(60, 133, 108, 1) 0%,
|
||||
rgba(96, 236, 180, 1) 33%,
|
||||
rgba(227, 144, 255, 1) 66%,
|
||||
rgba(51, 191, 135, 1) 25%,
|
||||
rgba(192, 93, 225, 1) 75%,
|
||||
rgba(149, 87, 181, 1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,6 +223,56 @@
|
||||
gap: var(--space-3xs);
|
||||
}
|
||||
|
||||
.result-card__cover {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.result-card__cover img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 7px 7px 0 0;
|
||||
}
|
||||
|
||||
.result-card__gradient {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-s);
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
border-radius: 7px 7px 0 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(60, 133, 108, 1) 0%,
|
||||
rgba(51, 191, 135, 1) 25%,
|
||||
rgba(192, 93, 225, 1) 75%,
|
||||
rgba(149, 87, 181, 1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.result-card__gradient-author {
|
||||
color: var(--accent-foreground);
|
||||
font-size: var(--step--2);
|
||||
opacity: 0.85;
|
||||
margin-bottom: 0.25rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.result-card__gradient-title {
|
||||
color: var(--accent-foreground);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-card__authors {
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
@@ -391,8 +441,8 @@
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(60, 133, 108, 1) 0%,
|
||||
rgba(96, 236, 180, 1) 33%,
|
||||
rgba(227, 144, 255, 1) 66%,
|
||||
rgba(51, 191, 135, 1) 25%,
|
||||
rgba(192, 93, 225, 1) 75%,
|
||||
rgba(149, 87, 181, 1) 100%
|
||||
);
|
||||
display: flex;
|
||||
|
||||
@@ -643,6 +643,7 @@
|
||||
enableFilepondMode();
|
||||
_xamxamFilepondReady = false;
|
||||
window.XamxamInitFilePonds();
|
||||
if (window.XamxamUpdateTfeRequired) window.XamxamUpdateTfeRequired();
|
||||
setTimeout(() => {
|
||||
_xamxamFilepondReady = true;
|
||||
}, 0);
|
||||
@@ -694,6 +695,68 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── TFE file optional when Site web (1), Performance (4) or Installation (6) ──
|
||||
// The format checkboxes no longer trigger HTMX swaps; this JS toggles the TFE
|
||||
// required attribute and asterisk client-side so the student sees immediate feedback.
|
||||
// admin_mode hidden input (value="1") suppresses required toggling for admins.
|
||||
(function () {
|
||||
var optionalFormatIds = ["1", "4", "6"];
|
||||
|
||||
function isAdminMode() {
|
||||
var el = document.querySelector('input[name="admin_mode"]');
|
||||
return el && el.value === "1";
|
||||
}
|
||||
|
||||
function updateTfeRequired() {
|
||||
if (isAdminMode()) return;
|
||||
|
||||
var tfeInput = document.getElementById("tfe-files-input");
|
||||
if (!tfeInput) return;
|
||||
|
||||
var checkedAny = false;
|
||||
var boxes = document.querySelectorAll('input[name="formats[]"]:checked');
|
||||
for (var i = 0; i < boxes.length; i++) {
|
||||
if (optionalFormatIds.indexOf(boxes[i].value) !== -1) {
|
||||
checkedAny = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the label for the TFE input (its parent group's <label>)
|
||||
var fieldGroup = tfeInput.closest(".admin-files-fieldgroup");
|
||||
var label = fieldGroup ? fieldGroup.querySelector("label[for='tfe-files-input']") : null;
|
||||
|
||||
if (checkedAny) {
|
||||
tfeInput.removeAttribute("required");
|
||||
// Replace asterisk + optional text
|
||||
if (label) {
|
||||
label.textContent = "TFE (optionnel pour ce format)";
|
||||
}
|
||||
} else {
|
||||
tfeInput.setAttribute("required", "");
|
||||
if (label) {
|
||||
label.innerHTML = "TFE <span class='asterisk'>*</span>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate change events on the format fieldset
|
||||
var formatFieldset = document.getElementById("fieldset-formats");
|
||||
if (formatFieldset) {
|
||||
formatFieldset.addEventListener("change", function (e) {
|
||||
if (e.target && e.target.name === "formats[]") {
|
||||
updateTfeRequired();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Run once on page load
|
||||
updateTfeRequired();
|
||||
|
||||
// Expose for HTMX afterSwap re-init
|
||||
window.XamxamUpdateTfeRequired = updateTfeRequired;
|
||||
})();
|
||||
|
||||
// ── Relink file browser ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -159,8 +159,14 @@
|
||||
}
|
||||
highlight(selectedIdx);
|
||||
} else if (e.key === "Enter") {
|
||||
if (items.length > 0) {
|
||||
// Always prevent Enter from submitting the form.
|
||||
// If there are no suggestions (e.g., "anglais" in language
|
||||
// search — excluded main language), the Enter key would
|
||||
// otherwise propagate to the form and trigger its hx-post to
|
||||
// draft.php, causing the JSON response to replace the form
|
||||
// content.
|
||||
e.preventDefault();
|
||||
if (items.length > 0) {
|
||||
if (selectedIdx >= 0 && selectedIdx < items.length) {
|
||||
selectPill(items[selectedIdx]);
|
||||
} else {
|
||||
|
||||
@@ -75,15 +75,16 @@ if ($method === 'POST') {
|
||||
|
||||
$_SESSION[$draftKey] = $draft;
|
||||
|
||||
// Rotate CSRF after mutation — keep share CSRF in sync
|
||||
$newToken = bin2hex(random_bytes(32));
|
||||
$_SESSION['csrf_token'] = $newToken;
|
||||
$_SESSION['share_csrf_' . $slug] = $newToken;
|
||||
// NOTE: Do NOT rotate the CSRF token here.
|
||||
// Rotating it breaks concurrent FilePond uploads and overlapping autosave
|
||||
// requests because the in-flight request carries the old token but the
|
||||
// session already has the new one.
|
||||
// The CSRF token already rotates on page load and form submit.
|
||||
// See admin/actions/draft.php for the detailed rationale.
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'csrf_token' => $newToken,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ $results = array_values(array_filter($results, function($item) use (&$seen) {
|
||||
|
||||
// Exclude already-selected items and, for languages, the main three
|
||||
if ($type === 'language') {
|
||||
$excludedMain = ['français', 'anglais', 'néerlandais'];
|
||||
$excludedMain = ['français', 'french', 'frans', 'anglais', 'english', 'engels', 'néerlandais', 'nederlands', 'dutch'];
|
||||
$results = array_values(array_filter($results, function($lang) use ($excludedMain, $exclude) {
|
||||
$lower = strtolower($lang['name']);
|
||||
return !in_array($lower, $excludedMain, true)
|
||||
@@ -98,14 +98,18 @@ foreach ($results as $item) {
|
||||
$inExcluded = in_array($q, $exclude, true);
|
||||
$canCreate = ($q !== '' && !$exactExists && !$inExcluded);
|
||||
|
||||
$mainLangBlocked = false;
|
||||
if ($type === 'language') {
|
||||
$excludedMain = ['français', 'anglais', 'néerlandais'];
|
||||
$excludedMain = ['français', 'french', 'frans', 'anglais', 'english', 'engels', 'néerlandais', 'nederlands', 'dutch'];
|
||||
if (in_array($q, $excludedMain, true)) {
|
||||
$canCreate = false;
|
||||
$mainLangBlocked = true;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<?php if (empty($results) && !$canCreate): ?>
|
||||
<?php if ($mainLangBlocked): ?>
|
||||
<div class="tag-search-empty tag-search-empty--hint" role="alert">Cette langue est déjà disponible via les cases à cocher ci-dessus.</div>
|
||||
<?php elseif (empty($results) && !$canCreate): ?>
|
||||
<div class="tag-search-empty"><?= htmlspecialchars($emptyMessage) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ require_once APP_ROOT . '/src/EmailObfuscator.php';
|
||||
require_once APP_ROOT . '/src/MarkdownHelper.php';
|
||||
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
|
||||
|
||||
class CharteController
|
||||
{
|
||||
@@ -27,7 +28,15 @@ class CharteController
|
||||
$pageTitle = 'Charte';
|
||||
}
|
||||
|
||||
$converter = new CommonMarkConverter(['html_input' => 'strip']);
|
||||
$converter = new CommonMarkConverter([
|
||||
'html_input' => 'strip',
|
||||
'heading_permalink' => [
|
||||
'id_prefix' => '',
|
||||
'insert' => 'before',
|
||||
'aria_hidden' => true,
|
||||
],
|
||||
]);
|
||||
$converter->getEnvironment()->addExtension(new HeadingPermalinkExtension());
|
||||
$html = EmailObfuscator::obfuscateHtml($converter->convert($content)->getContent());
|
||||
|
||||
$tocItems = MarkdownHelper::extractToc($content);
|
||||
|
||||
@@ -6,6 +6,7 @@ require_once APP_ROOT . '/src/EmailObfuscator.php';
|
||||
require_once APP_ROOT . '/src/MarkdownHelper.php';
|
||||
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
|
||||
|
||||
class LicenceController
|
||||
{
|
||||
@@ -27,7 +28,15 @@ class LicenceController
|
||||
$pageTitle = 'Licences';
|
||||
}
|
||||
|
||||
$converter = new CommonMarkConverter(['html_input' => 'strip']);
|
||||
$converter = new CommonMarkConverter([
|
||||
'html_input' => 'strip',
|
||||
'heading_permalink' => [
|
||||
'id_prefix' => '',
|
||||
'insert' => 'before',
|
||||
'aria_hidden' => true,
|
||||
],
|
||||
]);
|
||||
$converter->getEnvironment()->addExtension(new HeadingPermalinkExtension());
|
||||
$html = EmailObfuscator::obfuscateHtml($converter->convert($content)->getContent());
|
||||
|
||||
$tocItems = MarkdownHelper::extractToc($content);
|
||||
|
||||
@@ -551,7 +551,7 @@ class ThesisCreateController
|
||||
$cc2r = !empty($post['cc2r']);
|
||||
|
||||
// Duration: numeric value + unit (optional, admin-validated)
|
||||
$validDurationUnits = ['pages', 'minutes', 'sec', 'heures', 'mo'];
|
||||
$validDurationUnits = ['pages', 'mo', 'durée'];
|
||||
$durationValue = $post['duration_value'] ?? null;
|
||||
$durationUnit = $post['duration_unit'] ?? 'pages';
|
||||
if ($durationValue !== null && $durationValue !== '') {
|
||||
|
||||
@@ -145,9 +145,11 @@ class FormBootstrap
|
||||
$showAutosaveStatus = true;
|
||||
}
|
||||
}
|
||||
if ($showAutosaveStatus) {
|
||||
$formExtraAttrs = 'hx-post="' . htmlspecialchars($autosaveUrl) . '"';
|
||||
}
|
||||
// NOTE: Do NOT set hx-post on the <form> element.
|
||||
// It would intercept the native form submit and POST to draft.php
|
||||
// instead of the actual form action (formulaire.php / edit.php).
|
||||
// Autosave is handled by the hidden probe <div> with its own hx-post.
|
||||
$formExtraAttrs = ''; // intentionally always empty
|
||||
|
||||
return array_merge([
|
||||
// Base
|
||||
|
||||
@@ -17,15 +17,14 @@ class MarkdownHelper
|
||||
{
|
||||
$items = [];
|
||||
$lines = explode("\n", $content);
|
||||
|
||||
// Use CommonMark's own SlugNormalizer so TOC links match the rendered heading IDs exactly.
|
||||
$normalizer = new \League\CommonMark\Normalizer\SlugNormalizer();
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^#{1,3}\s+(.+)$/', $line, $m)) {
|
||||
$label = trim($m[1]);
|
||||
// Replicate CommonMark's default heading ID generation:
|
||||
// lowercase, strip non-word chars (except hyphens/spaces), spaces→hyphens
|
||||
$id = strtolower($label);
|
||||
$id = preg_replace('/[^\w\s-]/u', '', $id);
|
||||
$id = preg_replace('/\s+/', '-', $id);
|
||||
$id = trim($id, '-');
|
||||
$id = $normalizer->normalize($label);
|
||||
$items[] = ['label' => $label, 'href' => '#' . $id];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ $adminMode = ($_POST['admin_mode'] ?? '0') === '1';
|
||||
$editMode = ($_POST['edit_mode'] ?? '0') === '1';
|
||||
$errorFieldName = $errorFieldName ?? null;
|
||||
|
||||
// TFE file is optional when format is Site web (3), Performance (4) or Installation (6)
|
||||
$noTfeFileFormats = [3, 4, 6];
|
||||
// TFE file is optional when format is Site web (1), Performance (4) or Installation (6)
|
||||
$noTfeFileFormats = [1, 4, 6];
|
||||
$tfeFileOptional = !empty(array_intersect($selectedFormats, $noTfeFileFormats));
|
||||
|
||||
$websiteUrl = htmlspecialchars($_POST['website_url'] ?? '');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared partial — "Degrés d'ouverture et licences" fieldset.
|
||||
* Shared partial - "Degrés d'ouverture et licences" fieldset.
|
||||
*
|
||||
* Renders:
|
||||
* 1. Généralités (editable via form help blocks)
|
||||
@@ -9,13 +9,13 @@
|
||||
* 4. CC2r checkbox
|
||||
*
|
||||
* Variables consumed:
|
||||
* array $formData — raw form data for repopulation
|
||||
* array $licenseTypes — [{id, name}]
|
||||
* bool $libreEnabled — show Libre option (always true for admin)
|
||||
* bool $interneEnabled — show Interne option
|
||||
* bool $interditEnabled — show Interdit option
|
||||
* string $generalitiesHtml — HTML content for Généralités section (editable)
|
||||
* int $defaultAccessTypeId — default selected access type (default: 2)
|
||||
* array $formData - raw form data for repopulation
|
||||
* array $licenseTypes - [{id, name}]
|
||||
* bool $libreEnabled - show Libre option (always true for admin)
|
||||
* bool $interneEnabled - show Interne option
|
||||
* bool $interditEnabled - show Interdit option
|
||||
* string $generalitiesHtml - HTML content for Généralités section (editable)
|
||||
* int $defaultAccessTypeId - default selected access type (default: 2)
|
||||
*/
|
||||
|
||||
$formData = $formData ?? [];
|
||||
@@ -34,7 +34,7 @@ $adminMode = $adminMode ?? false;
|
||||
<div class="licence-choice">
|
||||
<p class="licence-prompt">J'autorise l'erg à archiver mon TFE de la manière suivante :</p>
|
||||
<?php
|
||||
// access_type_id may be null (meaning "not set"). Keep null to select "—" radio.
|
||||
// access_type_id may be null (meaning "not set"). Keep null to select "-" radio.
|
||||
$selectedAccess = array_key_exists('access_type_id', $formData) ? $formData['access_type_id'] : $defaultAccessTypeId;
|
||||
?>
|
||||
|
||||
@@ -47,7 +47,7 @@ $adminMode = $adminMode ?? false;
|
||||
hx-swap="outerHTML"
|
||||
hx-include="closest fieldset"
|
||||
<?= $selectedAccess === '' || $selectedAccess === null ? 'checked' : '' ?>>
|
||||
<strong>—</strong> Non défini
|
||||
<strong>-</strong> Non défini
|
||||
</label>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -64,10 +64,10 @@ $adminMode = $adminMode ?? false;
|
||||
<strong>🔓 Libre</strong>
|
||||
<br>
|
||||
</label>
|
||||
<details>
|
||||
<summary> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"></path></svg> Info</summary>
|
||||
<details class="licence-details">
|
||||
<summary class="licence-summary"> <svg xmlns="http://www.w3.org/2000/svg" width="1rem" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"></path></svg> Info</summary>
|
||||
<p>
|
||||
Mon TFE est en libre accès à tout le monde sur la plateforme des TFE ainsi que dans la bibliothèque de l’erg. Je suis conscient des responsabilités et obligations légales qui viennent avec une diffusion externe – et acquiesce avoir lu la documentation prévue à cet effet par l'erg, ainsi qu'avoir discuté des enjeux d'une publication avec l'équipe pédagogique. J'accepte de partager mes droits de diffusion avec l'erg, ce uniquement dans le cadre d'une diffusion sur la plateforme xamxam.
|
||||
Mon TFE est en libre accès à tout le monde sur la plateforme des TFE ainsi que dans la bibliothèque de l'erg. Je suis conscient des responsabilités et obligations légales qui viennent avec une diffusion externe – et acquiesce avoir lu la documentation prévue à cet effet par l'erg, ainsi qu'avoir discuté des enjeux d'une publication avec l'équipe pédagogique. J'accepte de partager mes droits de diffusion avec l'erg, ce uniquement dans le cadre d'une diffusion sur la plateforme xamxam.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
@@ -85,10 +85,10 @@ $adminMode = $adminMode ?? false;
|
||||
<strong>🔒 Interne</strong>
|
||||
</label>
|
||||
<br>
|
||||
<details>
|
||||
<summary> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"></path></svg> Info</summary>
|
||||
<details class="licence-details">
|
||||
<summary class="licence-summary"> <svg xmlns="http://www.w3.org/2000/svg" width="1rem" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"></path></svg> Info</summary>
|
||||
<p>
|
||||
Mon TFE et ma note d'intention ne sont accessibles que sur place en physique ainsi que sur la plateforme xamxam par la communauté erg. Une note descriptive est disponible sur le site à toustes. J’autorise une (ré-)utilisation et diffusion dans un contexte académique et didactique au sein de l'erg.
|
||||
Mon TFE et ma note d'intention ne sont accessibles que sur place en physique ainsi que sur la plateforme xamxam par la communauté erg. Une note descriptive est disponible sur le site à toustes. J'autorise une (ré-)utilisation et diffusion dans un contexte académique et didactique au sein de l'erg.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -108,10 +108,10 @@ $adminMode = $adminMode ?? false;
|
||||
|
||||
</label>
|
||||
<br>
|
||||
<details>
|
||||
<summary> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"></path></svg> Info</summary>
|
||||
<details class="licence-details">
|
||||
<summary class="licence-summary"> <svg xmlns="http://www.w3.org/2000/svg" width="1rem" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"></path></svg> Info</summary>
|
||||
<p>
|
||||
Mon TFE n’est pas disponible en physique ni sur le site. Une note descriptive est disponible sur le site.
|
||||
Mon TFE n'est pas disponible en physique ni sur le site. Une note descriptive est disponible sur le site.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
@@ -121,14 +121,14 @@ $adminMode = $adminMode ?? false;
|
||||
|
||||
<!-- Seed saved licence values for the initial htmx load.
|
||||
These are overridden by visible inputs inside .licence-license-choice
|
||||
once htmx renders them — later DOM order wins in POST. -->
|
||||
once htmx renders them - later DOM order wins in POST. -->
|
||||
<input type="hidden" name="license_id" value="<?= htmlspecialchars((string)($formData['license_id'] ?? '')) ?>">
|
||||
<input type="hidden" name="license_custom" value="<?= htmlspecialchars($formData['license_custom'] ?? '') ?>">
|
||||
<input type="hidden" name="cc2r" value="<?= !empty($formData['cc2r']) ? '1' : '' ?>">
|
||||
<?php $wantLicense = !empty($formData['license_id']) || !empty($formData['license_custom']); ?>
|
||||
<input type="hidden" name="want_license" value="<?= $wantLicense ? '1' : '' ?>">
|
||||
|
||||
<!-- Licence — swapped via htmx when radio changes -->
|
||||
<!-- Licence - swapped via htmx when radio changes -->
|
||||
<div class="licence-license-choice"
|
||||
hx-post="<?= $adminMode ? '/admin/fragments/licence.php' : '/partage/fragments/licence.php' ?>"
|
||||
hx-trigger="load"
|
||||
|
||||
@@ -418,37 +418,71 @@ if ($filesMode === 'add'): ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- ═══════════════════ Durée ═══════════════════ -->
|
||||
<fieldset>
|
||||
<?php
|
||||
$_durRaw = $durationValue ?? ($formData['duration_value'] ?? null);
|
||||
$_durUnit = $durationUnit ?? ($formData['duration_unit'] ?? 'pages');
|
||||
$_durFloat = $_durRaw !== null && $_durRaw !== '' ? (float)$_durRaw : null;
|
||||
// Pre-split stored hours into h/m/s for the time-input fields
|
||||
$_durH = '';
|
||||
$_durM = '';
|
||||
$_durS = '';
|
||||
if ($_durFloat !== null && $_durUnit === 'durée') {
|
||||
$_durH = (int)floor($_durFloat);
|
||||
$_remaining = round(($_durFloat - $_durH) * 3600);
|
||||
$_durM = (int)floor($_remaining / 60);
|
||||
$_durS = $_remaining % 60;
|
||||
$_durH = (string)$_durH;
|
||||
$_durM = (string)$_durM;
|
||||
$_durS = (string)$_durS;
|
||||
}
|
||||
?>
|
||||
<fieldset id="duration-fieldset">
|
||||
<legend>Durée</legend>
|
||||
<div class="admin-form-group admin-form-group--inline">
|
||||
<div class="admin-form-group">
|
||||
<div>
|
||||
<label for="duration_unit">Unité :</label>
|
||||
<select id="duration_unit" name="duration_unit">
|
||||
<?php
|
||||
$_currentUnit = $durationUnit ?? ($formData['duration_unit'] ?? 'pages');
|
||||
$_units = [
|
||||
'pages' => 'pages',
|
||||
'minutes' => 'minutes',
|
||||
'sec' => 'secondes',
|
||||
'heures' => 'heures',
|
||||
'mo' => 'Mo',
|
||||
'durée' => 'durée (h:m:s)',
|
||||
];
|
||||
foreach ($_units as $_val => $_label): ?>
|
||||
<option value="<?= $_val ?>" <?= $_currentUnit === $_val ? 'selected' : '' ?>><?= htmlspecialchars($_label) ?></option>
|
||||
<?php endforeach; unset($_units, $_currentUnit, $_val, $_label); ?>
|
||||
<option value="<?= $_val ?>" <?= $_durUnit === $_val ? 'selected' : '' ?>><?= htmlspecialchars($_label) ?></option>
|
||||
<?php endforeach; unset($_units, $_val, $_label); ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="duration_value">Valeur :</label>
|
||||
<input type="number" id="duration_value" name="duration_value"
|
||||
value="<?= htmlspecialchars((string)($durationValue ?? ($formData['duration_value'] ?? ''))) ?>"
|
||||
step="0.1" min="0" placeholder="0"
|
||||
<!-- Integer input for pages / Mo -->
|
||||
<div id="duration-value-integer"<?= $_durUnit === 'durée' ? ' style="display:none"' : '' ?>>
|
||||
<label for="duration_value_int">Valeur :</label>
|
||||
<input type="number" id="duration_value_int"
|
||||
value="<?= htmlspecialchars($_durUnit !== 'durée' ? (string)($_durFloat ?? '') : '') ?>"
|
||||
step="1" min="0" placeholder="0"
|
||||
style="width: 8ch;">
|
||||
</div>
|
||||
<!-- Time inputs for durée -->
|
||||
<div id="duration-value-time" class="duration-time-inputs"<?= $_durUnit !== 'durée' ? ' style="display:none"' : '' ?>>
|
||||
<label>Durée :</label>
|
||||
<span class="duration-time-fields">
|
||||
<input type="number" id="duration_h" value="<?= htmlspecialchars($_durH) ?>"
|
||||
step="1" min="0" placeholder="0" style="width: 5ch;" aria-label="Heures">
|
||||
<span>h</span>
|
||||
<input type="number" id="duration_m" value="<?= htmlspecialchars($_durM) ?>"
|
||||
step="1" min="0" max="59" placeholder="0" style="width: 5ch;" aria-label="Minutes">
|
||||
<span>m</span>
|
||||
<input type="number" id="duration_s" value="<?= htmlspecialchars($_durS) ?>"
|
||||
step="1" min="0" max="59" placeholder="0" style="width: 5ch;" aria-label="Secondes">
|
||||
<span>s</span>
|
||||
</span>
|
||||
</div>
|
||||
<small>Optionnel. Exemples : 88 pages, 32 minutes, 1.5 heures, 120 Mo.</small>
|
||||
</div>
|
||||
<!-- Hidden field: always submitted, populated by JS on unit change / submit -->
|
||||
<input type="hidden" id="duration_value" name="duration_value"
|
||||
value="<?= htmlspecialchars((string)($_durFloat ?? '')) ?>">
|
||||
<small>Optionnel. Exemples : 88 pages, 120 Mo, 1h30.</small>
|
||||
</fieldset>
|
||||
<?php unset($_durRaw, $_durUnit, $_durFloat, $_durH, $_durM, $_durS, $_remaining); ?>
|
||||
|
||||
<!-- ═══════════════════ Degrés d'ouverture et licences ═══════════════════ -->
|
||||
<?php
|
||||
@@ -607,3 +641,47 @@ if ($filesMode === 'add'): ?>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
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 timeWrap = document.getElementById('duration-value-time');
|
||||
var hInput = document.getElementById('duration_h');
|
||||
var mInput = document.getElementById('duration_m');
|
||||
var sInput = document.getElementById('duration_s');
|
||||
if (!unit || !hidden) return;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFields() {
|
||||
if (unit.value === 'durée') {
|
||||
intWrap.style.display = 'none';
|
||||
timeWrap.style.display = '';
|
||||
} else {
|
||||
timeWrap.style.display = 'none';
|
||||
intWrap.style.display = '';
|
||||
}
|
||||
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();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -31,12 +31,11 @@ function renderEntries(array $entries): string
|
||||
$suffix = implode(" & ", array_slice($parts, -2));
|
||||
return $prefix !== "" ? $prefix . ", " . $suffix : $suffix;
|
||||
} ?>
|
||||
<main class="apropos-main" id="main-content">
|
||||
<div class="apropos-layout">
|
||||
<main class="page-content" id="main-content">
|
||||
|
||||
<!-- LEFT: sticky table of contents -->
|
||||
<nav class="apropos-toc" aria-label="Sections de la page">
|
||||
<p class="apropos-toc-label">Parties</p>
|
||||
<p class="apropos-toc-label">PARTIES</p>
|
||||
<ul>
|
||||
<li><a href="#apropos-intro">À propos</a></li>
|
||||
<?php if (!empty($contacts)): ?>
|
||||
@@ -55,20 +54,14 @@ function renderEntries(array $entries): string
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
|
||||
<!-- MIDDLE: main prose + sections -->
|
||||
<div class="apropos-content">
|
||||
|
||||
<!-- Intro text from DB -->
|
||||
<section class="apropos-section" id="apropos-intro">
|
||||
<div class="prose">
|
||||
<section class="content-section" id="apropos-intro">
|
||||
<?= $aboutHtml ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if (!empty($contacts)): ?>
|
||||
<!-- Contacts section -->
|
||||
<section class="apropos-section" id="apropos-contacts">
|
||||
<h2 class="apropos-section-title">Contacts</h2>
|
||||
<section class="content-section" id="apropos-contacts">
|
||||
<h2 class="content-section-title">Contacts</h2>
|
||||
<div class="apropos-contacts-grid">
|
||||
<?php foreach ($contacts as $group): ?>
|
||||
<address class="apropos-contact-card">
|
||||
@@ -92,8 +85,8 @@ function renderEntries(array $entries): string
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Credits section (hardcoded) -->
|
||||
<section class="apropos-section" id="apropos-credits">
|
||||
<h2 class="apropos-section-title">Crédits</h2>
|
||||
<section class="content-section" id="apropos-credits">
|
||||
<h2 class="content-section-title">Crédits</h2>
|
||||
<dl class="apropos-credits-list">
|
||||
<div class="apropos-credit-row">
|
||||
<dt>Design & développement</dt>
|
||||
@@ -121,7 +114,4 @@ function renderEntries(array $entries): string
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<main class="apropos-main" id="main-content">
|
||||
<div class="apropos-layout">
|
||||
<main class="page-content" id="main-content">
|
||||
|
||||
<!-- LEFT: sticky table of contents -->
|
||||
<?php if (!empty($tocItems)): ?>
|
||||
<nav class="apropos-toc" aria-label="Sections de la page">
|
||||
<p class="apropos-toc-label">Parties</p>
|
||||
<p class="apropos-toc-label">PARTIES</p>
|
||||
<ul>
|
||||
<?php foreach ($tocItems as $item): ?>
|
||||
<li><a href="<?= htmlspecialchars($item['href']) ?>"><?= htmlspecialchars($item['label']) ?></a></li>
|
||||
@@ -13,18 +12,12 @@
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- MIDDLE: main prose -->
|
||||
<div class="apropos-content">
|
||||
<section class="apropos-section">
|
||||
<div class="prose">
|
||||
<div class="content">
|
||||
<?php if (!empty(trim($content))): ?>
|
||||
<?= $html ?>
|
||||
<?php else: ?>
|
||||
<p>Contenu à venir.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<main class="apropos-main" id="main-content">
|
||||
<div class="apropos-layout">
|
||||
<main class="page-content" id="main-content">
|
||||
|
||||
<!-- LEFT: sticky table of contents -->
|
||||
<?php if (!empty($tocItems)): ?>
|
||||
<nav class="apropos-toc" aria-label="Sections de la page">
|
||||
<p class="apropos-toc-label">Parties</p>
|
||||
<p class="apropos-toc-label">PARTIES</p>
|
||||
<ul>
|
||||
<?php foreach ($tocItems as $item): ?>
|
||||
<li><a href="<?= htmlspecialchars($item['href']) ?>"><?= htmlspecialchars($item['label']) ?></a></li>
|
||||
@@ -13,18 +12,12 @@
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- MIDDLE: main prose -->
|
||||
<div class="apropos-content">
|
||||
<section class="apropos-section">
|
||||
<div class="prose">
|
||||
<div class="content">
|
||||
<?php if (!empty(trim($content))): ?>
|
||||
<?= $html ?>
|
||||
<?php else: ?>
|
||||
<p>Contenu à venir.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -76,15 +76,22 @@
|
||||
<ul class="results-grid">
|
||||
<?php foreach ($results as $item): ?>
|
||||
<?php $thumb = $coverMap[$item['id']] ?? null; ?>
|
||||
<li><a href="/tfe?id=<?= (int)$item['id'] ?>" class="result-card<?= $thumb ? ' result-card--has-cover' : '' ?>">
|
||||
<li><a href="/tfe?id=<?= (int)$item['id'] ?>" class="result-card">
|
||||
<?php if ($thumb): ?>
|
||||
<figure class="result-card__cover">
|
||||
<img src="/media?path=<?= urlencode($thumb) ?>"
|
||||
alt="Couverture — <?= htmlspecialchars($item['title']) ?>"
|
||||
loading="lazy">
|
||||
</figure>
|
||||
<?php else: ?>
|
||||
<div class="result-card__gradient">
|
||||
<span class="result-card__gradient-author"><?= htmlspecialchars($item['authors'] ?? '') ?></span>
|
||||
<span class="result-card__gradient-title"><?= htmlspecialchars($item['title']) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($thumb): ?>
|
||||
<span class="result-card__authors"><?= htmlspecialchars($item['authors'] ?? '') ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="result-card__title"><?= htmlspecialchars($item['title']) ?></span>
|
||||
<small class="result-card__meta"><?= htmlspecialchars($item['year']) ?><?php if (!empty($item['orientation'])): ?> · <?= htmlspecialchars($item['orientation']) ?><?php endif; ?></small>
|
||||
</a></li>
|
||||
|
||||
@@ -50,22 +50,25 @@
|
||||
<?php
|
||||
$_dVal = (float)$data["duration_value"];
|
||||
$_dUnit = $data["duration_unit"];
|
||||
$_unitLabels = [
|
||||
$_label = match($_dUnit) {
|
||||
'pages' => 'pages',
|
||||
'minutes' => 'minutes',
|
||||
'sec' => 'secondes',
|
||||
'heures' => 'heures',
|
||||
'mo' => 'Mo',
|
||||
];
|
||||
$_label = $_unitLabels[$_dUnit] ?? $_dUnit;
|
||||
// if float, show 0.1 or .0 as needed
|
||||
'durée' => '',
|
||||
default => $_dUnit,
|
||||
};
|
||||
if ($_dUnit === 'durée') {
|
||||
$_hours = (int)floor($_dVal);
|
||||
$_mins = (int)round(($_dVal - $_hours) * 60);
|
||||
$_display = ($_mins > 0) ? "{$_hours}h{$_mins}" : "{$_hours}h";
|
||||
} else {
|
||||
$_display = ($_dVal == (int)$_dVal) ? (int)$_dVal : $_dVal;
|
||||
}
|
||||
?>
|
||||
<p class="tfe-meta-item">
|
||||
<span class="tfe-meta-label">Durée :</span>
|
||||
<?= $_display ?> <?= htmlspecialchars($_label) ?>
|
||||
<?= $_display ?><?= $_label ? ' ' . htmlspecialchars($_label) : '' ?>
|
||||
</p>
|
||||
<?php unset($_unitLabels, $_dVal, $_dUnit, $_label, $_display); endif; ?>
|
||||
<?php unset($_dVal, $_dUnit, $_label, $_display, $_hours, $_mins); endif; ?>
|
||||
|
||||
<?php if (!empty($data["languages"])): ?>
|
||||
<p class="tfe-meta-item">
|
||||
|
||||
Reference in New Issue
Block a user