mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-26 08:39:18 +02:00
refactor: extract inline JS into app/ modules, remove dead overtype-webcomponent
- Remove overtype-webcomponent.min.js (zero references) - Extract copyLogContent + fallbackCopy + HTMX tab-updater → app/admin-logs.js (removes duplicate from both system.php and parametres.php) - Extract copyUrl → app/clipboard.js (shared by acces.php) - Extract tag/language pill-search logic → app/pill-search.js Generalized with data-pill-search attributes, auto-inits via DOMContentLoaded + htmx:afterSwap - Extract access-request form handler → app/access-request.js (was inline in templates/public/tfe.php) Files created: admin-logs.js, clipboard.js, pill-search.js, access-request.js Files modified: 9 templates/controllers to drop inline scripts and reference external JS files
This commit is contained in:
97
app/public/assets/js/app/access-request.js
Normal file
97
app/public/assets/js/app/access-request.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* access-request.js — handles the "Demander l'accès" form on public thesis pages.
|
||||
*
|
||||
* Shows/hides the justification textarea based on email domain (@erg.school / @erg.be).
|
||||
* Submits via fetch() to /request-access and displays success/error messages.
|
||||
* Handles the special "recipient_rejected" status to let the user fix their email.
|
||||
*
|
||||
* Expects a form with:
|
||||
* #access-request-form — the form (needs data-thesis-id)
|
||||
* #access-email — email input
|
||||
* #justification-container — wrapper div for justification
|
||||
* #access-justification — justification textarea
|
||||
* #access-request-message — message display div
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var form = document.getElementById('access-request-form');
|
||||
if (!form) return;
|
||||
|
||||
var emailInput = document.getElementById('access-email');
|
||||
var justificationContainer = document.getElementById('justification-container');
|
||||
var justificationInput = document.getElementById('access-justification');
|
||||
var messageDiv = document.getElementById('access-request-message');
|
||||
|
||||
if (!emailInput || !messageDiv) return;
|
||||
|
||||
// Show/hide justification based on email domain
|
||||
emailInput.addEventListener('input', function () {
|
||||
var email = this.value.trim().toLowerCase();
|
||||
var isErg = email.endsWith('@erg.school') || email.endsWith('@erg.be');
|
||||
if (justificationContainer) justificationContainer.style.display = isErg ? 'none' : 'block';
|
||||
if (justificationInput) justificationInput.required = !isErg;
|
||||
});
|
||||
|
||||
function showRetryPrompt(rejectedEmail, serverMessage) {
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.className = 'tfe-access-message tfe-access-error';
|
||||
messageDiv.innerHTML =
|
||||
'<strong>Adresse e-mail introuvable sur le serveur de l\'ERG.</strong><br>' +
|
||||
'<small>' + serverMessage.replace(/</g, '<') + '</small><br><br>' +
|
||||
'Corrigez votre adresse e-mail et réessayez.';
|
||||
emailInput.value = rejectedEmail;
|
||||
emailInput.classList.add('input-error');
|
||||
emailInput.focus();
|
||||
emailInput.select();
|
||||
emailInput.addEventListener('input', function clearError() {
|
||||
emailInput.classList.remove('input-error');
|
||||
emailInput.removeEventListener('input', clearError);
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var submitBtn = form.querySelector('button[type="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Envoi en cours...';
|
||||
messageDiv.style.display = 'none';
|
||||
|
||||
var submittedEmail = emailInput.value.trim();
|
||||
var formData = new FormData(form);
|
||||
formData.append('thesis_id', form.getAttribute('data-thesis-id'));
|
||||
|
||||
fetch('/request-access', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(function (response) { return response.json(); })
|
||||
.then(function (data) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Demander l\'accès';
|
||||
|
||||
if (data.status === 'recipient_rejected') {
|
||||
showRetryPrompt(submittedEmail, data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
messageDiv.style.display = 'block';
|
||||
if (data.success) {
|
||||
messageDiv.className = 'tfe-access-message tfe-access-success';
|
||||
messageDiv.textContent = data.message;
|
||||
form.reset();
|
||||
} else {
|
||||
messageDiv.className = 'tfe-access-message tfe-access-error';
|
||||
messageDiv.textContent = data.message || 'Une erreur est survenue. Veuillez réessayer.';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Demander l\'accès';
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.className = 'tfe-access-message tfe-access-error';
|
||||
messageDiv.textContent = 'Erreur de connexion. Veuillez réessayer.';
|
||||
});
|
||||
});
|
||||
})();
|
||||
64
app/public/assets/js/app/admin-logs.js
Normal file
64
app/public/assets/js/app/admin-logs.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* admin-logs.js — log viewer utilities shared by system.php and parametres.php.
|
||||
*
|
||||
* Provides:
|
||||
* - copyLogContent(btn) — copy visible log lines to clipboard
|
||||
* - HTMX afterSwap handler to update active tab class on #sys-tab-panel
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
window.copyLogContent = function (btn) {
|
||||
var logOut = document.querySelector('#log-output');
|
||||
if (!logOut) return;
|
||||
var text = Array.from(logOut.querySelectorAll('.log-line'))
|
||||
.map(function (el) { return el.textContent; }).join('\n');
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function () {
|
||||
btn.textContent = '\u2713 Copi\u00e9';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(function () { btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
|
||||
});
|
||||
} else {
|
||||
window._fallbackCopy(text, btn);
|
||||
}
|
||||
};
|
||||
|
||||
window._fallbackCopy = function (text, btn) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
btn.textContent = '\u2713 Copi\u00e9';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(function () { btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
|
||||
} catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
};
|
||||
|
||||
// Update active tab class after each HTMX swap on #sys-tab-panel
|
||||
document.body.addEventListener('htmx:afterSwap', function (evt) {
|
||||
if (!(evt.detail.target && evt.detail.target.id === 'sys-tab-panel')) return;
|
||||
var rc = evt.detail.requestConfig;
|
||||
var tab = null;
|
||||
// Tab clicks carry ?tab=… in the path
|
||||
var qIdx = rc.path.indexOf('?');
|
||||
if (qIdx !== -1) {
|
||||
tab = new URLSearchParams(rc.path.substring(qIdx + 1)).get('tab');
|
||||
}
|
||||
// Line-count form sends tab via hx-vals in parameters
|
||||
if (!tab && rc.parameters && rc.parameters.tab) {
|
||||
tab = rc.parameters.tab;
|
||||
}
|
||||
if (!tab) return;
|
||||
document.querySelectorAll('.sys-tabs .sys-tab').forEach(function (a) {
|
||||
var isActive = a.getAttribute('data-tab') === tab;
|
||||
a.classList.toggle('active', isActive);
|
||||
if (isActive) a.setAttribute('aria-current', 'page');
|
||||
else a.removeAttribute('aria-current');
|
||||
});
|
||||
});
|
||||
})();
|
||||
25
app/public/assets/js/app/beforeunload-guard.js
Normal file
25
app/public/assets/js/app/beforeunload-guard.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Beforeunload guard — prompts the user before navigating away from unsaved changes.
|
||||
*
|
||||
* Attach to any form with a data-beforeunload-guard attribute.
|
||||
* Also watches window.__xamxamDirty (set by file-upload-filepond.js on FilePond events).
|
||||
* No effect when JavaScript is unavailable (form posts normally).
|
||||
*/
|
||||
(() => {
|
||||
const forms = document.querySelectorAll('form[data-beforeunload-guard]');
|
||||
if (!forms.length) return;
|
||||
|
||||
let dirty = false;
|
||||
|
||||
for (const form of forms) {
|
||||
form.addEventListener('input', () => { dirty = true; });
|
||||
form.addEventListener('change', () => { dirty = true; });
|
||||
form.addEventListener('submit', () => { dirty = false; window.__xamxamDirty = false; });
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (dirty || window.__xamxamDirty) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
})();
|
||||
38
app/public/assets/js/app/clipboard.js
Normal file
38
app/public/assets/js/app/clipboard.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* clipboard.js — lightweight URL copy helper.
|
||||
*
|
||||
* Usage:
|
||||
* <input type="hidden" id="url-123" value="https://...">
|
||||
* <button onclick="copyUrl(123)">Copier</button>
|
||||
*
|
||||
* Or with a custom selector pattern:
|
||||
* <button onclick="copyUrlFrom(document.getElementById('my-url'))">Copier</button>
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
window.copyUrl = function (id) {
|
||||
var input = document.getElementById('url-' + id);
|
||||
if (input) {
|
||||
window.copyUrlFrom(input);
|
||||
}
|
||||
};
|
||||
|
||||
window.copyUrlFrom = function (sourceEl) {
|
||||
var text = sourceEl.value || sourceEl.textContent || '';
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text).then(function () {
|
||||
var btn = window.event && window.event.target ? window.event.target.closest('button') : null;
|
||||
if (btn) {
|
||||
var origTitle = btn.getAttribute('title') || '';
|
||||
var origText = btn.textContent;
|
||||
btn.setAttribute('title', '\u2713 Copi\u00e9');
|
||||
btn.textContent = '\u2713 Copi\u00e9';
|
||||
setTimeout(function () {
|
||||
btn.setAttribute('title', origTitle);
|
||||
btn.textContent = origText;
|
||||
}, 1200);
|
||||
}
|
||||
});
|
||||
};
|
||||
})();
|
||||
384
app/public/assets/js/app/file-upload-filepond.js
Normal file
384
app/public/assets/js/app/file-upload-filepond.js
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* file-upload-filepond.js
|
||||
*
|
||||
* Thin FilePond wrapper — replaces the old custom file-upload-queue.js.
|
||||
*
|
||||
* 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. storeAsFile: true preserves native multipart form submission.
|
||||
* Server receives files via $_FILES indexed by each input's name attribute
|
||||
* (e.g. queue_file[tfe][], queue_file[video][], etc.).
|
||||
* 4. Type + size validation: via native FilePond options + FileValidateType/Size plugins.
|
||||
* beforeAddFile is used ONLY for hybrid validation (e.g. per-type size limits)
|
||||
* and returns true/false per the FilePond API contract.
|
||||
* 5. Order serialization: hidden inputs track file order from pond.getFiles().
|
||||
* 6. HTMX cleanup: generic destroyFilePondsIn(target) for all swaps, not just known IDs.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// ── 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"
|
||||
],
|
||||
// When PeerTube is active, exclude video/audio from TFE pool
|
||||
acceptedFileTypesPeerTube: [
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||||
"application/pdf",
|
||||
"text/vtt",
|
||||
"application/zip", "application/x-tar", "application/gzip"
|
||||
],
|
||||
labelFileTypeNotAllowed: "Format non accepté",
|
||||
fileValidateTypeLabelExpectedTypes: "PDF, Images, Vidéos, Audio, VTT, Archives",
|
||||
fileValidateTypeLabelExpectedTypesPeerTube: "PDF, Images, VTT, Archives",
|
||||
maxFileSize: "500MB",
|
||||
labelMaxFileSizeExceeded: "Fichier trop volumineux",
|
||||
labelMaxFileSize: "Taille max: {filesize}",
|
||||
allowMultiple: true,
|
||||
// Per-extension size limits: certain types get higher caps.
|
||||
perExtensionMaxSize: {
|
||||
pdf: "100MB",
|
||||
mp4: "2GB", webm: "2GB", ogv: "2GB", mov: "2GB",
|
||||
mp3: "2GB", ogg: "2GB", oga: "2GB", wav: "2GB", flac: "2GB", aac: "2GB", m4a: "2GB"
|
||||
}
|
||||
},
|
||||
video: {
|
||||
acceptedFileTypes: ["video/mp4", "video/webm", "video/ogg", "video/quicktime"],
|
||||
labelFileTypeNotAllowed: "Format non accepté",
|
||||
fileValidateTypeLabelExpectedTypes: "MP4, WebM, OGV, MOV",
|
||||
maxFileSize: "500MB",
|
||||
labelMaxFileSizeExceeded: "Fichier trop volumineux",
|
||||
labelMaxFileSize: "Taille max: {filesize}",
|
||||
allowMultiple: true
|
||||
},
|
||||
audio: {
|
||||
acceptedFileTypes: ["audio/mpeg", "audio/ogg", "audio/flac", "audio/x-wav", "audio/aac", "audio/mp4"],
|
||||
labelFileTypeNotAllowed: "Format non accepté",
|
||||
fileValidateTypeLabelExpectedTypes: "MP3, OGG, FLAC, WAV, AAC, M4A",
|
||||
maxFileSize: "500MB",
|
||||
labelMaxFileSizeExceeded: "Fichier trop volumineux",
|
||||
labelMaxFileSize: "Taille max: {filesize}",
|
||||
allowMultiple: true
|
||||
},
|
||||
annexe: {
|
||||
acceptedFileTypes: ["application/pdf", "application/zip", "application/x-tar", "application/gzip"],
|
||||
labelFileTypeNotAllowed: "Format non accepté",
|
||||
fileValidateTypeLabelExpectedTypes: "PDF, ZIP, TAR, GZ",
|
||||
maxFileSize: "500MB",
|
||||
labelMaxFileSizeExceeded: "Fichier trop volumineux",
|
||||
labelMaxFileSize: "Taille max: {filesize}",
|
||||
allowMultiple: true
|
||||
},
|
||||
cover: {
|
||||
acceptedFileTypes: ["image/jpeg", "image/png", "image/webp"],
|
||||
labelFileTypeNotAllowed: "Seulement JPG, PNG ou WEBP",
|
||||
fileValidateTypeLabelExpectedTypes: "JPG, PNG, WEBP",
|
||||
maxFileSize: "20MB",
|
||||
labelMaxFileSizeExceeded: "Fichier trop volumineux",
|
||||
labelMaxFileSize: "Taille max: {filesize}",
|
||||
allowMultiple: false
|
||||
},
|
||||
note_intention: {
|
||||
acceptedFileTypes: ["application/pdf"],
|
||||
labelFileTypeNotAllowed: "Seulement PDF",
|
||||
fileValidateTypeLabelExpectedTypes: "PDF",
|
||||
maxFileSize: "100MB",
|
||||
labelMaxFileSizeExceeded: "Fichier trop volumineux",
|
||||
labelMaxFileSize: "Taille max: {filesize}",
|
||||
allowMultiple: false
|
||||
},
|
||||
peertube_video: {
|
||||
acceptedFileTypes: ["video/mp4", "video/webm", "video/ogg", "video/quicktime"],
|
||||
labelFileTypeNotAllowed: "Format non accepté",
|
||||
fileValidateTypeLabelExpectedTypes: "MP4, WebM, OGV, MOV",
|
||||
maxFileSize: "500MB",
|
||||
labelMaxFileSizeExceeded: "Fichier trop volumineux",
|
||||
labelMaxFileSize: "Taille max: {filesize}",
|
||||
allowMultiple: true
|
||||
},
|
||||
peertube_audio: {
|
||||
acceptedFileTypes: ["audio/mpeg", "audio/ogg", "audio/flac", "audio/x-wav", "audio/aac", "audio/mp4"],
|
||||
labelFileTypeNotAllowed: "Format non accepté",
|
||||
fileValidateTypeLabelExpectedTypes: "MP3, OGG, FLAC, WAV, AAC, M4A",
|
||||
maxFileSize: "500MB",
|
||||
labelMaxFileSizeExceeded: "Fichier trop volumineux",
|
||||
labelMaxFileSize: "Taille max: {filesize}",
|
||||
allowMultiple: true
|
||||
},
|
||||
};
|
||||
|
||||
// Map input id → queue type
|
||||
var INPUT_ID_TO_TYPE = {
|
||||
"tfe-files-input": "tfe",
|
||||
"tfe-files-input-2": "tfe",
|
||||
"video-files-input": "video",
|
||||
"audio-files-input": "audio",
|
||||
"annexe-files-input": "annexe",
|
||||
"couverture": "cover",
|
||||
"note_intention": "note_intention",
|
||||
"peertube-video-input": "peertube_video",
|
||||
"peertube-audio-input": "peertube_audio",
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a size string like "500MB" or "2GB" to bytes.
|
||||
*/
|
||||
function parseSize(str) {
|
||||
var m = str.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)$/i);
|
||||
if (!m) return 0;
|
||||
var val = parseFloat(m[1]);
|
||||
var unit = m[2].toUpperCase();
|
||||
var mult = {B: 1, KB: 1024, MB: 1024*1024, GB: 1024*1024*1024, TB: 1024*1024*1024*1024};
|
||||
return Math.round(val * (mult[unit] || 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extension from filename (lowercase).
|
||||
*/
|
||||
function getExt(name) {
|
||||
var m = name.match(/\.([^./]+)$/);
|
||||
return m ? m[1].toLowerCase() : "";
|
||||
}
|
||||
|
||||
// ── Order serialization ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create/update a hidden input that serializes the file order for a queue.
|
||||
* Name: queue_order[<queueType>]
|
||||
* Value: pipe-separated list of file names.
|
||||
*/
|
||||
function syncOrderInput(queueType, pond) {
|
||||
if (!pond || !pond.element) return;
|
||||
var form = pond.element.closest("form");
|
||||
if (!form) return;
|
||||
|
||||
var orderInput = form.querySelector("input[name='queue_order[" + queueType + "]']");
|
||||
var files = pond.getFiles();
|
||||
if (files.length === 0) {
|
||||
if (orderInput) orderInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
var names = [];
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
names.push(files[i].filename || files[i].file.name);
|
||||
}
|
||||
|
||||
if (!orderInput) {
|
||||
orderInput = document.createElement("input");
|
||||
orderInput.type = "hidden";
|
||||
orderInput.name = "queue_order[" + queueType + "]";
|
||||
form.appendChild(orderInput);
|
||||
}
|
||||
orderInput.value = names.join("|");
|
||||
}
|
||||
|
||||
// ── 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 || {};
|
||||
|
||||
// When PeerTube is active, restrict TFE pool to PDF/text only
|
||||
var peerTubeActive = queueType === "tfe" && input.dataset.peertubeActive === "1";
|
||||
var acceptedFileTypes = peerTubeActive && cfg.acceptedFileTypesPeerTube
|
||||
? cfg.acceptedFileTypesPeerTube
|
||||
: cfg.acceptedFileTypes;
|
||||
var expectedTypesLabel = peerTubeActive && cfg.fileValidateTypeLabelExpectedTypesPeerTube
|
||||
? cfg.fileValidateTypeLabelExpectedTypesPeerTube
|
||||
: cfg.fileValidateTypeLabelExpectedTypes;
|
||||
|
||||
return {
|
||||
allowMultiple: cfg.allowMultiple,
|
||||
allowReorder: true,
|
||||
allowProcess: false,
|
||||
storeAsFile: true,
|
||||
|
||||
// ── Native FilePond validation ──
|
||||
acceptedFileTypes: acceptedFileTypes,
|
||||
labelFileTypeNotAllowed: cfg.labelFileTypeNotAllowed,
|
||||
fileValidateTypeLabelExpectedTypes: expectedTypesLabel,
|
||||
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 (hybrid: FilePond validates global maxFileSize,
|
||||
// beforeAddFile enforces per-extension limits via false return) ──
|
||||
beforeAddFile: function (item) {
|
||||
var f = item.file;
|
||||
var ext = getExt(f.name);
|
||||
if (ext && perExtMax[ext]) {
|
||||
var limit = parseSize(perExtMax[ext]);
|
||||
if (limit > 0 && f.size > limit) {
|
||||
// Return false per FilePond API contract — the FileValidateSize
|
||||
// plugin sets the error state via maxFileSize, but per-extension
|
||||
// cap violations must be rejected here.
|
||||
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); },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Instance tracking ────────────────────────────────────────────────
|
||||
|
||||
var _ponds = {};
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upgrade .tfe-file-picker inputs to FilePond instances.
|
||||
* Called on page load and after HTMX swaps.
|
||||
*/
|
||||
window.XamxamInitFilePonds = function () {
|
||||
document.querySelectorAll(".tfe-file-picker").forEach(function (input) {
|
||||
// Canonical duplicate check: FilePond.find() is the authoritative source
|
||||
if (FilePond.find(input)) return;
|
||||
|
||||
var id = input.id;
|
||||
var queueType = INPUT_ID_TO_TYPE[id];
|
||||
if (!queueType) {
|
||||
queueType = input.dataset.queueType || null;
|
||||
}
|
||||
if (!queueType) return;
|
||||
|
||||
var options = buildFilePondOptions(queueType, input);
|
||||
if (!options) return;
|
||||
|
||||
options.name = input.getAttribute("name") || input.name || "";
|
||||
|
||||
var pond = FilePond.create(input, options);
|
||||
|
||||
var key = id || queueType;
|
||||
_ponds[key] = pond;
|
||||
|
||||
// Initial order serialization (for existing files in edit mode — none expected)
|
||||
syncOrderInput(queueType, pond);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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(function (input) {
|
||||
var pond = FilePond.find(input);
|
||||
if (pond) {
|
||||
try {
|
||||
// Remove order input before destroying
|
||||
var form = input.closest("form");
|
||||
if (form) {
|
||||
var id = input.id;
|
||||
var queueType = INPUT_ID_TO_TYPE[id] || input.dataset.queueType || null;
|
||||
if (queueType) {
|
||||
var orderInput = form.querySelector("input[name='queue_order[" + queueType + "]']");
|
||||
if (orderInput) orderInput.remove();
|
||||
}
|
||||
}
|
||||
pond.destroy();
|
||||
} catch (_) {}
|
||||
}
|
||||
// Clean up tracking
|
||||
if (input.id && _ponds[input.id]) {
|
||||
delete _ponds[input.id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────────────────
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
if (window.htmx) {
|
||||
window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap);
|
||||
window.htmx.on("htmx:afterSwap", function () {
|
||||
window.XamxamInitFilePonds();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
window.XamxamInitFilePonds();
|
||||
});
|
||||
} else {
|
||||
window.XamxamInitFilePonds();
|
||||
}
|
||||
|
||||
// ── Mark form dirty on FilePond changes (beforeunload guard) ─────────
|
||||
document.addEventListener("FilePond:addfile", function () {
|
||||
window.__xamxamDirty = true;
|
||||
});
|
||||
|
||||
document.addEventListener("submit", function (e) {
|
||||
var form = e.target;
|
||||
if (form && form.hasAttribute && form.hasAttribute("data-beforeunload-guard")) {
|
||||
window.__xamxamDirty = false;
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
171
app/public/assets/js/app/pill-search.js
Normal file
171
app/public/assets/js/app/pill-search.js
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* pill-search.js — generalized pill-based search component for tags and languages.
|
||||
*
|
||||
* Initialisez avec un conteneur ayant l'attribut data-pill-search :
|
||||
* <div data-pill-search data-pill-name="tag" data-pill-max="10" data-pill-min="3" data-pill-required="1">
|
||||
*
|
||||
* DOM attendu à l'intérieur du conteneur :
|
||||
* - .tag-search-pills → conteneur des pills
|
||||
* - .tag-search-input → champ de recherche (avec hx-post, hx-trigger, etc.)
|
||||
* - .tag-search-suggestions → dropdown
|
||||
* - .tag-search-count → compteur
|
||||
* - .tag-search-counter → wrapper du compteur
|
||||
* - .tag-search-input-wrap → wrapper du champ de recherche
|
||||
* - .tag-search-max-msg → message "maximum atteint"
|
||||
*
|
||||
* Options (par attribut data) :
|
||||
* data-pill-name → nom pour les inputs cachés (ex: "tag", "language_autre")
|
||||
* data-pill-max → max pills (default 10)
|
||||
* data-pill-min → min pills requis (default 0)
|
||||
* data-pill-required → si "1", active l'affichage du minimum
|
||||
* data-pill-role → "tag" (lowercase) ou "lang" (ucfirst)
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function initAll() {
|
||||
document.querySelectorAll('[data-pill-search]:not([data-pill-search-initialized])').forEach(function (container) {
|
||||
container.setAttribute('data-pill-search-initialized', '1');
|
||||
initPillSearch(container);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initAll);
|
||||
document.body.addEventListener('htmx:afterSwap', initAll);
|
||||
|
||||
function initPillSearch(container) {
|
||||
var pills = container.querySelector('.tag-search-pills');
|
||||
var search = container.querySelector('.tag-search-input');
|
||||
var dropdown = container.querySelector('.tag-search-suggestions');
|
||||
var countEl = container.querySelector('.tag-search-count');
|
||||
var counter = container.querySelector('.tag-search-counter');
|
||||
var maxTags = parseInt(container.getAttribute('data-pill-max')) || 10;
|
||||
var minTags = parseInt(container.getAttribute('data-pill-min')) || 0;
|
||||
var required = container.getAttribute('data-pill-required') === '1';
|
||||
var inputName = container.getAttribute('data-pill-name') || 'tag';
|
||||
var role = container.getAttribute('data-pill-role') || 'tag';
|
||||
var selectedIdx = -1;
|
||||
|
||||
if (!pills || !search || !dropdown) return;
|
||||
|
||||
function normalize(name) {
|
||||
return name.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
function pillAlreadyExists(name) {
|
||||
var norm = normalize(name);
|
||||
var existing = pills.querySelectorAll('.tag-pill-name');
|
||||
for (var i = 0; i < existing.length; i++) {
|
||||
if (normalize(existing[i].textContent) === norm) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
var n = pills.querySelectorAll('.tag-pill').length;
|
||||
var suffix = required ? ' (min ' + minTags + ')' : '';
|
||||
if (countEl) countEl.textContent = n + '/' + maxTags + suffix;
|
||||
if (counter) counter.style.display = (n > 0 || required) ? '' : 'none';
|
||||
if (countEl && required) {
|
||||
countEl.style.color = n < minTags ? 'var(--text-danger)' : 'var(--accent)';
|
||||
}
|
||||
|
||||
var wrap = container.querySelector('.tag-search-input-wrap');
|
||||
var maxMsg = container.querySelector('.tag-search-max-msg');
|
||||
if (n >= maxTags) {
|
||||
if (wrap) wrap.style.display = 'none';
|
||||
if (maxMsg) maxMsg.style.display = '';
|
||||
} else {
|
||||
if (wrap) { wrap.style.display = ''; if (search) search.style.display = ''; }
|
||||
if (maxMsg) maxMsg.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
pills.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.tag-pill-remove');
|
||||
if (!btn) return;
|
||||
var pill = btn.closest('.tag-pill');
|
||||
pill.remove();
|
||||
updateCount();
|
||||
var wrap = container.querySelector('.tag-search-input-wrap');
|
||||
var inp = container.querySelector('.tag-search-input');
|
||||
if (wrap && inp) { wrap.style.display = ''; inp.style.display = ''; }
|
||||
});
|
||||
|
||||
function highlight(idx) {
|
||||
var items = dropdown.querySelectorAll('.tag-search-item');
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
items[i].classList.toggle('tag-search-item--highlight', i === idx);
|
||||
}
|
||||
}
|
||||
|
||||
function selectPill(btn) {
|
||||
var name = normalize(btn.getAttribute('data-tag-name') || '');
|
||||
if (!name) return;
|
||||
if (pillAlreadyExists(name)) return;
|
||||
if ((pills.querySelectorAll('.tag-pill').length) >= maxTags) return;
|
||||
|
||||
var escaped = htmlEscape(name);
|
||||
var pill = document.createElement('span');
|
||||
pill.className = 'tag-pill';
|
||||
pill.innerHTML = '<input type="hidden" name="' + inputName + '[]" value="' + escaped + '">'
|
||||
+ '<span class="tag-pill-name">' + escaped + '</span>'
|
||||
+ '<button type="button" class="tag-pill-remove" title="Retirer \u00AB\u00A0' + escaped + '\u00A0\u00BB" aria-label="Retirer ' + escaped + '">'
|
||||
+ '<svg width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM112,168V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Zm48,0V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Z"></path></svg>'
|
||||
+ '</button>';
|
||||
pills.appendChild(pill);
|
||||
updateCount();
|
||||
search.value = '';
|
||||
dropdown.innerHTML = '';
|
||||
selectedIdx = -1;
|
||||
search.focus();
|
||||
}
|
||||
|
||||
dropdown.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.tag-search-item');
|
||||
if (!btn) return;
|
||||
selectPill(btn);
|
||||
});
|
||||
|
||||
search.addEventListener('keydown', function (e) {
|
||||
var items = dropdown.querySelectorAll('.tag-search-item');
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (items.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
selectedIdx = (selectedIdx + 1) % items.length;
|
||||
} else {
|
||||
selectedIdx = selectedIdx <= 0 ? items.length - 1 : selectedIdx - 1;
|
||||
}
|
||||
highlight(selectedIdx);
|
||||
} else if (e.key === 'Enter') {
|
||||
if (items.length > 0) {
|
||||
e.preventDefault();
|
||||
if (selectedIdx >= 0 && selectedIdx < items.length) {
|
||||
selectPill(items[selectedIdx]);
|
||||
} else {
|
||||
selectPill(items[0]);
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
dropdown.innerHTML = '';
|
||||
selectedIdx = -1;
|
||||
}
|
||||
});
|
||||
|
||||
search.addEventListener('blur', function () {
|
||||
setTimeout(function () {
|
||||
if (!dropdown.contains(document.activeElement)) {
|
||||
dropdown.innerHTML = '';
|
||||
selectedIdx = -1;
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
|
||||
function htmlEscape(str) {
|
||||
var el = document.createElement('span');
|
||||
el.textContent = str;
|
||||
return el.innerHTML;
|
||||
}
|
||||
}
|
||||
})();
|
||||
185
app/public/assets/js/app/upload-progress.js
Normal file
185
app/public/assets/js/app/upload-progress.js
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* upload-progress.js
|
||||
*
|
||||
* Intercepts admin form submissions with files and submits via XMLHttpRequest.
|
||||
* Polls GET /admin/actions/upload-progress.php?token=xxx for server-side
|
||||
* processing progress (PeerTube uploads, file moves).
|
||||
*
|
||||
* Progress display:
|
||||
* 0%–25% : browser → server upload (XHR upload.progress)
|
||||
* 25%–99% : server processing (polled from progress endpoint)
|
||||
* 100% : response received — "Téléversé avec succès", then redirect
|
||||
*/
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const FORMS = document.querySelectorAll('form[data-upload-progress]');
|
||||
if (!FORMS.length) return;
|
||||
|
||||
const POLL_INTERVAL = 400;
|
||||
const UPLOAD_CAP = 25;
|
||||
const PROCESSING_MAX = 99;
|
||||
const SUCCESS_DELAY = 800;
|
||||
|
||||
for (const form of FORMS) {
|
||||
const progressWrap = form.querySelector('#upload-progress-wrap');
|
||||
const progressBar = form.querySelector('#upload-progress-bar');
|
||||
const progressLabel = form.querySelector('#upload-progress-label');
|
||||
const progressFile = form.querySelector('#upload-progress-file');
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
const tokenInput = form.querySelector('input[name="progress_token"]');
|
||||
|
||||
if (!progressBar || !progressWrap) continue;
|
||||
|
||||
function collectFileNames() {
|
||||
const names = [];
|
||||
// Check raw <input type="file"> elements (non-FilePond or FilePond-managed with storeAsFile)
|
||||
const inputs = form.querySelectorAll('input[type="file"]');
|
||||
for (const fi of inputs) {
|
||||
if (fi.files) {
|
||||
for (const f of fi.files) {
|
||||
if (f.name) names.push(f.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check FilePond instances directly (their storeAsFile hidden inputs may not
|
||||
// have .files populated yet when the submit event fires)
|
||||
if (typeof FilePond !== 'undefined') {
|
||||
const pondInputs = form.querySelectorAll('.tfe-file-picker');
|
||||
for (const pi of pondInputs) {
|
||||
const pond = FilePond.find(pi);
|
||||
if (pond) {
|
||||
const pondFiles = pond.getFiles();
|
||||
for (const pf of pondFiles) {
|
||||
const name = pf.filename || (pf.file && pf.file.name);
|
||||
if (name) names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
const fileNames = collectFileNames();
|
||||
if (!fileNames.length) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const token = tokenInput ? tokenInput.value : '';
|
||||
|
||||
progressWrap.style.display = '';
|
||||
progressBar.value = 0;
|
||||
progressBar.removeAttribute('data-complete');
|
||||
progressLabel.textContent = 'Téléversement en cours…';
|
||||
progressFile.textContent = fileNames.length === 1
|
||||
? fileNames[0]
|
||||
: fileNames.length + ' fichiers';
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
|
||||
const fd = new FormData(form);
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
let uploadDone = false;
|
||||
let lastUploadPct = 0;
|
||||
let pollingTimer = null;
|
||||
|
||||
/** Poll server-side progress */
|
||||
function startPolling() {
|
||||
if (pollingTimer || !token) return;
|
||||
progressLabel.textContent = 'Traitement en cours…';
|
||||
pollingTimer = setInterval(function () {
|
||||
fetch('/admin/actions/upload-progress.php?token=' + encodeURIComponent(token))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data && data.stage && data.stage !== 'upload') {
|
||||
const pct = Math.min(PROCESSING_MAX, Math.max(UPLOAD_CAP, data.pct || UPLOAD_CAP));
|
||||
progressBar.value = pct;
|
||||
if (data.file) {
|
||||
progressFile.textContent = data.file;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function () { /* ignore poll errors */ });
|
||||
}, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer);
|
||||
pollingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function finishSuccess() {
|
||||
stopPolling();
|
||||
progressBar.value = 100;
|
||||
progressBar.setAttribute('data-complete', '');
|
||||
progressLabel.textContent = 'Téléversé avec succès';
|
||||
progressFile.textContent = '';
|
||||
}
|
||||
|
||||
// ── Upload phase (0% → UPLOAD_CAP) ──
|
||||
xhr.upload.addEventListener('progress', function (evt) {
|
||||
if (evt.lengthComputable) {
|
||||
const rawPct = Math.round((evt.loaded / evt.total) * 100);
|
||||
const scaled = Math.round((rawPct / 100) * UPLOAD_CAP);
|
||||
if (scaled > lastUploadPct) {
|
||||
lastUploadPct = scaled;
|
||||
progressBar.value = scaled;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xhr.upload.addEventListener('loadend', function () {
|
||||
uploadDone = true;
|
||||
progressBar.value = UPLOAD_CAP;
|
||||
startPolling();
|
||||
});
|
||||
|
||||
// ── Response handling ──
|
||||
xhr.addEventListener('readystatechange', function () {
|
||||
if (xhr.readyState !== XMLHttpRequest.DONE) return;
|
||||
|
||||
stopPolling();
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
finishSuccess();
|
||||
|
||||
setTimeout(function () {
|
||||
const finalUrl = xhr.responseURL || '';
|
||||
if (finalUrl && finalUrl !== form.action) {
|
||||
window.location.href = finalUrl;
|
||||
} else {
|
||||
document.open();
|
||||
document.write(xhr.responseText);
|
||||
document.close();
|
||||
}
|
||||
}, SUCCESS_DELAY);
|
||||
} else {
|
||||
progressLabel.textContent = 'Erreur';
|
||||
progressFile.textContent = 'Échec du téléversement';
|
||||
document.open();
|
||||
document.write(xhr.responseText);
|
||||
document.close();
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', function () {
|
||||
stopPolling();
|
||||
progressLabel.textContent = 'Erreur réseau';
|
||||
progressFile.textContent = '';
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
});
|
||||
|
||||
xhr.addEventListener('abort', function () {
|
||||
stopPolling();
|
||||
progressWrap.style.display = 'none';
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
});
|
||||
|
||||
xhr.open('POST', form.action, true);
|
||||
xhr.send(fd);
|
||||
});
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user