/**
* 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