/** * file-upload-filepond.js * * FilePond wrapper with async server round-trip architecture. * * Architecture: * 1. Each is upgraded to a FilePond instance. * 2. FilePond handles drag-to-reorder, thumbnails, remove, validation — zero custom DOM. * 3. Async upload: files are POSTed to /admin/actions/filepond/process.php immediately. * The server returns a file_id stored as item.serverId. * 4. Form submit sends only file_ids (tiny payload), not the files themselves. * 5. Type + size validation: via native FilePond options + FileValidateType/Size plugins * plus fileValidateSizeFilter for per-extension size caps. * 6. Order serialization: hidden inputs track file order using serverId (not filename). * 7. HTMX cleanup: generic destroyFilePondsIn(target) for all swaps, not just known IDs. * 8. Edit mode: loads existing files via data-existing-files JSON + server.load. */ (() => { // ── 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", ], labelFileTypeNotAllowed: "Format non accepté", fileValidateTypeLabelExpectedTypes: "PDF, Images, Vidéos, Audio, VTT, Archives", maxFileSize: 524288000, // 500 MB labelMaxFileSizeExceeded: "Fichier trop volumineux", labelMaxFileSize: "Taille max: {filesize}", allowMultiple: true, // Per-extension size limits: certain types get higher caps. // Values in bytes; FileValidateSize plugin reads maxFileSize as INT, // so numeric literals are required (string suffixes like "1GB" become // parseInt("1GB") = 1 byte inside the plugin). perExtensionMaxSize: { pdf: 104857600, // 100 MB mp4: 5368709120, // 5 GB webm: 5368709120, ogv: 5368709120, mov: 5368709120, mp3: 5368709120, ogg: 5368709120, oga: 5368709120, wav: 5368709120, flac: 5368709120, aac: 5368709120, m4a: 5368709120, }, }, annexe: { acceptedFileTypes: [ "application/pdf", "application/zip", "application/x-tar", "application/gzip", ], labelFileTypeNotAllowed: "Format non accepté", fileValidateTypeLabelExpectedTypes: "PDF, ZIP, TAR, GZ", maxFileSize: 524288000, // 500 MB 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: 20971520, // 20 MB labelMaxFileSizeExceeded: "Fichier trop volumineux", labelMaxFileSize: "Taille max: {filesize}", allowMultiple: false, }, note_intention: { acceptedFileTypes: ["application/pdf"], labelFileTypeNotAllowed: "Seulement PDF", fileValidateTypeLabelExpectedTypes: "PDF", maxFileSize: 104857600, // 100 MB labelMaxFileSizeExceeded: "Fichier trop volumineux", labelMaxFileSize: "Taille max: {filesize}", allowMultiple: false, }, csv_import: { acceptedFileTypes: ["text/csv"], labelFileTypeNotAllowed: "Seulement CSV", fileValidateTypeLabelExpectedTypes: "CSV", maxFileSize: 52428800, // 50 MB labelMaxFileSizeExceeded: "Fichier trop volumineux", labelMaxFileSize: "Taille max: {filesize}", allowMultiple: false, // CSV import stays as storeAsFile (no async upload to process.php), // so the form submits the file directly. storeAsFile: true, }, }; // ── Helpers ─────────────────────────────────────────────────────────── /** * Parse a size string like "500MB" or "2GB" to bytes. */ function parseSize(str) { // Already a number (bytes) — pass through if (typeof str === "number") return 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) { if (!name) return ""; var m = name.match(/\.([^./]+)$/); return m ? m[1].toLowerCase() : ""; } /** * Get the CSRF token from the meta tag. */ function getCsrfToken() { var meta = document.querySelector('meta[name="csrf-token"]'); return meta ? meta.getAttribute("content") : ""; } /** * Get the FilePond endpoint base URL from meta tag. * Defaults to /admin/actions/filepond/ for backward compat. */ function getFilepondBase() { var meta = document.querySelector('meta[name="filepond-base"]'); return meta ? meta.getAttribute("content") : "/admin/actions/filepond"; } // ── Order serialization ─────────────────────────────────────────────── /** * Create/update a hidden input that serializes the file order for a queue. * Name: queue_file[][] for each file_id. * Name: queue_order[] for the pipe-separated order. */ function syncOrderInput(queueType, pond) { if (!pond?.element) return; var form = pond.element.closest("form"); if (!form) return; var files = pond.getFiles(); // Remove old order input and all queue_file hidden inputs for this queueType var oldOrder = form.querySelector( `input[name='queue_order[${queueType}]']`, ); if (oldOrder) oldOrder.remove(); var oldHidden = form.querySelectorAll( `input[name='queue_file[${queueType}][]'][data-filepond-id]`, ); for (let h = 0; h < oldHidden.length; h++) { oldHidden[h].remove(); } if (files.length === 0) return; // Create hidden inputs per file: queue_file[][] = serverId var ids = []; for (let i = 0; i < files.length; i++) { const f = files[i]; // Only include files that have been uploaded and have a serverId const id = f.serverId || null; if (id) { ids.push(id); const hidden = document.createElement("input"); hidden.type = "hidden"; hidden.name = `queue_file[${queueType}][]`; hidden.value = id; hidden.setAttribute("data-filepond-id", "1"); form.appendChild(hidden); } } // Create order input if (ids.length > 0) { const orderInput = document.createElement("input"); orderInput.type = "hidden"; orderInput.name = `queue_order[${queueType}]`; orderInput.value = ids.join("|"); form.appendChild(orderInput); } } // ── Server config builder ───────────────────────────────────────────── function buildServerConfig(queueType) { var csrfToken = getCsrfToken(); console.log( "[filepond] buildServerConfig | queueType=" + queueType + " | csrfToken=" + (csrfToken ? `${csrfToken.substring(0, 8)}...` : "MISSING"), ); var base = getFilepondBase(); console.log( "[filepond] buildServerConfig | queueType=" + queueType + " | csrfToken=" + (csrfToken ? `${csrfToken.substring(0, 8)}...` : "MISSING") + " | base=" + base, ); return { process: { url: `${base}/process.php`, method: "POST", // Use a function for headers so the CSRF token is re-read // from the meta tag on every request. The autosave handler // rotates the token periodically and updates the meta tag; // a static snapshot captured at init time would go stale. headers: () => ({ "X-CSRF-Token": getCsrfToken() }), ondata: (formData) => { formData.append("queue_type", queueType); console.log(`[filepond] process ondata | queueType=${queueType}`); return formData; }, onload: (response) => { var id = response.trim(); // Guard: if the server returned an error message disguised as 200, // return a distinguishable error marker instead of a valid serverId. // Throwing here crashes FilePond internally (no try/catch in the wrapper). if (id.length > 64 || /[<>\n\r]/.test(id)) { console.error( "[filepond] process onload | unexpected response | body=" + id.substring(0, 200), ); return `__error__${id.substring(0, 32)}`; } console.log(`[filepond] process onload | serverId=${id}`); return id; // file_id stored as serverId }, onerror: (response) => { // response is the raw XHR response text (string), not an XHR object. // Log it and return a human-readable error message. var body = typeof response === "string" ? response : response?.body ? response.body : String(response || ""); console.error(`[filepond] process onerror | body=${body}`); return body || "Erreur lors du téléversement."; }, }, revert: { url: `${base}/revert.php`, method: "DELETE", // Re-read CSRF token on each request (same rationale as process). headers: () => ({ "X-CSRF-Token": getCsrfToken() }), onload: () => { console.log("[filepond] revert OK"); }, onerror: (r) => { var body = typeof r === "string" ? r : r?.body ? r.body : ""; console.error(`[filepond] revert ERROR | body=${body || r}`); }, }, load: { url: `${base}/load.php?id=`, method: "GET", onload: (response) => { // response is the blob from the server; pass through unchanged return response; }, onerror: (response) => { var body = typeof response === "string" ? response : response?.body ? response.body : String(response || ""); console.error(`[filepond] load onerror | body=${body}`); // Return a descriptive error — FilePond will fire an error event. return body || "Fichier introuvable."; }, }, // FilePond appends the source value (db_id) automatically remove: (source, load, error) => { console.log(`[filepond] remove called | id=${source}`); // During teardown (HTMX swap), skip server round-trips. // FilePond 4.x destroy() does not normally fire remove callbacks, // but this guard prevents accidental deletion of DB files as a // defence-in-depth measure. if (_xamxamTeardown) { console.log(`[filepond] remove skipped (teardown) | id=${source}`); load(); return; } // Hex IDs (32 chars) → temp files → use revert endpoint if (/^[a-f0-9]{32}$/.test(source)) { fetch(`${base}/revert.php`, { method: "DELETE", headers: { "X-CSRF-Token": getCsrfToken() }, body: source, }) .then((r) => { console.log( "[filepond] revert (from remove) response | ok=" + r.ok + " | status=" + r.status, ); r.ok ? load() : error("Erreur suppression"); }) .catch((e) => { console.error("[filepond] revert (from remove) fetch error", e); error("Erreur réseau"); }); return; } // Numeric IDs → DB files → use remove endpoint fetch(`${base}/remove.php`, { method: "DELETE", headers: { "Content-Type": "application/json", "X-CSRF-Token": getCsrfToken(), }, body: JSON.stringify({ db_id: source }), }) .then((r) => { console.log( "[filepond] remove response | ok=" + r.ok + " | status=" + r.status, ); r.ok ? load() : error("Erreur suppression"); }) .catch((e) => { console.error("[filepond] remove fetch error", e); error("Erreur réseau"); }); }, }; } // ── 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 || {}; // Base options shared by all queue types var opts = { allowMultiple: cfg.allowMultiple, allowReorder: true, // ── Native FilePond validation ── acceptedFileTypes: cfg.acceptedFileTypes, labelFileTypeNotAllowed: cfg.labelFileTypeNotAllowed, fileValidateTypeLabelExpectedTypes: cfg.fileValidateTypeLabelExpectedTypes, maxFileSize: cfg.maxFileSize, labelMaxFileSizeExceeded: cfg.labelMaxFileSizeExceeded, labelMaxFileSize: cfg.labelMaxFileSize, // ── French labels ── labelIdle: "Glissez-déposez vos fichiers ou Parcourir", 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: skip the global maxFileSize check // for files with per-extension caps — beforeAddFile enforces those. // fileValidateSizeFilter is a gate: return false to skip the // built-in maxFileSize check; return true to proceed with it. fileValidateSizeFilter: (item) => { var ext = getExt(item.filename || item.name); // For files with per-extension caps, skip the global check. // beforeAddFile enforces the per-extension limit. if (ext && perExtMax[ext]) { return false; } // For files without per-extension caps, use the global maxFileSize. return true; }, // beforeAddFile: primary per-extension size enforcement. // For files with per-extension caps, this is the authority. // The global maxFileSize handles files without per-ext caps. beforeAddFile: (item) => { if (typeof item.file === "undefined") return true; var f = item.file; var ext = getExt(f.name); if (ext && perExtMax[ext]) { const limit = parseSize(perExtMax[ext]); if (limit > 0 && f.size > limit) { 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); }, // Re-sync after async upload completes (serverId is now set) onprocessfile: function (error, _item) { if (!error) syncOrderInput(queueType, this); }, }; // storeAsFile queues skip async upload; the file stays in the form if (cfg.storeAsFile) { opts.storeAsFile = true; opts.allowProcess = false; } else { opts.server = buildServerConfig(queueType); } return opts; } // ── Public API ──────────────────────────────────────────────────────── /** * Upgrade .tfe-file-picker inputs to FilePond instances. * Called on page load and after HTMX swaps. */ window.XamxamInitFilePonds = () => { document.querySelectorAll(".tfe-file-picker").forEach((input) => { // Canonical duplicate check: FilePond.find() is the authoritative source if (FilePond.find(input)) return; // Skip inputs inside closed elements — FilePond can't render // when the container has display:none. Initialize when the dialog opens. var dialog = input.closest("dialog"); if (dialog && !dialog.open) return; // Queue type: always from data-queue-type attribute var queueType = input.dataset.queueType || null; if (!queueType) return; var options = buildFilePondOptions(queueType, input); if (!options) return; var pond = FilePond.create(input, options); console.log( "[filepond] Created instance | queueType=" + queueType + " | inputId=" + (input.id || "none") + " | inputName=" + (input.getAttribute("name") || input.name || "?"), ); // Initial order serialization syncOrderInput(queueType, pond); // ── Edit mode: load existing files ── var existingFiles = []; try { existingFiles = JSON.parse(input.dataset.existingFiles || "[]"); } catch (_) {} if (existingFiles.length) { pond.addFiles( existingFiles.map((f) => ({ source: f.source, options: f.options })), ); } }); }; /** * Guard flag set during destroyFilePondsIn — the server.remove callback * checks this and skips the server round-trip during teardown to prevent * accidental deletion of existing DB files from FilePond.destroy(). */ let _xamxamTeardown = false; /** * 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((input) => { var pond = FilePond.find(input); if (pond) { try { // Remove order/hidden inputs before destroying const form = input.closest("form"); if (form) { const queueType = input.dataset.queueType || null; if (queueType) { const orderInput = form.querySelector( `input[name='queue_order[${queueType}]']`, ); if (orderInput) orderInput.remove(); const hiddenInputs = form.querySelectorAll( "input[name='queue_file[" + queueType + "][]'][data-filepond-id]", ); for (let h = 0; h < hiddenInputs.length; h++) { hiddenInputs[h].remove(); } } } // Abort any in-flight uploads before destroying to prevent // FilePond internal crashes when XHR callbacks fire on a // torn-down instance ("can't access property main"). const files = pond.getFiles(); for (let i = 0; i < files.length; i++) { const f = files[i]; if (f.status === 4 || f.status === 2 || f.status === 3) { // FileStatus: PROCESSING=4, PROCESSING_QUEUED=2, PROCESSING=4 // (FilePond 4.x internal: 4 = processing) // Abort processing to avoid stale XHR callbacks try { pond.removeFile(f); } catch (_abort) {} } } _xamxamTeardown = true; pond.destroy(); _xamxamTeardown = false; } catch (_) { _xamxamTeardown = false; } } }); } // ── 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 ───────────────────────────────────────────────────────── // Global FilePond event listeners for debugging document.addEventListener("FilePond:processfile", (e) => { console.log( "[filepond:event] processfile | id=" + (e.detail.file ? e.detail.file.serverId : "") + " | error=" + (e.detail.error || "none"), ); }); document.addEventListener("FilePond:processfilestart", (e) => { console.log( "[filepond:event] processfilestart | filename=" + (e.detail.file ? e.detail.file.filename : "?"), ); }); document.addEventListener("FilePond:processfileprogress", (e) => { var pct = e.detail.progress; if (pct && (pct === 0 || pct === 1 || Math.floor(pct * 100) % 25 === 0)) { console.log( "[filepond:event] processfileprogress | pct=" + Math.floor(pct * 100) + "%", ); } }); document.addEventListener("FilePond:processfileabort", (_e) => { console.log("[filepond:event] processfileabort"); }); document.addEventListener("FilePond:processfilerevert", (_e) => { console.log("[filepond:event] processfilerevert"); }); document.addEventListener("FilePond:error", (e) => { console.error("[filepond:event] error", e.detail); }); // 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); } // ── HTMX integration (register later once htmx is loaded) ─────────── // Note: htmx.min.js loads at the end of (admin/footer.php), // after this script. Use DOM polling or a listener to wire up. function tryRegisterHtmx() { if (!window.htmx) { setTimeout(tryRegisterHtmx, 50); return; } console.log("[filepond] htmx detected, registering swap listeners"); window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap); window.htmx.on("htmx:afterSwap", () => { enableFilepondMode(); _xamxamFilepondReady = false; window.XamxamInitFilePonds(); if (window.XamxamUpdateTfeRequired) window.XamxamUpdateTfeRequired(); setTimeout(() => { _xamxamFilepondReady = true; }, 0); }); } // ── Enable filepond_mode hidden input (no-JS safety) ──────────────── // The hidden input starts as disabled / value=0 so the server falls // back to $_FILES when JS is unavailable. Enable it now that FilePond // will handle uploads asynchronously. function enableFilepondMode() { var inputs = document.querySelectorAll("input[name='filepond_mode']"); for (let i = 0; i < inputs.length; i++) { inputs[i].disabled = false; inputs[i].value = "1"; } } // Flag set after FilePond instances are fully initialised. // Before this flag is set, FilePond:addfile events are from initial load // (e.g. existing files loaded in edit mode) and should not mark the form dirty. let _xamxamFilepondReady = false; tryRegisterHtmx(); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { enableFilepondMode(); window.XamxamInitFilePonds(); _xamxamFilepondReady = true; }); } else { enableFilepondMode(); window.XamxamInitFilePonds(); _xamxamFilepondReady = true; } // ── Mark form dirty on FilePond changes (beforeunload guard) ───────── document.addEventListener("FilePond:addfile", () => { if (_xamxamFilepondReady) window.__xamxamDirty = true; }); document.addEventListener("FilePond:removefile", () => { if (_xamxamFilepondReady) window.__xamxamDirty = true; }); document.addEventListener("submit", (e) => { var form = e.target; if (form?.hasAttribute?.("data-beforeunload-guard")) { window.__xamxamDirty = false; } }); // ── 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. (() => { 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