Files
xamxam/app/public/assets/js/app/file-upload-filepond.js
Pontoporeia 6ecd3d4540 Fix biome lint errors: remove duplicate CSS properties, apply safe auto-fixes
CSS:
- Remove duplicate 'background' fallbacks in base.css, header.css, search.css
  (solid color declared before gradient — gradient always wins)
- Remove duplicate 'padding' in admin.css .admin-import-log

JS (biome --write safe fixes applied):
- function() → arrow functions in all IIFEs and callbacks
- forEach/callback → arrow functions
- evaluePtrn → parseInt(x, 10) in admin-contacts-form.js
- Cleaned label text in build.mjs lint step

Remaining warnings are intentional: !important overrides, descending
specificity (admin.css cascade), noUnusedVariables (functions exported
to window/onclick), useTemplate style preference.
2026-06-24 13:57:00 +02:00

1058 lines
32 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: 1073741824, // 1 GB
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: 8589934592, // 8 GB
webm: 8589934592,
ogv: 8589934592,
mov: 8589934592,
mp3: 8589934592,
ogg: 8589934592,
oga: 8589934592,
wav: 8589934592,
flac: 8589934592,
aac: 8589934592,
m4a: 8589934592,
},
},
annexe: {
acceptedFileTypes: [
"application/pdf",
"application/zip",
"application/x-tar",
"application/gzip",
],
labelFileTypeNotAllowed: "Format non accepté",
fileValidateTypeLabelExpectedTypes: "PDF, ZIP, TAR, GZ",
maxFileSize: 1073741824, // 1 GB
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 || !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}`);
// 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 via FileValidateSize plugin hook.
// Falls back to beforeAddFile for silent rejection (the plugin shows the error).
fileValidateSizeFilter: (item) => {
// item may be a raw File/Blob (.name) or a FilePond item wrapper (.filename)
var ext = getExt(item.filename || item.name);
if (ext && perExtMax[ext]) {
return parseSize(perExtMax[ext]); // per-extension cap for this item
}
return parseSize(cfg.maxFileSize); // queue default
},
// Fallback: beforeAddFile enforces per-extension limits (silent rejection).
beforeAddFile: (item) => {
// This check is redundant if fileValidateSizeFilter 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]) {
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 })),
);
}
});
};
/**
* 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) {}
}
}
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", () => {
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}`);
// 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 = () => {
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) {
let url = "/admin/fragments/fichiers.php";
if (window.__xamxamRelinkCtx?.thesisId) {
url +=
"?_thesis_id=" +
encodeURIComponent(window.__xamxamRelinkCtx.thesisId);
}
htmx.ajax("GET", url, {
target: "#format-fichiers-block",
swap: "outerHTML",
});
}
};
if (input) {
const 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(() => {
console.log(
"[relink] addFile resolved | source=" +
String(data.id) +
" | queueType=" +
queueType,
);
closeAndRefresh();
})
.catch((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>';
});
};
// 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.querySelector(
'.tfe-file-picker[data-queue-type="tfe"]',
);
var closeAndRefresh = () => {
var modal = document.getElementById("peertube-relink-modal");
if (modal) modal.close();
var block = document.getElementById("format-fichiers-block");
if (block && window.htmx) {
var url = "/admin/fragments/fichiers.php";
if (thesisId)
url += "?_thesis_id=" + encodeURIComponent(thesisId);
htmx.ajax("GET", url, {
target: "#format-fichiers-block",
swap: "outerHTML",
});
}
};
if (input) {
var pond = FilePond.find(input);
if (pond) {
pond
.addFile(String(data.id), {
type: "limbo",
file: {
name: name,
size: 0,
type: "video/mp4",
},
})
.then(() => {
console.log("[pt-relink] addFile resolved");
closeAndRefresh();
})
.catch((err) => {
console.error("[pt-relink] addFile rejected", err);
closeAndRefresh();
});
} else {
console.error("[pt-relink] FilePond.find returned null");
closeAndRefresh();
}
} else {
console.warn("[pt-relink] input not found");
closeAndRefresh();
}
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>';
});
};
})();