Files
xamxam/app/public/assets/js/app/file-upload-filepond.js

703 lines
23 KiB
JavaScript

/**
* file-upload-filepond.js
*
* FilePond wrapper with async server round-trip architecture.
*
* 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. 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 fileValidateSizeFilterItem 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: "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",
},
},
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,
},
};
// ── 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() : "";
}
/**
* 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[<queueType>][] for each file_id.
* Name: queue_order[<queueType>] for the pipe-separated order.
*/
function syncOrderInput(queueType, pond) {
if (!pond || !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 (var h = 0; h < oldHidden.length; h++) {
oldHidden[h].remove();
}
if (files.length === 0) return;
// Create hidden inputs per file: queue_file[<queueType>][] = serverId
var ids = [];
for (var i = 0; i < files.length; i++) {
var f = files[i];
// Only include files that have been uploaded and have a serverId
var id = f.serverId || null;
if (id) {
ids.push(id);
var 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) {
var 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",
headers: { "X-CSRF-Token": csrfToken },
ondata: (formData) => {
formData.append("queue_type", queueType);
console.log(`[filepond] process ondata | queueType=${queueType}`);
return formData;
},
onload: (response) => {
var id = response.trim();
console.log(`[filepond] process onload | serverId=${id}`);
return id; // file_id stored as serverId
},
onerror: (response) => {
console.error(
"[filepond] process onerror | status=" +
response.status +
" | body=" +
response,
);
return response;
},
},
revert: {
url: `${base}/revert.php`,
method: "DELETE",
headers: { "X-CSRF-Token": csrfToken },
onload: () => {
console.log("[filepond] revert OK");
},
onerror: (r) => {
console.error(`[filepond] revert ERROR | body=${r}`);
},
},
load: `${base}/load.php?id=`,
// FilePond appends the source value (db_id) automatically
remove: (source, load, error) => {
console.log(`[filepond] remove called | db_id=${source}`);
fetch(`${base}/remove.php`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
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 || {};
return {
allowMultiple: cfg.allowMultiple,
allowReorder: true,
// ── Async server model (replaces storeAsFile + allowProcess: false) ──
server: buildServerConfig(queueType),
// ── 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 <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 ──────────────────────────────
// Uses fileValidateSizeFilterItem if the FileValidateSize plugin supports it.
// Falls back to beforeAddFile for silent rejection (the plugin shows the error).
fileValidateSizeFilterItem: (item) => {
var ext = getExt(item.filename);
if (ext && perExtMax[ext]) {
return parseSize(perExtMax[ext]); // per-extension cap for this item
}
return parseSize(cfg.maxFileSize); // queue default
},
// Fallback: if fileValidateSizeFilterItem is not available,
// beforeAddFile enforces per-extension limits (silent rejection).
beforeAddFile: (item) => {
// This check is redundant if fileValidateSizeFilterItem works,
// but serves as a fallback.
if (typeof item.file === "undefined") return true;
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;
}
}
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);
},
};
}
// ── 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;
// 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 })),
);
}
});
};
/**
* 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
var form = input.closest("form");
if (form) {
var queueType = input.dataset.queueType || null;
if (queueType) {
var orderInput = form.querySelector(
`input[name='queue_order[${queueType}]']`,
);
if (orderInput) orderInput.remove();
var hiddenInputs = form.querySelectorAll(
"input[name='queue_file[" +
queueType +
"][]'][data-filepond-id]",
);
for (var h = 0; h < hiddenInputs.length; h++) {
hiddenInputs[h].remove();
}
}
}
pond.destroy();
} catch (_) {}
}
});
}
// ── 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 <body> (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", () => {
_xamxamFilepondReady = false;
window.XamxamInitFilePonds();
setTimeout(() => { _xamxamFilepondReady = true; }, 0);
});
}
// 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", () => {
window.XamxamInitFilePonds();
_xamxamFilepondReady = true;
});
} else {
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;
}
});
// ── Relink file browser ──────────────────────────────────────────
/**
* Relink a selected file to the thesis.
* Called from the onclick handler on file-browser entries.
* The file browser is loaded inside #relink-modal-body via HTMX.
*/
window.XamxamRelinkFile = (el) => {
var li = el.closest('.file-browser-entry');
console.log('[relink] XamxamRelinkFile called | el=', el, '| li=', li);
if (!li) return;
var ctx = window.__xamxamRelinkCtx || {};
var thesisId = ctx.thesisId;
var queueType = ctx.queueType;
var filePath = li.dataset.filePath;
var fileName = li.dataset.fileName;
var fileSize = parseInt(li.dataset.fileSize, 10) || 0;
var ext = li.dataset.fileExt || '';
console.log('[relink] data | thesisId=' + thesisId + ' | queueType=' + queueType + ' | filePath=' + filePath + ' | fileName=' + fileName + ' | ext=' + ext);
if (!filePath || !thesisId || !queueType) {
console.error('[relink] missing data', { filePath, thesisId, queueType });
return;
}
// Determine MIME from extension
var mimeMap = {
pdf: 'application/pdf',
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif',
mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', mov: 'video/quicktime',
mp3: 'audio/mpeg', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac', aac: 'audio/aac', m4a: 'audio/mp4',
vtt: 'text/vtt',
zip: 'application/zip', tar: 'application/x-tar', gz: 'application/gzip',
};
var mimeType = mimeMap[ext] || 'application/octet-stream';
var csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
console.log('[relink] csrfToken=' + (csrfToken ? csrfToken.substring(0, 8) + '...' : 'MISSING'));
var bodyEl = document.getElementById('relink-modal-body');
if (bodyEl) bodyEl.innerHTML = '<p class="file-browser-loading">Reliage en cours…</p>';
fetch('/admin/actions/filepond/relink.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({
thesis_id: parseInt(thesisId, 10),
file_path: filePath,
file_name: fileName,
file_size: fileSize,
mime_type: mimeType,
queue_type: queueType,
}),
})
.then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data })))
.then(({ ok, status, data }) => {
if (!ok || (data && data.ok === false)) {
var msg = (data && data.error) ? data.error : (typeof data === 'string' ? data : 'Erreur ' + status);
if (bodyEl) bodyEl.innerHTML = `<p class="file-browser-error">Erreur : ${msg}</p>`;
return;
}
console.log('[relink] success | new_id=' + data.id);
// Add the new file to the FilePond pool, then close the modal.
// If the DOM was replaced (e.g. live-reload), refresh the
// form fragment via HTMX so the server re-renders the pools
// with the newly-linked file included.
var input = document.querySelector(`.tfe-file-picker[data-queue-type="${queueType}"]`);
console.log('[relink] looking for input | selector=' + `.tfe-file-picker[data-queue-type="${queueType}"]` + ' | found=' + !!input);
var closeAndRefresh = function() {
var modal = document.getElementById('relink-modal');
if (modal) modal.close();
// Re-fetch the fichiers fragment from the server so the
// newly-linked file appears in the FilePond pools.
var block = document.getElementById('format-fichiers-block');
if (block && window.htmx) {
var url = '/admin/fragments/fichiers.php';
if (window.__xamxamRelinkCtx && window.__xamxamRelinkCtx.thesisId) {
url += '?_thesis_id=' + encodeURIComponent(window.__xamxamRelinkCtx.thesisId);
}
htmx.ajax('GET', url, {
target: '#format-fichiers-block',
swap: 'outerHTML'
});
}
};
if (input) {
var pond = FilePond.find(input);
console.log('[relink] looking for pond | found=' + !!pond);
if (pond) {
pond.addFile(String(data.id), {
type: 'limbo',
file: {
name: fileName,
size: fileSize,
type: mimeType
}
}).then(function() {
console.log('[relink] addFile resolved | source=' + String(data.id) + ' | queueType=' + queueType);
closeAndRefresh();
}).catch(function(err) {
console.error('[relink] addFile rejected', err);
closeAndRefresh();
});
} else {
console.error('[relink] FilePond.find returned null for input', input);
closeAndRefresh();
}
} else {
console.warn('[relink] input not found, page may have reloaded | queueType=' + queueType);
closeAndRefresh();
}
// Mark form dirty
window.__xamxamDirty = true;
})
.catch(err => {
console.error('[relink] fetch error', err);
if (bodyEl) bodyEl.innerHTML = '<p class="file-browser-error">Erreur réseau.</p>';
});
};
})();