mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
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.
1058 lines
32 KiB
JavaScript
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>';
|
|
});
|
|
};
|
|
})();
|