Files
xamxam/app/public/assets/js/app/file-upload-filepond.js
T
Pontoporeia d8f85b03ad fix: use getElementById for relink FilePond pool lookup
FilePond 4.x wraps the original input element in its own DOM structure
which can break CSS attribute selectors. querySelector with
.tfe-file-picker[data-queue-type='...'] returned null even though the
element existed and was initialized.

Switched to getElementById with a queueType→inputId map:
  cover → couverture
  note_intention → note_intention
  tfe → tfe-files-input
  annexe → annexe-files-input

getElementById is both faster and immune to FilePond's DOM wrapping.
2026-07-10 16:42:56 +02:00

1068 lines
33 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 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[<queueType>][] for each file_id.
* Name: queue_order[<queueType>] 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[<queueType>][] = 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 <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: 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 <dialog> 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 <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", () => {
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 <label>)
var fieldGroup = tfeInput.closest(".admin-files-fieldgroup");
var label = fieldGroup
? fieldGroup.querySelector("label[for='tfe-files-input']")
: null;
if (checkedAny) {
tfeInput.removeAttribute("required");
// Replace asterisk + optional text
if (label) {
label.textContent = "TFE (optionnel pour ce format)";
}
} else {
tfeInput.setAttribute("required", "");
if (label) {
label.innerHTML = "TFE <span class='asterisk'>*</span>";
}
}
}
// Delegate change events on the format fieldset
var formatFieldset = document.getElementById("fieldset-formats");
if (formatFieldset) {
formatFieldset.addEventListener("change", (e) => {
if (e.target && e.target.name === "formats[]") {
updateTfeRequired();
}
});
}
// Run once on page load
updateTfeRequired();
// Expose for HTMX afterSwap re-init
window.XamxamUpdateTfeRequired = updateTfeRequired;
})();
// ── 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)) {
const msg = 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}`);
// Look up the FilePond pool by the known input ID for this queue type.
// querySelector on data-queue-type is unreliable after FilePond
// wraps the original input element.
var inputIdMap = {
cover: "couverture",
note_intention: "note_intention",
tfe: "tfe-files-input",
annexe: "annexe-files-input",
};
var inputId = inputIdMap[queueType] || null;
var input = inputId ? document.getElementById(inputId) : null;
console.log(
"[relink] looking for input | queueType=" + queueType +
" | inputId=" + inputId +
" | found=" + !!input,
);
var closeModal = () => {
var modal = document.getElementById("relink-modal");
if (modal) modal.close();
};
if (input) {
const pond = FilePond.find(input);
console.log(`[relink] looking for pond | found=${!!pond}`);
if (pond) {
pond
.addFiles([{
source: String(data.id),
options: {
type: "local",
file: {
name: fileName,
size: fileSize,
type: mimeType,
},
},
}])
.then(() => {
console.log(
"[relink] addFile resolved | source=" +
String(data.id) +
" | queueType=" +
queueType,
);
closeModal();
})
.catch((err) => {
console.error("[relink] addFile rejected", err);
closeModal();
});
} else {
console.error(
"[relink] FilePond.find returned null for input",
input,
);
closeModal();
}
} else {
console.warn(
"[relink] input not found, page may have reloaded | queueType=" +
queueType,
);
closeModal();
}
// 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>';
});
};
// PeerTube video relink (edit page — binds a channel-orphan video to the thesis)
window.XamxamRelinkPeerTube = (el) => {
var li = el.closest(".file-browser-entry");
if (!li) return;
var uuid = li.dataset.ptUuid;
var name = li.dataset.ptName;
if (!uuid) return;
var ctx = window.__xamxamPeertubeRelinkCtx || {};
var thesisId = ctx.thesisId;
if (!thesisId) return;
var bodyEl = document.getElementById("peertube-relink-modal-body");
if (bodyEl)
bodyEl.innerHTML =
'<p class="file-browser-loading">Reliage en cours…</p>';
var csrfToken =
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content") || "";
fetch("/admin/actions/peertube-relink.php", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify({
thesis_id: parseInt(thesisId, 10),
uuid: uuid,
}),
})
.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?.error
? data.error
: typeof data === "string"
? data
: `Erreur ${status}`;
if (bodyEl)
bodyEl.innerHTML = `<p class="file-browser-error">Erreur : ${msg}</p>`;
return;
}
console.log(`[pt-relink] success | new_id=${data.id}`);
var input = document.getElementById("tfe-files-input");
console.log(
"[pt-relink] looking for input | inputId=tfe-files-input | found=" + !!input,
);
var closeModal = () => {
var modal = document.getElementById("peertube-relink-modal");
if (modal) modal.close();
};
if (input) {
var pond = FilePond.find(input);
if (pond) {
pond
.addFiles([{
source: String(data.id),
options: {
type: "local",
file: {
name: name,
size: 0,
type: "video/mp4",
},
},
}])
.then(() => {
console.log("[pt-relink] addFile resolved");
closeModal();
})
.catch((err) => {
console.error("[pt-relink] addFile rejected", err);
closeModal();
});
} else {
console.error("[pt-relink] FilePond.find returned null");
closeModal();
}
} else {
console.warn("[pt-relink] input not found");
closeModal();
}
window.__xamxamDirty = true;
})
.catch((err) => {
console.error("[pt-relink] fetch error", err);
if (bodyEl)
bodyEl.innerHTML = '<p class="file-browser-error">Erreur réseau.</p>';
});
};
})();