refactor: extract inline JS into app/ modules, remove dead overtype-webcomponent

- Remove overtype-webcomponent.min.js (zero references)
- Extract copyLogContent + fallbackCopy + HTMX tab-updater → app/admin-logs.js
  (removes duplicate from both system.php and parametres.php)
- Extract copyUrl → app/clipboard.js (shared by acces.php)
- Extract tag/language pill-search logic → app/pill-search.js
  Generalized with data-pill-search attributes, auto-inits via
  DOMContentLoaded + htmx:afterSwap
- Extract access-request form handler → app/access-request.js
  (was inline in templates/public/tfe.php)

Files created: admin-logs.js, clipboard.js, pill-search.js, access-request.js
Files modified: 9 templates/controllers to drop inline scripts and
  reference external JS files
This commit is contained in:
Pontoporeia
2026-05-11 19:37:31 +02:00
parent 04094d802d
commit b56d073210
31 changed files with 430 additions and 1724 deletions

View File

@@ -0,0 +1,97 @@
/**
* access-request.js — handles the "Demander l'accès" form on public thesis pages.
*
* Shows/hides the justification textarea based on email domain (@erg.school / @erg.be).
* Submits via fetch() to /request-access and displays success/error messages.
* Handles the special "recipient_rejected" status to let the user fix their email.
*
* Expects a form with:
* #access-request-form — the form (needs data-thesis-id)
* #access-email — email input
* #justification-container — wrapper div for justification
* #access-justification — justification textarea
* #access-request-message — message display div
*/
(function () {
'use strict';
var form = document.getElementById('access-request-form');
if (!form) return;
var emailInput = document.getElementById('access-email');
var justificationContainer = document.getElementById('justification-container');
var justificationInput = document.getElementById('access-justification');
var messageDiv = document.getElementById('access-request-message');
if (!emailInput || !messageDiv) return;
// Show/hide justification based on email domain
emailInput.addEventListener('input', function () {
var email = this.value.trim().toLowerCase();
var isErg = email.endsWith('@erg.school') || email.endsWith('@erg.be');
if (justificationContainer) justificationContainer.style.display = isErg ? 'none' : 'block';
if (justificationInput) justificationInput.required = !isErg;
});
function showRetryPrompt(rejectedEmail, serverMessage) {
messageDiv.style.display = 'block';
messageDiv.className = 'tfe-access-message tfe-access-error';
messageDiv.innerHTML =
'<strong>Adresse e-mail introuvable sur le serveur de l\'ERG.</strong><br>' +
'<small>' + serverMessage.replace(/</g, '&lt;') + '</small><br><br>' +
'Corrigez votre adresse e-mail et réessayez.';
emailInput.value = rejectedEmail;
emailInput.classList.add('input-error');
emailInput.focus();
emailInput.select();
emailInput.addEventListener('input', function clearError() {
emailInput.classList.remove('input-error');
emailInput.removeEventListener('input', clearError);
});
}
form.addEventListener('submit', function (e) {
e.preventDefault();
var submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Envoi en cours...';
messageDiv.style.display = 'none';
var submittedEmail = emailInput.value.trim();
var formData = new FormData(form);
formData.append('thesis_id', form.getAttribute('data-thesis-id'));
fetch('/request-access', {
method: 'POST',
body: formData
})
.then(function (response) { return response.json(); })
.then(function (data) {
submitBtn.disabled = false;
submitBtn.textContent = 'Demander l\'accès';
if (data.status === 'recipient_rejected') {
showRetryPrompt(submittedEmail, data.message);
return;
}
messageDiv.style.display = 'block';
if (data.success) {
messageDiv.className = 'tfe-access-message tfe-access-success';
messageDiv.textContent = data.message;
form.reset();
} else {
messageDiv.className = 'tfe-access-message tfe-access-error';
messageDiv.textContent = data.message || 'Une erreur est survenue. Veuillez réessayer.';
}
})
.catch(function () {
submitBtn.disabled = false;
submitBtn.textContent = 'Demander l\'accès';
messageDiv.style.display = 'block';
messageDiv.className = 'tfe-access-message tfe-access-error';
messageDiv.textContent = 'Erreur de connexion. Veuillez réessayer.';
});
});
})();

View File

@@ -0,0 +1,64 @@
/**
* admin-logs.js — log viewer utilities shared by system.php and parametres.php.
*
* Provides:
* - copyLogContent(btn) — copy visible log lines to clipboard
* - HTMX afterSwap handler to update active tab class on #sys-tab-panel
*/
(function () {
'use strict';
window.copyLogContent = function (btn) {
var logOut = document.querySelector('#log-output');
if (!logOut) return;
var text = Array.from(logOut.querySelectorAll('.log-line'))
.map(function (el) { return el.textContent; }).join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
btn.textContent = '\u2713 Copi\u00e9';
btn.classList.add('copied');
setTimeout(function () { btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
});
} else {
window._fallbackCopy(text, btn);
}
};
window._fallbackCopy = function (text, btn) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
btn.textContent = '\u2713 Copi\u00e9';
btn.classList.add('copied');
setTimeout(function () { btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
} catch (e) {}
document.body.removeChild(ta);
};
// Update active tab class after each HTMX swap on #sys-tab-panel
document.body.addEventListener('htmx:afterSwap', function (evt) {
if (!(evt.detail.target && evt.detail.target.id === 'sys-tab-panel')) return;
var rc = evt.detail.requestConfig;
var tab = null;
// Tab clicks carry ?tab=… in the path
var qIdx = rc.path.indexOf('?');
if (qIdx !== -1) {
tab = new URLSearchParams(rc.path.substring(qIdx + 1)).get('tab');
}
// Line-count form sends tab via hx-vals in parameters
if (!tab && rc.parameters && rc.parameters.tab) {
tab = rc.parameters.tab;
}
if (!tab) return;
document.querySelectorAll('.sys-tabs .sys-tab').forEach(function (a) {
var isActive = a.getAttribute('data-tab') === tab;
a.classList.toggle('active', isActive);
if (isActive) a.setAttribute('aria-current', 'page');
else a.removeAttribute('aria-current');
});
});
})();

View File

@@ -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.
* Also watches window.__xamxamDirty (set by file-upload-filepond.js on FilePond events).
* No effect when JavaScript is unavailable (form posts normally).
*/
(() => {
const forms = document.querySelectorAll('form[data-beforeunload-guard]');
if (!forms.length) return;
let dirty = false;
for (const form of forms) {
form.addEventListener('input', () => { dirty = true; });
form.addEventListener('change', () => { dirty = true; });
form.addEventListener('submit', () => { dirty = false; window.__xamxamDirty = false; });
}
window.addEventListener('beforeunload', (e) => {
if (dirty || window.__xamxamDirty) {
e.preventDefault();
}
});
})();

View File

@@ -0,0 +1,38 @@
/**
* clipboard.js — lightweight URL copy helper.
*
* Usage:
* <input type="hidden" id="url-123" value="https://...">
* <button onclick="copyUrl(123)">Copier</button>
*
* Or with a custom selector pattern:
* <button onclick="copyUrlFrom(document.getElementById('my-url'))">Copier</button>
*/
(function () {
'use strict';
window.copyUrl = function (id) {
var input = document.getElementById('url-' + id);
if (input) {
window.copyUrlFrom(input);
}
};
window.copyUrlFrom = function (sourceEl) {
var text = sourceEl.value || sourceEl.textContent || '';
if (!text) return;
navigator.clipboard.writeText(text).then(function () {
var btn = window.event && window.event.target ? window.event.target.closest('button') : null;
if (btn) {
var origTitle = btn.getAttribute('title') || '';
var origText = btn.textContent;
btn.setAttribute('title', '\u2713 Copi\u00e9');
btn.textContent = '\u2713 Copi\u00e9';
setTimeout(function () {
btn.setAttribute('title', origTitle);
btn.textContent = origText;
}, 1200);
}
});
};
})();

View File

@@ -0,0 +1,384 @@
/**
* file-upload-filepond.js
*
* Thin FilePond wrapper — replaces the old custom file-upload-queue.js.
*
* Architecture:
* 1. Each <input type="file" class="tfe-file-picker"> is upgraded to a FilePond instance.
* 2. FilePond handles drag-to-reorder, thumbnails, remove, validation — zero custom DOM.
* 3. storeAsFile: true preserves native multipart form submission.
* Server receives files via $_FILES indexed by each input's name attribute
* (e.g. queue_file[tfe][], queue_file[video][], etc.).
* 4. Type + size validation: via native FilePond options + FileValidateType/Size plugins.
* beforeAddFile is used ONLY for hybrid validation (e.g. per-type size limits)
* and returns true/false per the FilePond API contract.
* 5. Order serialization: hidden inputs track file order from pond.getFiles().
* 6. HTMX cleanup: generic destroyFilePondsIn(target) for all swaps, not just known IDs.
*/
(function () {
"use strict";
// ── Per-queue-type configuration ────────────────────────────────────
// Single source of truth for validation. These specificatons are also
// reflected in the PHP-synthesised accept attributes on inputs.
var QUEUE_CONFIG = {
tfe: {
acceptedFileTypes: [
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf",
"video/mp4", "video/webm", "video/ogg", "video/quicktime",
"audio/mpeg", "audio/ogg", "audio/flac", "audio/x-wav", "audio/aac", "audio/mp4",
"text/vtt",
"application/zip", "application/x-tar", "application/gzip"
],
// When PeerTube is active, exclude video/audio from TFE pool
acceptedFileTypesPeerTube: [
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf",
"text/vtt",
"application/zip", "application/x-tar", "application/gzip"
],
labelFileTypeNotAllowed: "Format non accepté",
fileValidateTypeLabelExpectedTypes: "PDF, Images, Vidéos, Audio, VTT, Archives",
fileValidateTypeLabelExpectedTypesPeerTube: "PDF, Images, VTT, Archives",
maxFileSize: "500MB",
labelMaxFileSizeExceeded: "Fichier trop volumineux",
labelMaxFileSize: "Taille max: {filesize}",
allowMultiple: true,
// Per-extension size limits: certain types get higher caps.
perExtensionMaxSize: {
pdf: "100MB",
mp4: "2GB", webm: "2GB", ogv: "2GB", mov: "2GB",
mp3: "2GB", ogg: "2GB", oga: "2GB", wav: "2GB", flac: "2GB", aac: "2GB", m4a: "2GB"
}
},
video: {
acceptedFileTypes: ["video/mp4", "video/webm", "video/ogg", "video/quicktime"],
labelFileTypeNotAllowed: "Format non accepté",
fileValidateTypeLabelExpectedTypes: "MP4, WebM, OGV, MOV",
maxFileSize: "500MB",
labelMaxFileSizeExceeded: "Fichier trop volumineux",
labelMaxFileSize: "Taille max: {filesize}",
allowMultiple: true
},
audio: {
acceptedFileTypes: ["audio/mpeg", "audio/ogg", "audio/flac", "audio/x-wav", "audio/aac", "audio/mp4"],
labelFileTypeNotAllowed: "Format non accepté",
fileValidateTypeLabelExpectedTypes: "MP3, OGG, FLAC, WAV, AAC, M4A",
maxFileSize: "500MB",
labelMaxFileSizeExceeded: "Fichier trop volumineux",
labelMaxFileSize: "Taille max: {filesize}",
allowMultiple: true
},
annexe: {
acceptedFileTypes: ["application/pdf", "application/zip", "application/x-tar", "application/gzip"],
labelFileTypeNotAllowed: "Format non accepté",
fileValidateTypeLabelExpectedTypes: "PDF, ZIP, TAR, GZ",
maxFileSize: "500MB",
labelMaxFileSizeExceeded: "Fichier trop volumineux",
labelMaxFileSize: "Taille max: {filesize}",
allowMultiple: true
},
cover: {
acceptedFileTypes: ["image/jpeg", "image/png", "image/webp"],
labelFileTypeNotAllowed: "Seulement JPG, PNG ou WEBP",
fileValidateTypeLabelExpectedTypes: "JPG, PNG, WEBP",
maxFileSize: "20MB",
labelMaxFileSizeExceeded: "Fichier trop volumineux",
labelMaxFileSize: "Taille max: {filesize}",
allowMultiple: false
},
note_intention: {
acceptedFileTypes: ["application/pdf"],
labelFileTypeNotAllowed: "Seulement PDF",
fileValidateTypeLabelExpectedTypes: "PDF",
maxFileSize: "100MB",
labelMaxFileSizeExceeded: "Fichier trop volumineux",
labelMaxFileSize: "Taille max: {filesize}",
allowMultiple: false
},
peertube_video: {
acceptedFileTypes: ["video/mp4", "video/webm", "video/ogg", "video/quicktime"],
labelFileTypeNotAllowed: "Format non accepté",
fileValidateTypeLabelExpectedTypes: "MP4, WebM, OGV, MOV",
maxFileSize: "500MB",
labelMaxFileSizeExceeded: "Fichier trop volumineux",
labelMaxFileSize: "Taille max: {filesize}",
allowMultiple: true
},
peertube_audio: {
acceptedFileTypes: ["audio/mpeg", "audio/ogg", "audio/flac", "audio/x-wav", "audio/aac", "audio/mp4"],
labelFileTypeNotAllowed: "Format non accepté",
fileValidateTypeLabelExpectedTypes: "MP3, OGG, FLAC, WAV, AAC, M4A",
maxFileSize: "500MB",
labelMaxFileSizeExceeded: "Fichier trop volumineux",
labelMaxFileSize: "Taille max: {filesize}",
allowMultiple: true
},
};
// Map input id → queue type
var INPUT_ID_TO_TYPE = {
"tfe-files-input": "tfe",
"tfe-files-input-2": "tfe",
"video-files-input": "video",
"audio-files-input": "audio",
"annexe-files-input": "annexe",
"couverture": "cover",
"note_intention": "note_intention",
"peertube-video-input": "peertube_video",
"peertube-audio-input": "peertube_audio",
};
// ── Helpers ───────────────────────────────────────────────────────────
/**
* Parse a size string like "500MB" or "2GB" to bytes.
*/
function parseSize(str) {
var m = str.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)$/i);
if (!m) return 0;
var val = parseFloat(m[1]);
var unit = m[2].toUpperCase();
var mult = {B: 1, KB: 1024, MB: 1024*1024, GB: 1024*1024*1024, TB: 1024*1024*1024*1024};
return Math.round(val * (mult[unit] || 1));
}
/**
* Get extension from filename (lowercase).
*/
function getExt(name) {
var m = name.match(/\.([^./]+)$/);
return m ? m[1].toLowerCase() : "";
}
// ── Order serialization ───────────────────────────────────────────────
/**
* Create/update a hidden input that serializes the file order for a queue.
* Name: queue_order[<queueType>]
* Value: pipe-separated list of file names.
*/
function syncOrderInput(queueType, pond) {
if (!pond || !pond.element) return;
var form = pond.element.closest("form");
if (!form) return;
var orderInput = form.querySelector("input[name='queue_order[" + queueType + "]']");
var files = pond.getFiles();
if (files.length === 0) {
if (orderInput) orderInput.remove();
return;
}
var names = [];
for (var i = 0; i < files.length; i++) {
names.push(files[i].filename || files[i].file.name);
}
if (!orderInput) {
orderInput = document.createElement("input");
orderInput.type = "hidden";
orderInput.name = "queue_order[" + queueType + "]";
form.appendChild(orderInput);
}
orderInput.value = names.join("|");
}
// ── FilePond configuration per queue type ─────────────────────────────
function buildFilePondOptions(queueType, input) {
var cfg = QUEUE_CONFIG[queueType];
if (!cfg) return null;
// Per-type max size overrides (for TFE: PDF=100MB, video/audio=2GB)
var perExtMax = cfg.perExtensionMaxSize || {};
// When PeerTube is active, restrict TFE pool to PDF/text only
var peerTubeActive = queueType === "tfe" && input.dataset.peertubeActive === "1";
var acceptedFileTypes = peerTubeActive && cfg.acceptedFileTypesPeerTube
? cfg.acceptedFileTypesPeerTube
: cfg.acceptedFileTypes;
var expectedTypesLabel = peerTubeActive && cfg.fileValidateTypeLabelExpectedTypesPeerTube
? cfg.fileValidateTypeLabelExpectedTypesPeerTube
: cfg.fileValidateTypeLabelExpectedTypes;
return {
allowMultiple: cfg.allowMultiple,
allowReorder: true,
allowProcess: false,
storeAsFile: true,
// ── Native FilePond validation ──
acceptedFileTypes: acceptedFileTypes,
labelFileTypeNotAllowed: cfg.labelFileTypeNotAllowed,
fileValidateTypeLabelExpectedTypes: expectedTypesLabel,
maxFileSize: cfg.maxFileSize,
labelMaxFileSizeExceeded: cfg.labelMaxFileSizeExceeded,
labelMaxFileSize: cfg.labelMaxFileSize,
// ── French labels ──
labelIdle: "Glissez-déposez vos fichiers ou <span class='filepond--label-action'>Parcourir</span>",
labelFileProcessing: "Chargement en cours",
labelFileProcessingComplete: "Chargement terminé",
labelFileProcessingAborted: "Chargement annulé",
labelFileProcessingError: "Erreur lors du chargement",
labelTapToCancel: "Appuyez pour annuler",
labelTapToRetry: "Appuyez pour réessayer",
labelTapToUndo: "Appuyez pour annuler",
labelButtonRemoveItem: "Supprimer",
labelButtonAbortItemLoad: "Annuler",
labelButtonRetryItemLoad: "Réessayer",
labelButtonProcessItem: "Charger",
// ── Per-extension size validation (hybrid: FilePond validates global maxFileSize,
// beforeAddFile enforces per-extension limits via false return) ──
beforeAddFile: function (item) {
var f = item.file;
var ext = getExt(f.name);
if (ext && perExtMax[ext]) {
var limit = parseSize(perExtMax[ext]);
if (limit > 0 && f.size > limit) {
// Return false per FilePond API contract — the FileValidateSize
// plugin sets the error state via maxFileSize, but per-extension
// cap violations must be rejected here.
return false;
}
}
return true;
},
// ── Order serialization on add/remove/reorder ──
onaddfile: function () { syncOrderInput(queueType, this); },
onremovefile: function () { syncOrderInput(queueType, this); },
onreorderfiles: function () { syncOrderInput(queueType, this); },
onupdatefiles: function () { syncOrderInput(queueType, this); },
};
}
// ── Instance tracking ────────────────────────────────────────────────
var _ponds = {};
// ── Public API ────────────────────────────────────────────────────────
/**
* Upgrade .tfe-file-picker inputs to FilePond instances.
* Called on page load and after HTMX swaps.
*/
window.XamxamInitFilePonds = function () {
document.querySelectorAll(".tfe-file-picker").forEach(function (input) {
// Canonical duplicate check: FilePond.find() is the authoritative source
if (FilePond.find(input)) return;
var id = input.id;
var queueType = INPUT_ID_TO_TYPE[id];
if (!queueType) {
queueType = input.dataset.queueType || null;
}
if (!queueType) return;
var options = buildFilePondOptions(queueType, input);
if (!options) return;
options.name = input.getAttribute("name") || input.name || "";
var pond = FilePond.create(input, options);
var key = id || queueType;
_ponds[key] = pond;
// Initial order serialization (for existing files in edit mode — none expected)
syncOrderInput(queueType, pond);
});
};
/**
* Destroy FilePond instances inside a given container element.
* Generic: handles ANY HTMX swap target, not just known IDs.
*/
function destroyFilePondsIn(el) {
if (!el) return;
el.querySelectorAll(".tfe-file-picker").forEach(function (input) {
var pond = FilePond.find(input);
if (pond) {
try {
// Remove order input before destroying
var form = input.closest("form");
if (form) {
var id = input.id;
var queueType = INPUT_ID_TO_TYPE[id] || input.dataset.queueType || null;
if (queueType) {
var orderInput = form.querySelector("input[name='queue_order[" + queueType + "]']");
if (orderInput) orderInput.remove();
}
}
pond.destroy();
} catch (_) {}
}
// Clean up tracking
if (input.id && _ponds[input.id]) {
delete _ponds[input.id];
}
});
}
// ── HTMX integration ─────────────────────────────────────────────────
/**
* Generic beforeSwap handler: destroy FilePonds in ANY swapped target.
* This prevents detached FilePond instances from leaking listeners.
*/
function onHtmxBeforeSwap(evt) {
var target = evt.detail.target;
if (target) {
destroyFilePondsIn(target);
}
}
// ── Bootstrap ─────────────────────────────────────────────────────────
// Register FilePond plugins (idempotent)
if (typeof FilePondPluginFileValidateType !== "undefined") {
FilePond.registerPlugin(FilePondPluginFileValidateType);
}
if (typeof FilePondPluginFileValidateSize !== "undefined") {
FilePond.registerPlugin(FilePondPluginFileValidateSize);
}
if (typeof FilePondPluginImagePreview !== "undefined") {
FilePond.registerPlugin(FilePondPluginImagePreview);
}
if (typeof FilePondPluginImageExifOrientation !== "undefined") {
FilePond.registerPlugin(FilePondPluginImageExifOrientation);
}
if (window.htmx) {
window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap);
window.htmx.on("htmx:afterSwap", function () {
window.XamxamInitFilePonds();
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
window.XamxamInitFilePonds();
});
} else {
window.XamxamInitFilePonds();
}
// ── Mark form dirty on FilePond changes (beforeunload guard) ─────────
document.addEventListener("FilePond:addfile", function () {
window.__xamxamDirty = true;
});
document.addEventListener("submit", function (e) {
var form = e.target;
if (form && form.hasAttribute && form.hasAttribute("data-beforeunload-guard")) {
window.__xamxamDirty = false;
}
});
})();

View File

@@ -0,0 +1,171 @@
/**
* pill-search.js — generalized pill-based search component for tags and languages.
*
* Initialisez avec un conteneur ayant l'attribut data-pill-search :
* <div data-pill-search data-pill-name="tag" data-pill-max="10" data-pill-min="3" data-pill-required="1">
*
* DOM attendu à l'intérieur du conteneur :
* - .tag-search-pills → conteneur des pills
* - .tag-search-input → champ de recherche (avec hx-post, hx-trigger, etc.)
* - .tag-search-suggestions → dropdown
* - .tag-search-count → compteur
* - .tag-search-counter → wrapper du compteur
* - .tag-search-input-wrap → wrapper du champ de recherche
* - .tag-search-max-msg → message "maximum atteint"
*
* Options (par attribut data) :
* data-pill-name → nom pour les inputs cachés (ex: "tag", "language_autre")
* data-pill-max → max pills (default 10)
* data-pill-min → min pills requis (default 0)
* data-pill-required → si "1", active l'affichage du minimum
* data-pill-role → "tag" (lowercase) ou "lang" (ucfirst)
*/
(function () {
'use strict';
function initAll() {
document.querySelectorAll('[data-pill-search]:not([data-pill-search-initialized])').forEach(function (container) {
container.setAttribute('data-pill-search-initialized', '1');
initPillSearch(container);
});
}
document.addEventListener('DOMContentLoaded', initAll);
document.body.addEventListener('htmx:afterSwap', initAll);
function initPillSearch(container) {
var pills = container.querySelector('.tag-search-pills');
var search = container.querySelector('.tag-search-input');
var dropdown = container.querySelector('.tag-search-suggestions');
var countEl = container.querySelector('.tag-search-count');
var counter = container.querySelector('.tag-search-counter');
var maxTags = parseInt(container.getAttribute('data-pill-max')) || 10;
var minTags = parseInt(container.getAttribute('data-pill-min')) || 0;
var required = container.getAttribute('data-pill-required') === '1';
var inputName = container.getAttribute('data-pill-name') || 'tag';
var role = container.getAttribute('data-pill-role') || 'tag';
var selectedIdx = -1;
if (!pills || !search || !dropdown) return;
function normalize(name) {
return name.trim().replace(/\s+/g, ' ').toLowerCase();
}
function pillAlreadyExists(name) {
var norm = normalize(name);
var existing = pills.querySelectorAll('.tag-pill-name');
for (var i = 0; i < existing.length; i++) {
if (normalize(existing[i].textContent) === norm) return true;
}
return false;
}
function updateCount() {
var n = pills.querySelectorAll('.tag-pill').length;
var suffix = required ? ' (min ' + minTags + ')' : '';
if (countEl) countEl.textContent = n + '/' + maxTags + suffix;
if (counter) counter.style.display = (n > 0 || required) ? '' : 'none';
if (countEl && required) {
countEl.style.color = n < minTags ? 'var(--text-danger)' : 'var(--accent)';
}
var wrap = container.querySelector('.tag-search-input-wrap');
var maxMsg = container.querySelector('.tag-search-max-msg');
if (n >= maxTags) {
if (wrap) wrap.style.display = 'none';
if (maxMsg) maxMsg.style.display = '';
} else {
if (wrap) { wrap.style.display = ''; if (search) search.style.display = ''; }
if (maxMsg) maxMsg.style.display = 'none';
}
}
pills.addEventListener('click', function (e) {
var btn = e.target.closest('.tag-pill-remove');
if (!btn) return;
var pill = btn.closest('.tag-pill');
pill.remove();
updateCount();
var wrap = container.querySelector('.tag-search-input-wrap');
var inp = container.querySelector('.tag-search-input');
if (wrap && inp) { wrap.style.display = ''; inp.style.display = ''; }
});
function highlight(idx) {
var items = dropdown.querySelectorAll('.tag-search-item');
for (var i = 0; i < items.length; i++) {
items[i].classList.toggle('tag-search-item--highlight', i === idx);
}
}
function selectPill(btn) {
var name = normalize(btn.getAttribute('data-tag-name') || '');
if (!name) return;
if (pillAlreadyExists(name)) return;
if ((pills.querySelectorAll('.tag-pill').length) >= maxTags) return;
var escaped = htmlEscape(name);
var pill = document.createElement('span');
pill.className = 'tag-pill';
pill.innerHTML = '<input type="hidden" name="' + inputName + '[]" value="' + escaped + '">'
+ '<span class="tag-pill-name">' + escaped + '</span>'
+ '<button type="button" class="tag-pill-remove" title="Retirer \u00AB\u00A0' + escaped + '\u00A0\u00BB" aria-label="Retirer ' + escaped + '">'
+ '<svg width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM112,168V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Zm48,0V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Z"></path></svg>'
+ '</button>';
pills.appendChild(pill);
updateCount();
search.value = '';
dropdown.innerHTML = '';
selectedIdx = -1;
search.focus();
}
dropdown.addEventListener('click', function (e) {
var btn = e.target.closest('.tag-search-item');
if (!btn) return;
selectPill(btn);
});
search.addEventListener('keydown', function (e) {
var items = dropdown.querySelectorAll('.tag-search-item');
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (items.length === 0) return;
if (e.key === 'ArrowDown') {
selectedIdx = (selectedIdx + 1) % items.length;
} else {
selectedIdx = selectedIdx <= 0 ? items.length - 1 : selectedIdx - 1;
}
highlight(selectedIdx);
} else if (e.key === 'Enter') {
if (items.length > 0) {
e.preventDefault();
if (selectedIdx >= 0 && selectedIdx < items.length) {
selectPill(items[selectedIdx]);
} else {
selectPill(items[0]);
}
}
} else if (e.key === 'Escape') {
dropdown.innerHTML = '';
selectedIdx = -1;
}
});
search.addEventListener('blur', function () {
setTimeout(function () {
if (!dropdown.contains(document.activeElement)) {
dropdown.innerHTML = '';
selectedIdx = -1;
}
}, 150);
});
function htmlEscape(str) {
var el = document.createElement('span');
el.textContent = str;
return el.innerHTML;
}
}
})();

View File

@@ -0,0 +1,185 @@
/**
* upload-progress.js
*
* Intercepts admin form submissions with files and submits via XMLHttpRequest.
* Polls GET /admin/actions/upload-progress.php?token=xxx for server-side
* processing progress (PeerTube uploads, file moves).
*
* Progress display:
* 0%25% : browser → server upload (XHR upload.progress)
* 25%99% : server processing (polled from progress endpoint)
* 100% : response received — "Téléversé avec succès", then redirect
*/
(() => {
'use strict';
const FORMS = document.querySelectorAll('form[data-upload-progress]');
if (!FORMS.length) return;
const POLL_INTERVAL = 400;
const UPLOAD_CAP = 25;
const PROCESSING_MAX = 99;
const SUCCESS_DELAY = 800;
for (const form of FORMS) {
const progressWrap = form.querySelector('#upload-progress-wrap');
const progressBar = form.querySelector('#upload-progress-bar');
const progressLabel = form.querySelector('#upload-progress-label');
const progressFile = form.querySelector('#upload-progress-file');
const submitBtn = form.querySelector('button[type="submit"]');
const tokenInput = form.querySelector('input[name="progress_token"]');
if (!progressBar || !progressWrap) continue;
function collectFileNames() {
const names = [];
// Check raw <input type="file"> elements (non-FilePond or FilePond-managed with storeAsFile)
const inputs = form.querySelectorAll('input[type="file"]');
for (const fi of inputs) {
if (fi.files) {
for (const f of fi.files) {
if (f.name) names.push(f.name);
}
}
}
// Also check FilePond instances directly (their storeAsFile hidden inputs may not
// have .files populated yet when the submit event fires)
if (typeof FilePond !== 'undefined') {
const pondInputs = form.querySelectorAll('.tfe-file-picker');
for (const pi of pondInputs) {
const pond = FilePond.find(pi);
if (pond) {
const pondFiles = pond.getFiles();
for (const pf of pondFiles) {
const name = pf.filename || (pf.file && pf.file.name);
if (name) names.push(name);
}
}
}
}
return names;
}
form.addEventListener('submit', function (e) {
const fileNames = collectFileNames();
if (!fileNames.length) return;
e.preventDefault();
const token = tokenInput ? tokenInput.value : '';
progressWrap.style.display = '';
progressBar.value = 0;
progressBar.removeAttribute('data-complete');
progressLabel.textContent = 'Téléversement en cours…';
progressFile.textContent = fileNames.length === 1
? fileNames[0]
: fileNames.length + ' fichiers';
if (submitBtn) submitBtn.disabled = true;
const fd = new FormData(form);
const xhr = new XMLHttpRequest();
let uploadDone = false;
let lastUploadPct = 0;
let pollingTimer = null;
/** Poll server-side progress */
function startPolling() {
if (pollingTimer || !token) return;
progressLabel.textContent = 'Traitement en cours…';
pollingTimer = setInterval(function () {
fetch('/admin/actions/upload-progress.php?token=' + encodeURIComponent(token))
.then(function (r) { return r.json(); })
.then(function (data) {
if (data && data.stage && data.stage !== 'upload') {
const pct = Math.min(PROCESSING_MAX, Math.max(UPLOAD_CAP, data.pct || UPLOAD_CAP));
progressBar.value = pct;
if (data.file) {
progressFile.textContent = data.file;
}
}
})
.catch(function () { /* ignore poll errors */ });
}, POLL_INTERVAL);
}
function stopPolling() {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
}
function finishSuccess() {
stopPolling();
progressBar.value = 100;
progressBar.setAttribute('data-complete', '');
progressLabel.textContent = 'Téléversé avec succès';
progressFile.textContent = '';
}
// ── Upload phase (0% → UPLOAD_CAP) ──
xhr.upload.addEventListener('progress', function (evt) {
if (evt.lengthComputable) {
const rawPct = Math.round((evt.loaded / evt.total) * 100);
const scaled = Math.round((rawPct / 100) * UPLOAD_CAP);
if (scaled > lastUploadPct) {
lastUploadPct = scaled;
progressBar.value = scaled;
}
}
});
xhr.upload.addEventListener('loadend', function () {
uploadDone = true;
progressBar.value = UPLOAD_CAP;
startPolling();
});
// ── Response handling ──
xhr.addEventListener('readystatechange', function () {
if (xhr.readyState !== XMLHttpRequest.DONE) return;
stopPolling();
if (xhr.status >= 200 && xhr.status < 300) {
finishSuccess();
setTimeout(function () {
const finalUrl = xhr.responseURL || '';
if (finalUrl && finalUrl !== form.action) {
window.location.href = finalUrl;
} else {
document.open();
document.write(xhr.responseText);
document.close();
}
}, SUCCESS_DELAY);
} else {
progressLabel.textContent = 'Erreur';
progressFile.textContent = 'Échec du téléversement';
document.open();
document.write(xhr.responseText);
document.close();
}
});
xhr.addEventListener('error', function () {
stopPolling();
progressLabel.textContent = 'Erreur réseau';
progressFile.textContent = '';
if (submitBtn) submitBtn.disabled = false;
});
xhr.addEventListener('abort', function () {
stopPolling();
progressWrap.style.display = 'none';
if (submitBtn) submitBtn.disabled = false;
});
xhr.open('POST', form.action, true);
xhr.send(fd);
});
}
})();