/** * file-upload-filepond.js * * Thin FilePond wrapper — replaces the old custom file-upload-queue.js. * * Architecture: * 1. Each 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[] * 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 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 (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; } }); })();