mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
Add autosave draft system for partage form with HTMX-based session persistence
- New fragment endpoint POST/GET /partage/fragments/draft.php: saves all form fields to PHP session, excludes file/csrf/slug fields GET returns JSON for JS hydration on page load rotates both global CSRF and share CSRF tokens in sync - form.php accepts optional $formExtraAttrs and $showAutosaveStatus: allows injecting HTMX attributes and 'Brouillon enregistré' indicator - renderShareLinkForm adds hx-post with change/input debounce trigger, loads autosave-handler.js, hydrate fields from draft on page load - Draft cleared on successful form submission in handleShareLinkSubmission - autosave-handler.js now also updates share_link_token hidden input when rotating CSRF token (partage form uses both csrf_token and share_link_token) - Added .autosave-status CSS to form.css (was admin.css-only) - Updated fragment routing to accept GET requests (needed for draft hydration)
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@ return (new Config())
|
|||||||
->setFinder(
|
->setFinder(
|
||||||
(new Finder())
|
(new Finder())
|
||||||
->in(__DIR__ . '/app/src')
|
->in(__DIR__ . '/app/src')
|
||||||
->in(__DIR__ . '/app/tests')
|
->in(__DIR__ . '/tests')
|
||||||
->name('*.php')
|
->name('*.php')
|
||||||
)
|
)
|
||||||
;
|
;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -6,25 +6,31 @@
|
|||||||
*
|
*
|
||||||
* Provides visual feedback on the originating button.
|
* Provides visual feedback on the originating button.
|
||||||
*/
|
*/
|
||||||
(function () {
|
(() => {
|
||||||
'use strict';
|
window.copyTextToClipboard = (text) => {
|
||||||
|
var btn;
|
||||||
window.copyTextToClipboard = function (text) {
|
var origTitle;
|
||||||
if (!text) return;
|
var origHTML;
|
||||||
navigator.clipboard.writeText(text).then(function () {
|
if (!text) return;
|
||||||
var btn = window.event && window.event.target ? window.event.target.closest('button') : null;
|
navigator.clipboard
|
||||||
if (btn) {
|
.writeText(text)
|
||||||
var origTitle = btn.getAttribute('title') || '';
|
.then(() => {
|
||||||
var origHTML = btn.innerHTML;
|
btn = window.event?.target
|
||||||
btn.setAttribute('title', '\u2713 Copi\u00e9');
|
? window.event.target.closest("button")
|
||||||
btn.innerHTML = '\u2713';
|
: null;
|
||||||
setTimeout(function () {
|
if (btn) {
|
||||||
btn.setAttribute('title', origTitle);
|
origTitle = btn.getAttribute("title") || "";
|
||||||
btn.innerHTML = origHTML;
|
origHTML = btn.innerHTML;
|
||||||
}, 1200);
|
btn.setAttribute("title", "\u2713 Copi\u00e9");
|
||||||
}
|
btn.innerHTML = "\u2713";
|
||||||
}).catch(function () {
|
setTimeout(() => {
|
||||||
// Clipboard write failed — silently ignore
|
btn.setAttribute("title", origTitle);
|
||||||
});
|
btn.innerHTML = origHTML;
|
||||||
};
|
}, 1200);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Clipboard write failed — silently ignore
|
||||||
|
});
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -12,86 +12,90 @@
|
|||||||
* #access-justification — justification textarea
|
* #access-justification — justification textarea
|
||||||
* #access-request-message — message display div
|
* #access-request-message — message display div
|
||||||
*/
|
*/
|
||||||
(function () {
|
(() => {
|
||||||
'use strict';
|
var form = document.getElementById("access-request-form");
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
var form = document.getElementById('access-request-form');
|
var emailInput = document.getElementById("access-email");
|
||||||
if (!form) return;
|
var justificationContainer = document.getElementById(
|
||||||
|
"justification-container",
|
||||||
|
);
|
||||||
|
var justificationInput = document.getElementById("access-justification");
|
||||||
|
var messageDiv = document.getElementById("access-request-message");
|
||||||
|
|
||||||
var emailInput = document.getElementById('access-email');
|
if (!emailInput || !messageDiv) return;
|
||||||
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;
|
||||||
|
});
|
||||||
|
|
||||||
// Show/hide justification based on email domain
|
function showRetryPrompt(rejectedEmail, serverMessage) {
|
||||||
emailInput.addEventListener('input', function () {
|
messageDiv.style.display = "block";
|
||||||
var email = this.value.trim().toLowerCase();
|
messageDiv.className = "tfe-access-message tfe-access-error";
|
||||||
var isErg = email.endsWith('@erg.school') || email.endsWith('@erg.be');
|
messageDiv.innerHTML =
|
||||||
if (justificationContainer) justificationContainer.style.display = isErg ? 'none' : 'block';
|
"<strong>Adresse e-mail introuvable sur le serveur de l'ERG.</strong><br>" +
|
||||||
if (justificationInput) justificationInput.required = !isErg;
|
"<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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function showRetryPrompt(rejectedEmail, serverMessage) {
|
form.addEventListener("submit", (e) => {
|
||||||
messageDiv.style.display = 'block';
|
e.preventDefault();
|
||||||
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) {
|
var submitBtn = form.querySelector('button[type="submit"]');
|
||||||
e.preventDefault();
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = "Envoi en cours...";
|
||||||
|
messageDiv.style.display = "none";
|
||||||
|
|
||||||
var submitBtn = form.querySelector('button[type="submit"]');
|
var submittedEmail = emailInput.value.trim();
|
||||||
submitBtn.disabled = true;
|
var formData = new FormData(form);
|
||||||
submitBtn.textContent = 'Envoi en cours...';
|
formData.append("thesis_id", form.getAttribute("data-thesis-id"));
|
||||||
messageDiv.style.display = 'none';
|
|
||||||
|
|
||||||
var submittedEmail = emailInput.value.trim();
|
fetch("/request-access", {
|
||||||
var formData = new FormData(form);
|
method: "POST",
|
||||||
formData.append('thesis_id', form.getAttribute('data-thesis-id'));
|
body: formData,
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = "Demander l'accès";
|
||||||
|
|
||||||
fetch('/request-access', {
|
if (data.status === "recipient_rejected") {
|
||||||
method: 'POST',
|
showRetryPrompt(submittedEmail, data.message);
|
||||||
body: formData
|
return;
|
||||||
})
|
}
|
||||||
.then(function (response) { return response.json(); })
|
|
||||||
.then(function (data) {
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.textContent = 'Demander l\'accès';
|
|
||||||
|
|
||||||
if (data.status === 'recipient_rejected') {
|
messageDiv.style.display = "block";
|
||||||
showRetryPrompt(submittedEmail, data.message);
|
if (data.success) {
|
||||||
return;
|
messageDiv.className = "tfe-access-message tfe-access-success";
|
||||||
}
|
messageDiv.textContent = data.message;
|
||||||
|
form.reset();
|
||||||
messageDiv.style.display = 'block';
|
} else {
|
||||||
if (data.success) {
|
messageDiv.className = "tfe-access-message tfe-access-error";
|
||||||
messageDiv.className = 'tfe-access-message tfe-access-success';
|
messageDiv.textContent =
|
||||||
messageDiv.textContent = data.message;
|
data.message || "Une erreur est survenue. Veuillez réessayer.";
|
||||||
form.reset();
|
}
|
||||||
} else {
|
})
|
||||||
messageDiv.className = 'tfe-access-message tfe-access-error';
|
.catch(() => {
|
||||||
messageDiv.textContent = data.message || 'Une erreur est survenue. Veuillez réessayer.';
|
submitBtn.disabled = false;
|
||||||
}
|
submitBtn.textContent = "Demander l'accès";
|
||||||
})
|
messageDiv.style.display = "block";
|
||||||
.catch(function () {
|
messageDiv.className = "tfe-access-message tfe-access-error";
|
||||||
submitBtn.disabled = false;
|
messageDiv.textContent = "Erreur de connexion. Veuillez réessayer.";
|
||||||
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.';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -5,60 +5,66 @@
|
|||||||
* - copyLogContent(btn) — copy visible log lines to clipboard
|
* - copyLogContent(btn) — copy visible log lines to clipboard
|
||||||
* - HTMX afterSwap handler to update active tab class on #sys-tab-panel
|
* - HTMX afterSwap handler to update active tab class on #sys-tab-panel
|
||||||
*/
|
*/
|
||||||
(function () {
|
(() => {
|
||||||
'use strict';
|
window.copyLogContent = (btn) => {
|
||||||
|
var logOut = document.querySelector("#log-output");
|
||||||
|
if (!logOut) return;
|
||||||
|
var text = Array.from(logOut.querySelectorAll(".log-line"))
|
||||||
|
.map((el) => el.textContent)
|
||||||
|
.join("\n");
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
btn.textContent = "\u2713 Copi\u00e9";
|
||||||
|
btn.classList.add("copied");
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = "Copier";
|
||||||
|
btn.classList.remove("copied");
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window._fallbackCopy(text, btn);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.copyLogContent = function (btn) {
|
window._fallbackCopy = (text, btn) => {
|
||||||
var logOut = document.querySelector('#log-output');
|
var ta = document.createElement("textarea");
|
||||||
if (!logOut) return;
|
ta.value = text;
|
||||||
var text = Array.from(logOut.querySelectorAll('.log-line'))
|
ta.style.cssText = "position:fixed;opacity:0";
|
||||||
.map(function (el) { return el.textContent; }).join('\n');
|
document.body.appendChild(ta);
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
ta.select();
|
||||||
navigator.clipboard.writeText(text).then(function () {
|
try {
|
||||||
btn.textContent = '\u2713 Copi\u00e9';
|
document.execCommand("copy");
|
||||||
btn.classList.add('copied');
|
btn.textContent = "\u2713 Copi\u00e9";
|
||||||
setTimeout(function () { btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
|
btn.classList.add("copied");
|
||||||
});
|
setTimeout(() => {
|
||||||
} else {
|
btn.textContent = "Copier";
|
||||||
window._fallbackCopy(text, btn);
|
btn.classList.remove("copied");
|
||||||
}
|
}, 2000);
|
||||||
};
|
} catch (_e) {}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
};
|
||||||
|
|
||||||
window._fallbackCopy = function (text, btn) {
|
// Update active tab class after each HTMX swap on #sys-tab-panel
|
||||||
var ta = document.createElement('textarea');
|
document.body.addEventListener("htmx:afterSwap", (evt) => {
|
||||||
ta.value = text;
|
if (!(evt.detail.target && evt.detail.target.id === "sys-tab-panel"))
|
||||||
ta.style.cssText = 'position:fixed;opacity:0';
|
return;
|
||||||
document.body.appendChild(ta);
|
var rc = evt.detail.requestConfig;
|
||||||
ta.select();
|
var tab = null;
|
||||||
try {
|
// Tab clicks carry ?tab=… in the path
|
||||||
document.execCommand('copy');
|
var qIdx = rc.path.indexOf("?");
|
||||||
btn.textContent = '\u2713 Copi\u00e9';
|
if (qIdx !== -1) {
|
||||||
btn.classList.add('copied');
|
tab = new URLSearchParams(rc.path.substring(qIdx + 1)).get("tab");
|
||||||
setTimeout(function () { btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
|
}
|
||||||
} catch (e) {}
|
// Line-count form sends tab via hx-vals in parameters
|
||||||
document.body.removeChild(ta);
|
if (!tab && rc.parameters && rc.parameters.tab) {
|
||||||
};
|
tab = rc.parameters.tab;
|
||||||
|
}
|
||||||
// Update active tab class after each HTMX swap on #sys-tab-panel
|
if (!tab) return;
|
||||||
document.body.addEventListener('htmx:afterSwap', function (evt) {
|
document.querySelectorAll(".sys-tabs .sys-tab").forEach((a) => {
|
||||||
if (!(evt.detail.target && evt.detail.target.id === 'sys-tab-panel')) return;
|
var isActive = a.getAttribute("data-tab") === tab;
|
||||||
var rc = evt.detail.requestConfig;
|
a.classList.toggle("active", isActive);
|
||||||
var tab = null;
|
if (isActive) a.setAttribute("aria-current", "page");
|
||||||
// Tab clicks carry ?tab=… in the path
|
else a.removeAttribute("aria-current");
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -5,58 +5,62 @@
|
|||||||
* parse errors instead of silently swallowing them (unlike the
|
* parse errors instead of silently swallowing them (unlike the
|
||||||
* old autosave.js .catch(() => {}) pattern).
|
* old autosave.js .catch(() => {}) pattern).
|
||||||
*/
|
*/
|
||||||
function handleAutosaveResponse(event) {
|
function _handleAutosaveResponse(event) {
|
||||||
const form = event.target.closest("form");
|
const form = event.target.closest("form");
|
||||||
const status = form ? form.querySelector("[data-autosave-status]") : null;
|
const status = form ? form.querySelector("[data-autosave-status]") : null;
|
||||||
|
|
||||||
if (!event.detail.successful) {
|
if (!event.detail.successful) {
|
||||||
if (status) {
|
if (status) {
|
||||||
status.textContent = "Erreur !";
|
status.textContent = "Erreur !";
|
||||||
status.className = "autosave-status autosave-status--error";
|
status.className = "autosave-status autosave-status--error";
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.detail.xhr.responseText);
|
const data = JSON.parse(event.detail.xhr.responseText);
|
||||||
|
|
||||||
// Rotate CSRF token in both the form and the meta tag
|
// Rotate CSRF token in the form, any share_link_token fields, and the meta tag
|
||||||
if (data.csrf_token) {
|
if (data.csrf_token) {
|
||||||
const csrfInput = form.querySelector('input[name="csrf_token"]');
|
const csrfInput = form.querySelector('input[name="csrf_token"]');
|
||||||
if (csrfInput) csrfInput.value = data.csrf_token;
|
if (csrfInput) csrfInput.value = data.csrf_token;
|
||||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
const shareTokenInput = form.querySelector(
|
||||||
if (meta) meta.content = data.csrf_token;
|
'input[name="share_link_token"]',
|
||||||
}
|
);
|
||||||
|
if (shareTokenInput) shareTokenInput.value = data.csrf_token;
|
||||||
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (meta) meta.content = data.csrf_token;
|
||||||
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
status.textContent = "Enregistré ✓";
|
status.textContent = "Enregistré ✓";
|
||||||
status.className = "autosave-status autosave-status--saved";
|
status.className = "autosave-status autosave-status--saved";
|
||||||
} else {
|
} else {
|
||||||
status.textContent = "Erreur !";
|
status.textContent = "Erreur !";
|
||||||
status.className = "autosave-status autosave-status--error";
|
status.className = "autosave-status autosave-status--error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// JSON parse failed (e.g. PHP warning in output) — surface it
|
// JSON parse failed (e.g. PHP warning in output) — surface it
|
||||||
if (status) {
|
if (status) {
|
||||||
status.textContent = "Erreur !";
|
status.textContent = "Erreur !";
|
||||||
status.className = "autosave-status autosave-status--error";
|
status.className = "autosave-status autosave-status--error";
|
||||||
}
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
"Autosave: could not parse response",
|
"Autosave: could not parse response",
|
||||||
event.detail.xhr.responseText,
|
event.detail.xhr.responseText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show saving indicator while request is in flight
|
// Show saving indicator while request is in flight
|
||||||
document.body.addEventListener("htmx:beforeRequest", (e) => {
|
document.body.addEventListener("htmx:beforeRequest", (e) => {
|
||||||
const el = e.target;
|
const el = e.target;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const status = el.querySelector("[data-autosave-status]");
|
const status = el.querySelector("[data-autosave-status]");
|
||||||
if (status) {
|
if (status) {
|
||||||
status.textContent = "Enregistrement…";
|
status.textContent = "Enregistrement…";
|
||||||
status.className = "autosave-status autosave-status--saving";
|
status.className = "autosave-status autosave-status--saving";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,20 +6,27 @@
|
|||||||
* No effect when JavaScript is unavailable (form posts normally).
|
* No effect when JavaScript is unavailable (form posts normally).
|
||||||
*/
|
*/
|
||||||
(() => {
|
(() => {
|
||||||
const forms = document.querySelectorAll('form[data-beforeunload-guard]');
|
const forms = document.querySelectorAll("form[data-beforeunload-guard]");
|
||||||
if (!forms.length) return;
|
if (!forms.length) return;
|
||||||
|
|
||||||
let dirty = false;
|
let dirty = false;
|
||||||
|
|
||||||
for (const form of forms) {
|
for (const form of forms) {
|
||||||
form.addEventListener('input', () => { dirty = true; });
|
form.addEventListener("input", () => {
|
||||||
form.addEventListener('change', () => { dirty = true; });
|
dirty = true;
|
||||||
form.addEventListener('submit', () => { dirty = false; window.__xamxamDirty = false; });
|
});
|
||||||
}
|
form.addEventListener("change", () => {
|
||||||
|
dirty = true;
|
||||||
|
});
|
||||||
|
form.addEventListener("submit", () => {
|
||||||
|
dirty = false;
|
||||||
|
window.__xamxamDirty = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('beforeunload', (e) => {
|
window.addEventListener("beforeunload", (e) => {
|
||||||
if (dirty || window.__xamxamDirty) {
|
if (dirty || window.__xamxamDirty) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -8,31 +8,32 @@
|
|||||||
* Or with a custom selector pattern:
|
* Or with a custom selector pattern:
|
||||||
* <button onclick="copyUrlFrom(document.getElementById('my-url'))">Copier</button>
|
* <button onclick="copyUrlFrom(document.getElementById('my-url'))">Copier</button>
|
||||||
*/
|
*/
|
||||||
(function () {
|
(() => {
|
||||||
'use strict';
|
window.copyUrl = (id) => {
|
||||||
|
var input = document.getElementById(`url-${id}`);
|
||||||
|
if (input) {
|
||||||
|
window.copyUrlFrom(input);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.copyUrl = function (id) {
|
window.copyUrlFrom = (sourceEl) => {
|
||||||
var input = document.getElementById('url-' + id);
|
var text = sourceEl.value || sourceEl.textContent || "";
|
||||||
if (input) {
|
var btn;
|
||||||
window.copyUrlFrom(input);
|
var origTitle;
|
||||||
}
|
var origText;
|
||||||
};
|
if (!text) return;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
window.copyUrlFrom = function (sourceEl) {
|
btn = window.event?.target ? window.event.target.closest("button") : null;
|
||||||
var text = sourceEl.value || sourceEl.textContent || '';
|
if (btn) {
|
||||||
if (!text) return;
|
origTitle = btn.getAttribute("title") || "";
|
||||||
navigator.clipboard.writeText(text).then(function () {
|
origText = btn.textContent;
|
||||||
var btn = window.event && window.event.target ? window.event.target.closest('button') : null;
|
btn.setAttribute("title", "\u2713 Copi\u00e9");
|
||||||
if (btn) {
|
btn.textContent = "\u2713 Copi\u00e9";
|
||||||
var origTitle = btn.getAttribute('title') || '';
|
setTimeout(() => {
|
||||||
var origText = btn.textContent;
|
btn.setAttribute("title", origTitle);
|
||||||
btn.setAttribute('title', '\u2713 Copi\u00e9');
|
btn.textContent = origText;
|
||||||
btn.textContent = '\u2713 Copi\u00e9';
|
}, 1200);
|
||||||
setTimeout(function () {
|
}
|
||||||
btn.setAttribute('title', origTitle);
|
});
|
||||||
btn.textContent = origText;
|
};
|
||||||
}, 1200);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -56,8 +56,8 @@
|
|||||||
// so numeric literals are required (string suffixes like "1GB" become
|
// so numeric literals are required (string suffixes like "1GB" become
|
||||||
// parseInt("1GB") = 1 byte inside the plugin).
|
// parseInt("1GB") = 1 byte inside the plugin).
|
||||||
perExtensionMaxSize: {
|
perExtensionMaxSize: {
|
||||||
pdf: 104857600, // 100 MB
|
pdf: 104857600, // 100 MB
|
||||||
mp4: 8589934592, // 8 GB
|
mp4: 8589934592, // 8 GB
|
||||||
webm: 8589934592,
|
webm: 8589934592,
|
||||||
ogv: 8589934592,
|
ogv: 8589934592,
|
||||||
mov: 8589934592,
|
mov: 8589934592,
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
*/
|
*/
|
||||||
function parseSize(str) {
|
function parseSize(str) {
|
||||||
// Already a number (bytes) — pass through
|
// Already a number (bytes) — pass through
|
||||||
if (typeof str === 'number') return str;
|
if (typeof str === "number") return str;
|
||||||
var m = str.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)$/i);
|
var m = str.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)$/i);
|
||||||
if (!m) return 0;
|
if (!m) return 0;
|
||||||
var val = parseFloat(m[1]);
|
var val = parseFloat(m[1]);
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
var oldHidden = form.querySelectorAll(
|
var oldHidden = form.querySelectorAll(
|
||||||
`input[name='queue_file[${queueType}][]'][data-filepond-id]`,
|
`input[name='queue_file[${queueType}][]'][data-filepond-id]`,
|
||||||
);
|
);
|
||||||
for (var h = 0; h < oldHidden.length; h++) {
|
for (let h = 0; h < oldHidden.length; h++) {
|
||||||
oldHidden[h].remove();
|
oldHidden[h].remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,13 +195,13 @@
|
|||||||
|
|
||||||
// Create hidden inputs per file: queue_file[<queueType>][] = serverId
|
// Create hidden inputs per file: queue_file[<queueType>][] = serverId
|
||||||
var ids = [];
|
var ids = [];
|
||||||
for (var i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
var f = files[i];
|
const f = files[i];
|
||||||
// Only include files that have been uploaded and have a serverId
|
// Only include files that have been uploaded and have a serverId
|
||||||
var id = f.serverId || null;
|
const id = f.serverId || null;
|
||||||
if (id) {
|
if (id) {
|
||||||
ids.push(id);
|
ids.push(id);
|
||||||
var hidden = document.createElement("input");
|
const hidden = document.createElement("input");
|
||||||
hidden.type = "hidden";
|
hidden.type = "hidden";
|
||||||
hidden.name = `queue_file[${queueType}][]`;
|
hidden.name = `queue_file[${queueType}][]`;
|
||||||
hidden.value = id;
|
hidden.value = id;
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
|
|
||||||
// Create order input
|
// Create order input
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
var orderInput = document.createElement("input");
|
const orderInput = document.createElement("input");
|
||||||
orderInput.type = "hidden";
|
orderInput.type = "hidden";
|
||||||
orderInput.name = `queue_order[${queueType}]`;
|
orderInput.name = `queue_order[${queueType}]`;
|
||||||
orderInput.value = ids.join("|");
|
orderInput.value = ids.join("|");
|
||||||
@@ -257,8 +257,11 @@
|
|||||||
// return a distinguishable error marker instead of a valid serverId.
|
// return a distinguishable error marker instead of a valid serverId.
|
||||||
// Throwing here crashes FilePond internally (no try/catch in the wrapper).
|
// Throwing here crashes FilePond internally (no try/catch in the wrapper).
|
||||||
if (id.length > 64 || /[<>\n\r]/.test(id)) {
|
if (id.length > 64 || /[<>\n\r]/.test(id)) {
|
||||||
console.error("[filepond] process onload | unexpected response | body=" + id.substring(0, 200));
|
console.error(
|
||||||
return "__error__" + id.substring(0, 32);
|
"[filepond] process onload | unexpected response | body=" +
|
||||||
|
id.substring(0, 200),
|
||||||
|
);
|
||||||
|
return `__error__${id.substring(0, 32)}`;
|
||||||
}
|
}
|
||||||
console.log(`[filepond] process onload | serverId=${id}`);
|
console.log(`[filepond] process onload | serverId=${id}`);
|
||||||
return id; // file_id stored as serverId
|
return id; // file_id stored as serverId
|
||||||
@@ -266,8 +269,13 @@
|
|||||||
onerror: (response) => {
|
onerror: (response) => {
|
||||||
// response is the raw XHR response text (string), not an XHR object.
|
// response is the raw XHR response text (string), not an XHR object.
|
||||||
// Log it and return a human-readable error message.
|
// Log it and return a human-readable error message.
|
||||||
var body = typeof response === 'string' ? response : (response && response.body ? response.body : String(response || ''));
|
var body =
|
||||||
console.error("[filepond] process onerror | body=" + 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.";
|
return body || "Erreur lors du téléversement.";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -280,7 +288,7 @@
|
|||||||
console.log("[filepond] revert OK");
|
console.log("[filepond] revert OK");
|
||||||
},
|
},
|
||||||
onerror: (r) => {
|
onerror: (r) => {
|
||||||
var body = typeof r === 'string' ? r : (r && r.body ? r.body : '');
|
var body = typeof r === "string" ? r : r?.body ? r.body : "";
|
||||||
console.error(`[filepond] revert ERROR | body=${body || r}`);
|
console.error(`[filepond] revert ERROR | body=${body || r}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -293,8 +301,13 @@
|
|||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
onerror: (response) => {
|
onerror: (response) => {
|
||||||
var body = typeof response === 'string' ? response : (response && response.body ? response.body : String(response || ''));
|
var body =
|
||||||
console.error("[filepond] load onerror | body=" + 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 a descriptive error — FilePond will fire an error event.
|
||||||
return body || "Fichier introuvable.";
|
return body || "Fichier introuvable.";
|
||||||
},
|
},
|
||||||
@@ -311,7 +324,12 @@
|
|||||||
body: source,
|
body: source,
|
||||||
})
|
})
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
console.log("[filepond] revert (from remove) response | ok=" + r.ok + " | status=" + r.status);
|
console.log(
|
||||||
|
"[filepond] revert (from remove) response | ok=" +
|
||||||
|
r.ok +
|
||||||
|
" | status=" +
|
||||||
|
r.status,
|
||||||
|
);
|
||||||
r.ok ? load() : error("Erreur suppression");
|
r.ok ? load() : error("Erreur suppression");
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -403,7 +421,7 @@
|
|||||||
var f = item.file;
|
var f = item.file;
|
||||||
var ext = getExt(f.name);
|
var ext = getExt(f.name);
|
||||||
if (ext && perExtMax[ext]) {
|
if (ext && perExtMax[ext]) {
|
||||||
var limit = parseSize(perExtMax[ext]);
|
const limit = parseSize(perExtMax[ext]);
|
||||||
if (limit > 0 && f.size > limit) {
|
if (limit > 0 && f.size > limit) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -503,20 +521,20 @@
|
|||||||
if (pond) {
|
if (pond) {
|
||||||
try {
|
try {
|
||||||
// Remove order/hidden inputs before destroying
|
// Remove order/hidden inputs before destroying
|
||||||
var form = input.closest("form");
|
const form = input.closest("form");
|
||||||
if (form) {
|
if (form) {
|
||||||
var queueType = input.dataset.queueType || null;
|
const queueType = input.dataset.queueType || null;
|
||||||
if (queueType) {
|
if (queueType) {
|
||||||
var orderInput = form.querySelector(
|
const orderInput = form.querySelector(
|
||||||
`input[name='queue_order[${queueType}]']`,
|
`input[name='queue_order[${queueType}]']`,
|
||||||
);
|
);
|
||||||
if (orderInput) orderInput.remove();
|
if (orderInput) orderInput.remove();
|
||||||
var hiddenInputs = form.querySelectorAll(
|
const hiddenInputs = form.querySelectorAll(
|
||||||
"input[name='queue_file[" +
|
"input[name='queue_file[" +
|
||||||
queueType +
|
queueType +
|
||||||
"][]'][data-filepond-id]",
|
"][]'][data-filepond-id]",
|
||||||
);
|
);
|
||||||
for (var h = 0; h < hiddenInputs.length; h++) {
|
for (let h = 0; h < hiddenInputs.length; h++) {
|
||||||
hiddenInputs[h].remove();
|
hiddenInputs[h].remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,14 +542,16 @@
|
|||||||
// Abort any in-flight uploads before destroying to prevent
|
// Abort any in-flight uploads before destroying to prevent
|
||||||
// FilePond internal crashes when XHR callbacks fire on a
|
// FilePond internal crashes when XHR callbacks fire on a
|
||||||
// torn-down instance ("can't access property main").
|
// torn-down instance ("can't access property main").
|
||||||
var files = pond.getFiles();
|
const files = pond.getFiles();
|
||||||
for (var i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
var f = files[i];
|
const f = files[i];
|
||||||
if (f.status === 4 || f.status === 2 || f.status === 3) {
|
if (f.status === 4 || f.status === 2 || f.status === 3) {
|
||||||
// FileStatus: PROCESSING=4, PROCESSING_QUEUED=2, PROCESSING=4
|
// FileStatus: PROCESSING=4, PROCESSING_QUEUED=2, PROCESSING=4
|
||||||
// (FilePond 4.x internal: 4 = processing)
|
// (FilePond 4.x internal: 4 = processing)
|
||||||
// Abort processing to avoid stale XHR callbacks
|
// Abort processing to avoid stale XHR callbacks
|
||||||
try { pond.removeFile(f); } catch (_abort) {}
|
try {
|
||||||
|
pond.removeFile(f);
|
||||||
|
} catch (_abort) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pond.destroy();
|
pond.destroy();
|
||||||
@@ -612,13 +632,15 @@
|
|||||||
setTimeout(tryRegisterHtmx, 50);
|
setTimeout(tryRegisterHtmx, 50);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('[filepond] htmx detected, registering swap listeners');
|
console.log("[filepond] htmx detected, registering swap listeners");
|
||||||
window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap);
|
window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap);
|
||||||
window.htmx.on("htmx:afterSwap", () => {
|
window.htmx.on("htmx:afterSwap", () => {
|
||||||
enableFilepondMode();
|
enableFilepondMode();
|
||||||
_xamxamFilepondReady = false;
|
_xamxamFilepondReady = false;
|
||||||
window.XamxamInitFilePonds();
|
window.XamxamInitFilePonds();
|
||||||
setTimeout(() => { _xamxamFilepondReady = true; }, 0);
|
setTimeout(() => {
|
||||||
|
_xamxamFilepondReady = true;
|
||||||
|
}, 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// ── Enable filepond_mode hidden input (no-JS safety) ────────────────
|
// ── Enable filepond_mode hidden input (no-JS safety) ────────────────
|
||||||
@@ -627,7 +649,7 @@
|
|||||||
// will handle uploads asynchronously.
|
// will handle uploads asynchronously.
|
||||||
function enableFilepondMode() {
|
function enableFilepondMode() {
|
||||||
var inputs = document.querySelectorAll("input[name='filepond_mode']");
|
var inputs = document.querySelectorAll("input[name='filepond_mode']");
|
||||||
for (var i = 0; i < inputs.length; i++) {
|
for (let i = 0; i < inputs.length; i++) {
|
||||||
inputs[i].disabled = false;
|
inputs[i].disabled = false;
|
||||||
inputs[i].value = "1";
|
inputs[i].value = "1";
|
||||||
}
|
}
|
||||||
@@ -675,48 +697,81 @@
|
|||||||
* The file browser is loaded inside #relink-modal-body via HTMX.
|
* The file browser is loaded inside #relink-modal-body via HTMX.
|
||||||
*/
|
*/
|
||||||
window.XamxamRelinkFile = (el) => {
|
window.XamxamRelinkFile = (el) => {
|
||||||
var li = el.closest('.file-browser-entry');
|
var li = el.closest(".file-browser-entry");
|
||||||
console.log('[relink] XamxamRelinkFile called | el=', el, '| li=', li);
|
console.log("[relink] XamxamRelinkFile called | el=", el, "| li=", li);
|
||||||
if (!li) return;
|
if (!li) return;
|
||||||
|
|
||||||
var ctx = window.__xamxamRelinkCtx || {};
|
var ctx = window.__xamxamRelinkCtx || {};
|
||||||
var thesisId = ctx.thesisId;
|
var thesisId = ctx.thesisId;
|
||||||
var queueType = ctx.queueType;
|
var queueType = ctx.queueType;
|
||||||
|
|
||||||
var filePath = li.dataset.filePath;
|
var filePath = li.dataset.filePath;
|
||||||
var fileName = li.dataset.fileName;
|
var fileName = li.dataset.fileName;
|
||||||
var fileSize = parseInt(li.dataset.fileSize, 10) || 0;
|
var fileSize = parseInt(li.dataset.fileSize, 10) || 0;
|
||||||
var ext = li.dataset.fileExt || '';
|
var ext = li.dataset.fileExt || "";
|
||||||
|
|
||||||
console.log('[relink] data | thesisId=' + thesisId + ' | queueType=' + queueType + ' | filePath=' + filePath + ' | fileName=' + fileName + ' | ext=' + ext);
|
console.log(
|
||||||
|
"[relink] data | thesisId=" +
|
||||||
|
thesisId +
|
||||||
|
" | queueType=" +
|
||||||
|
queueType +
|
||||||
|
" | filePath=" +
|
||||||
|
filePath +
|
||||||
|
" | fileName=" +
|
||||||
|
fileName +
|
||||||
|
" | ext=" +
|
||||||
|
ext,
|
||||||
|
);
|
||||||
|
|
||||||
if (!filePath || !thesisId || !queueType) {
|
if (!filePath || !thesisId || !queueType) {
|
||||||
console.error('[relink] missing data', { filePath, thesisId, queueType });
|
console.error("[relink] missing data", { filePath, thesisId, queueType });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine MIME from extension
|
// Determine MIME from extension
|
||||||
var mimeMap = {
|
var mimeMap = {
|
||||||
pdf: 'application/pdf',
|
pdf: "application/pdf",
|
||||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif',
|
jpg: "image/jpeg",
|
||||||
mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', mov: 'video/quicktime',
|
jpeg: "image/jpeg",
|
||||||
mp3: 'audio/mpeg', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac', aac: 'audio/aac', m4a: 'audio/mp4',
|
png: "image/png",
|
||||||
vtt: 'text/vtt',
|
webp: "image/webp",
|
||||||
zip: 'application/zip', tar: 'application/x-tar', gz: 'application/gzip',
|
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 mimeType = mimeMap[ext] || "application/octet-stream";
|
||||||
|
|
||||||
var csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
var csrfToken =
|
||||||
console.log('[relink] csrfToken=' + (csrfToken ? csrfToken.substring(0, 8) + '...' : 'MISSING'));
|
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');
|
var bodyEl = document.getElementById("relink-modal-body");
|
||||||
if (bodyEl) bodyEl.innerHTML = '<p class="file-browser-loading">Reliage en cours…</p>';
|
if (bodyEl)
|
||||||
|
bodyEl.innerHTML =
|
||||||
|
'<p class="file-browser-loading">Reliage en cours…</p>';
|
||||||
|
|
||||||
fetch('/admin/actions/filepond/relink.php', {
|
fetch("/admin/actions/filepond/relink.php", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'X-CSRF-Token': csrfToken,
|
"X-CSRF-Token": csrfToken,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
thesis_id: parseInt(thesisId, 10),
|
thesis_id: parseInt(thesisId, 10),
|
||||||
@@ -727,71 +782,102 @@
|
|||||||
queue_type: queueType,
|
queue_type: queueType,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data })))
|
.then((r) =>
|
||||||
.then(({ ok, status, data }) => {
|
r.json().then((data) => ({ ok: r.ok, status: r.status, data })),
|
||||||
if (!ok || (data && data.ok === false)) {
|
)
|
||||||
var msg = (data && data.error) ? data.error : (typeof data === 'string' ? data : 'Erreur ' + status);
|
.then(({ ok, status, data }) => {
|
||||||
if (bodyEl) bodyEl.innerHTML = `<p class="file-browser-error">Erreur : ${msg}</p>`;
|
if (!ok || (data && data.ok === false)) {
|
||||||
return;
|
const msg = data?.error
|
||||||
}
|
? data.error
|
||||||
console.log('[relink] success | new_id=' + data.id);
|
: typeof data === "string"
|
||||||
|
? data
|
||||||
// Add the new file to the FilePond pool, then close the modal.
|
: `Erreur ${status}`;
|
||||||
// If the DOM was replaced (e.g. live-reload), refresh the
|
if (bodyEl)
|
||||||
// form fragment via HTMX so the server re-renders the pools
|
bodyEl.innerHTML = `<p class="file-browser-error">Erreur : ${msg}</p>`;
|
||||||
// with the newly-linked file included.
|
return;
|
||||||
var input = document.querySelector(`.tfe-file-picker[data-queue-type="${queueType}"]`);
|
|
||||||
console.log('[relink] looking for input | selector=' + `.tfe-file-picker[data-queue-type="${queueType}"]` + ' | found=' + !!input);
|
|
||||||
var closeAndRefresh = function() {
|
|
||||||
var modal = document.getElementById('relink-modal');
|
|
||||||
if (modal) modal.close();
|
|
||||||
// Re-fetch the fichiers fragment from the server so the
|
|
||||||
// newly-linked file appears in the FilePond pools.
|
|
||||||
var block = document.getElementById('format-fichiers-block');
|
|
||||||
if (block && window.htmx) {
|
|
||||||
var url = '/admin/fragments/fichiers.php';
|
|
||||||
if (window.__xamxamRelinkCtx && window.__xamxamRelinkCtx.thesisId) {
|
|
||||||
url += '?_thesis_id=' + encodeURIComponent(window.__xamxamRelinkCtx.thesisId);
|
|
||||||
}
|
|
||||||
htmx.ajax('GET', url, {
|
|
||||||
target: '#format-fichiers-block',
|
|
||||||
swap: 'outerHTML'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
console.log(`[relink] success | new_id=${data.id}`);
|
||||||
if (input) {
|
|
||||||
var pond = FilePond.find(input);
|
// Add the new file to the FilePond pool, then close the modal.
|
||||||
console.log('[relink] looking for pond | found=' + !!pond);
|
// If the DOM was replaced (e.g. live-reload), refresh the
|
||||||
if (pond) {
|
// form fragment via HTMX so the server re-renders the pools
|
||||||
pond.addFile(String(data.id), {
|
// with the newly-linked file included.
|
||||||
type: 'limbo',
|
var input = document.querySelector(
|
||||||
file: {
|
`.tfe-file-picker[data-queue-type="${queueType}"]`,
|
||||||
name: fileName,
|
);
|
||||||
size: fileSize,
|
console.log(
|
||||||
type: mimeType
|
"[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);
|
||||||
}
|
}
|
||||||
}).then(function() {
|
htmx.ajax("GET", url, {
|
||||||
console.log('[relink] addFile resolved | source=' + String(data.id) + ' | queueType=' + queueType);
|
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();
|
closeAndRefresh();
|
||||||
}).catch(function(err) {
|
}
|
||||||
console.error('[relink] addFile rejected', err);
|
|
||||||
closeAndRefresh();
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
console.error('[relink] FilePond.find returned null for input', input);
|
console.warn(
|
||||||
|
"[relink] input not found, page may have reloaded | queueType=" +
|
||||||
|
queueType,
|
||||||
|
);
|
||||||
closeAndRefresh();
|
closeAndRefresh();
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.warn('[relink] input not found, page may have reloaded | queueType=' + queueType);
|
|
||||||
closeAndRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark form dirty
|
// Mark form dirty
|
||||||
window.__xamxamDirty = true;
|
window.__xamxamDirty = true;
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch((err) => {
|
||||||
console.error('[relink] fetch error', err);
|
console.error("[relink] fetch error", err);
|
||||||
if (bodyEl) bodyEl.innerHTML = '<p class="file-browser-error">Erreur réseau.</p>';
|
if (bodyEl)
|
||||||
});
|
bodyEl.innerHTML = '<p class="file-browser-error">Erreur réseau.</p>';
|
||||||
|
});
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -11,130 +11,142 @@
|
|||||||
* data-jury-hx-post — HTMX endpoint URL (required)
|
* data-jury-hx-post — HTMX endpoint URL (required)
|
||||||
* data-jury-hx-target — CSS selector for the shared dropdown (optional)
|
* data-jury-hx-target — CSS selector for the shared dropdown (optional)
|
||||||
*/
|
*/
|
||||||
(function () {
|
(() => {
|
||||||
'use strict';
|
function initAll() {
|
||||||
|
document
|
||||||
|
.querySelectorAll(
|
||||||
|
"[data-jury-autocomplete]:not([data-jury-autocomplete-initialized])",
|
||||||
|
)
|
||||||
|
.forEach((fieldset) => {
|
||||||
|
fieldset.setAttribute("data-jury-autocomplete-initialized", "1");
|
||||||
|
initFieldset(fieldset);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function initAll() {
|
document.addEventListener("DOMContentLoaded", initAll);
|
||||||
document.querySelectorAll('[data-jury-autocomplete]:not([data-jury-autocomplete-initialized])').forEach(function (fieldset) {
|
document.body.addEventListener("htmx:afterSwap", initAll);
|
||||||
fieldset.setAttribute('data-jury-autocomplete-initialized', '1');
|
|
||||||
initFieldset(fieldset);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initAll);
|
function initFieldset(fieldset) {
|
||||||
document.body.addEventListener('htmx:afterSwap', initAll);
|
var list;
|
||||||
|
var activeInput;
|
||||||
|
var selectedIdx;
|
||||||
|
var debounceTimer;
|
||||||
|
|
||||||
function initFieldset(fieldset) {
|
var hxPost =
|
||||||
var hxPost = fieldset.getAttribute('data-jury-hx-post') || '/admin/fragments/pill-search.php';
|
fieldset.getAttribute("data-jury-hx-post") ||
|
||||||
var role = fieldset.getAttribute('data-jury-role') || '';
|
"/admin/fragments/pill-search.php";
|
||||||
var dropdown = fieldset.querySelector('.jury-suggestions');
|
var role = fieldset.getAttribute("data-jury-role") || "";
|
||||||
if (!dropdown) {
|
var dropdown = fieldset.querySelector(".jury-suggestions");
|
||||||
dropdown = document.createElement('div');
|
if (!dropdown) {
|
||||||
dropdown.className = 'jury-suggestions tag-search-suggestions';
|
dropdown = document.createElement("div");
|
||||||
dropdown.setAttribute('role', 'listbox');
|
dropdown.className = "jury-suggestions tag-search-suggestions";
|
||||||
// Insert after the list container
|
dropdown.setAttribute("role", "listbox");
|
||||||
var list = fieldset.querySelector('.admin-jury-list');
|
// Insert after the list container
|
||||||
if (list) {
|
list = fieldset.querySelector(".admin-jury-list");
|
||||||
list.insertAdjacentElement('afterend', dropdown);
|
if (list) {
|
||||||
} else {
|
list.insertAdjacentElement("afterend", dropdown);
|
||||||
fieldset.appendChild(dropdown);
|
} else {
|
||||||
}
|
fieldset.appendChild(dropdown);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var activeInput = null;
|
// Click on suggestion → fill the active input
|
||||||
var selectedIdx = -1;
|
dropdown.addEventListener("click", (e) => {
|
||||||
var debounceTimer = null;
|
var btn = e.target.closest(".tag-search-item");
|
||||||
|
if (!btn) return;
|
||||||
|
var name = (btn.getAttribute("data-tag-name") || "").trim();
|
||||||
|
if (!name || !activeInput) return;
|
||||||
|
activeInput.value = btn.classList.contains("tag-search-item--create")
|
||||||
|
? activeInput.value.trim()
|
||||||
|
: name;
|
||||||
|
dropdown.innerHTML = "";
|
||||||
|
selectedIdx = -1;
|
||||||
|
activeInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
// Click on suggestion → fill the active input
|
// Highlighting helper
|
||||||
dropdown.addEventListener('click', function (e) {
|
function highlight(idx) {
|
||||||
var btn = e.target.closest('.tag-search-item');
|
var items = dropdown.querySelectorAll(".tag-search-item");
|
||||||
if (!btn) return;
|
for (let i = 0; i < items.length; i++) {
|
||||||
var name = (btn.getAttribute('data-tag-name') || '').trim();
|
items[i].classList.toggle("tag-search-item--highlight", i === idx);
|
||||||
if (!name || !activeInput) return;
|
}
|
||||||
activeInput.value = btn.classList.contains('tag-search-item--create')
|
}
|
||||||
? activeInput.value.trim()
|
|
||||||
: name;
|
|
||||||
dropdown.innerHTML = '';
|
|
||||||
selectedIdx = -1;
|
|
||||||
activeInput.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Highlighting helper
|
fieldset.addEventListener("input", (e) => {
|
||||||
function highlight(idx) {
|
var inp = e.target.closest('input[type="text"]');
|
||||||
var items = dropdown.querySelectorAll('.tag-search-item');
|
if (!inp) return;
|
||||||
for (var i = 0; i < items.length; i++) {
|
|
||||||
items[i].classList.toggle('tag-search-item--highlight', i === idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldset.addEventListener('input', function (e) {
|
activeInput = inp;
|
||||||
var inp = e.target.closest('input[type="text"]');
|
var q = inp.value.trim();
|
||||||
if (!inp) return;
|
|
||||||
|
|
||||||
activeInput = inp;
|
// Build the hx-include query — include hidden type=supervisor
|
||||||
var q = inp.value.trim();
|
var _typeInput = fieldset.querySelector(
|
||||||
|
'input[name="type"][value="supervisor"]',
|
||||||
|
);
|
||||||
|
|
||||||
// Build the hx-include query — include hidden type=supervisor
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
var typeInput = fieldset.querySelector('input[name="type"][value="supervisor"]');
|
debounceTimer = setTimeout(() => {
|
||||||
var includeSelector = typeInput ? '[name="type"][value="supervisor"]' : '';
|
if (q === "") {
|
||||||
|
dropdown.innerHTML = "";
|
||||||
|
selectedIdx = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
// Manual HTMX POST
|
||||||
debounceTimer = setTimeout(function () {
|
var xhr = new XMLHttpRequest();
|
||||||
if (q === '') {
|
xhr.open("POST", hxPost);
|
||||||
dropdown.innerHTML = '';
|
xhr.setRequestHeader(
|
||||||
selectedIdx = -1;
|
"Content-Type",
|
||||||
return;
|
"application/x-www-form-urlencoded",
|
||||||
}
|
);
|
||||||
|
xhr.setRequestHeader("HX-Request", "true");
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
dropdown.innerHTML = xhr.responseText;
|
||||||
|
selectedIdx = -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var params =
|
||||||
|
"type=supervisor&q=" +
|
||||||
|
encodeURIComponent(q) +
|
||||||
|
(role ? `&role=${encodeURIComponent(role)}` : "");
|
||||||
|
xhr.send(params);
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
// Manual HTMX POST
|
// Keyboard navigation
|
||||||
var xhr = new XMLHttpRequest();
|
fieldset.addEventListener("keydown", (e) => {
|
||||||
xhr.open('POST', hxPost);
|
var items = dropdown.querySelectorAll(".tag-search-item");
|
||||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
xhr.setRequestHeader('HX-Request', 'true');
|
if (items.length === 0) return;
|
||||||
xhr.onload = function () {
|
e.preventDefault();
|
||||||
if (xhr.status === 200) {
|
if (e.key === "ArrowDown") {
|
||||||
dropdown.innerHTML = xhr.responseText;
|
selectedIdx = (selectedIdx + 1) % items.length;
|
||||||
selectedIdx = -1;
|
} else {
|
||||||
}
|
selectedIdx = selectedIdx <= 0 ? items.length - 1 : selectedIdx - 1;
|
||||||
};
|
}
|
||||||
var params = 'type=supervisor&q=' + encodeURIComponent(q) + (role ? '&role=' + encodeURIComponent(role) : '');
|
highlight(selectedIdx);
|
||||||
xhr.send(params);
|
} else if (e.key === "Enter") {
|
||||||
}, 200);
|
if (items.length > 0 && dropdown.innerHTML !== "") {
|
||||||
});
|
e.preventDefault();
|
||||||
|
if (selectedIdx >= 0 && selectedIdx < items.length) {
|
||||||
|
items[selectedIdx].click();
|
||||||
|
} else {
|
||||||
|
items[0].click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
dropdown.innerHTML = "";
|
||||||
|
selectedIdx = -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Keyboard navigation
|
// Close dropdown on outside click
|
||||||
fieldset.addEventListener('keydown', function (e) {
|
document.addEventListener("click", (e) => {
|
||||||
var items = dropdown.querySelectorAll('.tag-search-item');
|
if (!fieldset.contains(e.target)) {
|
||||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
dropdown.innerHTML = "";
|
||||||
if (items.length === 0) return;
|
selectedIdx = -1;
|
||||||
e.preventDefault();
|
}
|
||||||
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 && dropdown.innerHTML !== '') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (selectedIdx >= 0 && selectedIdx < items.length) {
|
|
||||||
items[selectedIdx].click();
|
|
||||||
} else {
|
|
||||||
items[0].click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
dropdown.innerHTML = '';
|
|
||||||
selectedIdx = -1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close dropdown on outside click
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
if (!fieldset.contains(e.target)) {
|
|
||||||
dropdown.innerHTML = '';
|
|
||||||
selectedIdx = -1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -20,152 +20,172 @@
|
|||||||
* data-pill-required → si "1", active l'affichage du minimum
|
* data-pill-required → si "1", active l'affichage du minimum
|
||||||
* data-pill-role → "tag" (lowercase) ou "lang" (ucfirst)
|
* data-pill-role → "tag" (lowercase) ou "lang" (ucfirst)
|
||||||
*/
|
*/
|
||||||
(function () {
|
(() => {
|
||||||
'use strict';
|
function initAll() {
|
||||||
|
document
|
||||||
|
.querySelectorAll(
|
||||||
|
"[data-pill-search]:not([data-pill-search-initialized])",
|
||||||
|
)
|
||||||
|
.forEach((container) => {
|
||||||
|
container.setAttribute("data-pill-search-initialized", "1");
|
||||||
|
initPillSearch(container);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function initAll() {
|
document.addEventListener("DOMContentLoaded", initAll);
|
||||||
document.querySelectorAll('[data-pill-search]:not([data-pill-search-initialized])').forEach(function (container) {
|
document.body.addEventListener("htmx:afterSwap", initAll);
|
||||||
container.setAttribute('data-pill-search-initialized', '1');
|
|
||||||
initPillSearch(container);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initAll);
|
function initPillSearch(container) {
|
||||||
document.body.addEventListener('htmx:afterSwap', initAll);
|
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) || 10;
|
||||||
|
var minTags = parseInt(container.getAttribute("data-pill-min"), 10) || 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;
|
||||||
|
|
||||||
function initPillSearch(container) {
|
if (!pills || !search || !dropdown) return;
|
||||||
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 normalize(name) {
|
function pillAlreadyExists(name) {
|
||||||
return name.trim().replace(/\s+/g, ' ').toLowerCase();
|
var norm = normalize(name);
|
||||||
}
|
var existing = pills.querySelectorAll(".tag-pill-name");
|
||||||
|
for (let i = 0; i < existing.length; i++) {
|
||||||
|
if (normalize(existing[i].textContent) === norm) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function pillAlreadyExists(name) {
|
function updateCount() {
|
||||||
var norm = normalize(name);
|
var n = pills.querySelectorAll(".tag-pill").length;
|
||||||
var existing = pills.querySelectorAll('.tag-pill-name');
|
var suffix = required ? ` (min ${minTags})` : "";
|
||||||
for (var i = 0; i < existing.length; i++) {
|
if (countEl) countEl.textContent = `${n}/${maxTags}${suffix}`;
|
||||||
if (normalize(existing[i].textContent) === norm) return true;
|
if (counter) counter.style.display = n > 0 || required ? "" : "none";
|
||||||
}
|
if (countEl && required) {
|
||||||
return false;
|
countEl.style.color =
|
||||||
}
|
n < minTags ? "var(--text-danger)" : "var(--accent)";
|
||||||
|
}
|
||||||
|
|
||||||
function updateCount() {
|
var wrap = container.querySelector(".tag-search-input-wrap");
|
||||||
var n = pills.querySelectorAll('.tag-pill').length;
|
var maxMsg = container.querySelector(".tag-search-max-msg");
|
||||||
var suffix = required ? ' (min ' + minTags + ')' : '';
|
if (n >= maxTags) {
|
||||||
if (countEl) countEl.textContent = n + '/' + maxTags + suffix;
|
if (wrap) wrap.style.display = "none";
|
||||||
if (counter) counter.style.display = (n > 0 || required) ? '' : 'none';
|
if (maxMsg) maxMsg.style.display = "";
|
||||||
if (countEl && required) {
|
} else {
|
||||||
countEl.style.color = n < minTags ? 'var(--text-danger)' : 'var(--accent)';
|
if (wrap) {
|
||||||
}
|
wrap.style.display = "";
|
||||||
|
if (search) search.style.display = "";
|
||||||
|
}
|
||||||
|
if (maxMsg) maxMsg.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var wrap = container.querySelector('.tag-search-input-wrap');
|
pills.addEventListener("click", (e) => {
|
||||||
var maxMsg = container.querySelector('.tag-search-max-msg');
|
var btn = e.target.closest(".tag-pill-remove");
|
||||||
if (n >= maxTags) {
|
if (!btn) return;
|
||||||
if (wrap) wrap.style.display = 'none';
|
var pill = btn.closest(".tag-pill");
|
||||||
if (maxMsg) maxMsg.style.display = '';
|
pill.remove();
|
||||||
} else {
|
updateCount();
|
||||||
if (wrap) { wrap.style.display = ''; if (search) search.style.display = ''; }
|
var wrap = container.querySelector(".tag-search-input-wrap");
|
||||||
if (maxMsg) maxMsg.style.display = 'none';
|
var inp = container.querySelector(".tag-search-input");
|
||||||
}
|
if (wrap && inp) {
|
||||||
}
|
wrap.style.display = "";
|
||||||
|
inp.style.display = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
pills.addEventListener('click', function (e) {
|
function highlight(idx) {
|
||||||
var btn = e.target.closest('.tag-pill-remove');
|
var items = dropdown.querySelectorAll(".tag-search-item");
|
||||||
if (!btn) return;
|
for (let i = 0; i < items.length; i++) {
|
||||||
var pill = btn.closest('.tag-pill');
|
items[i].classList.toggle("tag-search-item--highlight", i === idx);
|
||||||
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) {
|
function selectPill(btn) {
|
||||||
var items = dropdown.querySelectorAll('.tag-search-item');
|
var name = normalize(btn.getAttribute("data-tag-name") || "");
|
||||||
for (var i = 0; i < items.length; i++) {
|
if (!name) return;
|
||||||
items[i].classList.toggle('tag-search-item--highlight', i === idx);
|
if (pillAlreadyExists(name)) return;
|
||||||
}
|
if (pills.querySelectorAll(".tag-pill").length >= maxTags) return;
|
||||||
}
|
|
||||||
|
|
||||||
function selectPill(btn) {
|
var escaped = htmlEscape(name);
|
||||||
var name = normalize(btn.getAttribute('data-tag-name') || '');
|
var pill = document.createElement("span");
|
||||||
if (!name) return;
|
pill.className = "tag-pill";
|
||||||
if (pillAlreadyExists(name)) return;
|
pill.innerHTML =
|
||||||
if ((pills.querySelectorAll('.tag-pill').length) >= maxTags) return;
|
'<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();
|
||||||
|
}
|
||||||
|
|
||||||
var escaped = htmlEscape(name);
|
dropdown.addEventListener("click", (e) => {
|
||||||
var pill = document.createElement('span');
|
var btn = e.target.closest(".tag-search-item");
|
||||||
pill.className = 'tag-pill';
|
if (!btn) return;
|
||||||
pill.innerHTML = '<input type="hidden" name="' + inputName + '[]" value="' + escaped + '">'
|
selectPill(btn);
|
||||||
+ '<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) {
|
search.addEventListener("keydown", (e) => {
|
||||||
var btn = e.target.closest('.tag-search-item');
|
var items = dropdown.querySelectorAll(".tag-search-item");
|
||||||
if (!btn) return;
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
selectPill(btn);
|
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('keydown', function (e) {
|
search.addEventListener("blur", () => {
|
||||||
var items = dropdown.querySelectorAll('.tag-search-item');
|
setTimeout(() => {
|
||||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
if (!dropdown.contains(document.activeElement)) {
|
||||||
e.preventDefault();
|
dropdown.innerHTML = "";
|
||||||
if (items.length === 0) return;
|
selectedIdx = -1;
|
||||||
if (e.key === 'ArrowDown') {
|
}
|
||||||
selectedIdx = (selectedIdx + 1) % items.length;
|
}, 150);
|
||||||
} 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 () {
|
function htmlEscape(str) {
|
||||||
setTimeout(function () {
|
var el = document.createElement("span");
|
||||||
if (!dropdown.contains(document.activeElement)) {
|
el.textContent = str;
|
||||||
dropdown.innerHTML = '';
|
return el.innerHTML;
|
||||||
selectedIdx = -1;
|
}
|
||||||
}
|
}
|
||||||
}, 150);
|
|
||||||
});
|
|
||||||
|
|
||||||
function htmlEscape(str) {
|
|
||||||
var el = document.createElement('span');
|
|
||||||
el.textContent = str;
|
|
||||||
return el.innerHTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -11,203 +11,211 @@
|
|||||||
* 100% : response received — "Téléversé avec succès", then redirect
|
* 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 FORMS = document.querySelectorAll('form[data-upload-progress]');
|
const POLL_INTERVAL = 400;
|
||||||
if (!FORMS.length) return;
|
const UPLOAD_CAP = 25;
|
||||||
|
const PROCESSING_MAX = 99;
|
||||||
|
const SUCCESS_DELAY = 800;
|
||||||
|
|
||||||
const POLL_INTERVAL = 400;
|
for (const form of FORMS) {
|
||||||
const UPLOAD_CAP = 25;
|
const progressWrap = form.querySelector("#upload-progress-wrap");
|
||||||
const PROCESSING_MAX = 99;
|
const progressBar = form.querySelector("#upload-progress-bar");
|
||||||
const SUCCESS_DELAY = 800;
|
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"]');
|
||||||
|
|
||||||
for (const form of FORMS) {
|
if (!progressBar || !progressWrap) continue;
|
||||||
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)
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Read processed file names from FilePond instances (async mode)
|
||||||
|
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) {
|
||||||
|
// Only count successfully uploaded files (have serverId)
|
||||||
|
if (pf.serverId) {
|
||||||
|
const name = pf.filename || pf.file?.name || pf.serverId;
|
||||||
|
if (name) names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
function collectFileNames() {
|
form.addEventListener("submit", (e) => {
|
||||||
const names = [];
|
// ── Guard: block submit if any FilePond item is still uploading ──
|
||||||
// Check raw <input type="file"> elements (non-FilePond)
|
if (typeof FilePond !== "undefined") {
|
||||||
const inputs = form.querySelectorAll('input[type="file"]');
|
let stillUploading = false;
|
||||||
for (const fi of inputs) {
|
const pondInputs = form.querySelectorAll(".tfe-file-picker");
|
||||||
if (fi.files) {
|
for (const pi of pondInputs) {
|
||||||
for (const f of fi.files) {
|
const pond = FilePond.find(pi);
|
||||||
if (f.name) names.push(f.name);
|
if (pond) {
|
||||||
}
|
const pondFiles = pond.getFiles();
|
||||||
}
|
for (const pf of pondFiles) {
|
||||||
}
|
if (
|
||||||
// Read processed file names from FilePond instances (async mode)
|
pf.status === FilePond.FileStatus.PROCESSING ||
|
||||||
if (typeof FilePond !== 'undefined') {
|
pf.status === FilePond.FileStatus.IDLE
|
||||||
const pondInputs = form.querySelectorAll('.tfe-file-picker');
|
) {
|
||||||
for (const pi of pondInputs) {
|
stillUploading = true;
|
||||||
const pond = FilePond.find(pi);
|
break;
|
||||||
if (pond) {
|
}
|
||||||
const pondFiles = pond.getFiles();
|
}
|
||||||
for (const pf of pondFiles) {
|
}
|
||||||
// Only count successfully uploaded files (have serverId)
|
if (stillUploading) break;
|
||||||
if (pf.serverId) {
|
}
|
||||||
const name = pf.filename || (pf.file && pf.file.name) || pf.serverId;
|
if (stillUploading) {
|
||||||
if (name) names.push(name);
|
e.preventDefault();
|
||||||
}
|
progressLabel.textContent =
|
||||||
}
|
"Veuillez attendre la fin du téléversement…";
|
||||||
}
|
progressWrap.style.display = "";
|
||||||
}
|
return;
|
||||||
}
|
}
|
||||||
return names;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', function (e) {
|
const fileNames = collectFileNames();
|
||||||
// ── Guard: block submit if any FilePond item is still uploading ──
|
if (!fileNames.length) return;
|
||||||
if (typeof FilePond !== 'undefined') {
|
|
||||||
let stillUploading = false;
|
|
||||||
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) {
|
|
||||||
if (pf.status === FilePond.FileStatus.PROCESSING ||
|
|
||||||
pf.status === FilePond.FileStatus.IDLE) {
|
|
||||||
stillUploading = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (stillUploading) break;
|
|
||||||
}
|
|
||||||
if (stillUploading) {
|
|
||||||
e.preventDefault();
|
|
||||||
progressLabel.textContent = 'Veuillez attendre la fin du téléversement…';
|
|
||||||
progressWrap.style.display = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileNames = collectFileNames();
|
e.preventDefault();
|
||||||
if (!fileNames.length) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
const token = tokenInput ? tokenInput.value : "";
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
progressWrap.style.display = '';
|
const fd = new FormData(form);
|
||||||
progressBar.value = 0;
|
const xhr = new XMLHttpRequest();
|
||||||
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);
|
let _uploadDone = false;
|
||||||
const xhr = new XMLHttpRequest();
|
let lastUploadPct = 0;
|
||||||
|
let pollingTimer = null;
|
||||||
|
|
||||||
let uploadDone = false;
|
/** Poll server-side progress */
|
||||||
let lastUploadPct = 0;
|
function startPolling() {
|
||||||
let pollingTimer = null;
|
if (pollingTimer || !token) return;
|
||||||
|
progressLabel.textContent = "Traitement en cours…";
|
||||||
|
pollingTimer = setInterval(() => {
|
||||||
|
fetch(
|
||||||
|
"/admin/actions/upload-progress.php?token=" +
|
||||||
|
encodeURIComponent(token),
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (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(() => {
|
||||||
|
/* ignore poll errors */
|
||||||
|
});
|
||||||
|
}, POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
/** Poll server-side progress */
|
function stopPolling() {
|
||||||
function startPolling() {
|
if (pollingTimer) {
|
||||||
if (pollingTimer || !token) return;
|
clearInterval(pollingTimer);
|
||||||
progressLabel.textContent = 'Traitement en cours…';
|
pollingTimer = null;
|
||||||
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() {
|
function finishSuccess() {
|
||||||
if (pollingTimer) {
|
stopPolling();
|
||||||
clearInterval(pollingTimer);
|
progressBar.value = 100;
|
||||||
pollingTimer = null;
|
progressBar.setAttribute("data-complete", "");
|
||||||
}
|
progressLabel.textContent = "Téléversé avec succès";
|
||||||
}
|
progressFile.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
function finishSuccess() {
|
// ── Upload phase (0% → UPLOAD_CAP) ──
|
||||||
stopPolling();
|
xhr.upload.addEventListener("progress", (evt) => {
|
||||||
progressBar.value = 100;
|
if (evt.lengthComputable) {
|
||||||
progressBar.setAttribute('data-complete', '');
|
const rawPct = Math.round((evt.loaded / evt.total) * 100);
|
||||||
progressLabel.textContent = 'Téléversé avec succès';
|
const scaled = Math.round((rawPct / 100) * UPLOAD_CAP);
|
||||||
progressFile.textContent = '';
|
if (scaled > lastUploadPct) {
|
||||||
}
|
lastUploadPct = scaled;
|
||||||
|
progressBar.value = scaled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Upload phase (0% → UPLOAD_CAP) ──
|
xhr.upload.addEventListener("loadend", () => {
|
||||||
xhr.upload.addEventListener('progress', function (evt) {
|
_uploadDone = true;
|
||||||
if (evt.lengthComputable) {
|
progressBar.value = UPLOAD_CAP;
|
||||||
const rawPct = Math.round((evt.loaded / evt.total) * 100);
|
startPolling();
|
||||||
const scaled = Math.round((rawPct / 100) * UPLOAD_CAP);
|
});
|
||||||
if (scaled > lastUploadPct) {
|
|
||||||
lastUploadPct = scaled;
|
|
||||||
progressBar.value = scaled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.upload.addEventListener('loadend', function () {
|
// ── Response handling ──
|
||||||
uploadDone = true;
|
xhr.addEventListener("readystatechange", () => {
|
||||||
progressBar.value = UPLOAD_CAP;
|
if (xhr.readyState !== XMLHttpRequest.DONE) return;
|
||||||
startPolling();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Response handling ──
|
stopPolling();
|
||||||
xhr.addEventListener('readystatechange', function () {
|
|
||||||
if (xhr.readyState !== XMLHttpRequest.DONE) return;
|
|
||||||
|
|
||||||
stopPolling();
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
finishSuccess();
|
||||||
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
setTimeout(() => {
|
||||||
finishSuccess();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
setTimeout(function () {
|
xhr.addEventListener("error", () => {
|
||||||
const finalUrl = xhr.responseURL || '';
|
stopPolling();
|
||||||
if (finalUrl && finalUrl !== form.action) {
|
progressLabel.textContent = "Erreur réseau";
|
||||||
window.location.href = finalUrl;
|
progressFile.textContent = "";
|
||||||
} else {
|
if (submitBtn) submitBtn.disabled = false;
|
||||||
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 () {
|
xhr.addEventListener("abort", () => {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
progressLabel.textContent = 'Erreur réseau';
|
progressWrap.style.display = "none";
|
||||||
progressFile.textContent = '';
|
if (submitBtn) submitBtn.disabled = false;
|
||||||
if (submitBtn) submitBtn.disabled = false;
|
});
|
||||||
});
|
|
||||||
|
|
||||||
xhr.addEventListener('abort', function () {
|
xhr.open("POST", form.action, true);
|
||||||
stopPolling();
|
xhr.send(fd);
|
||||||
progressWrap.style.display = 'none';
|
});
|
||||||
if (submitBtn) submitBtn.disabled = false;
|
}
|
||||||
});
|
|
||||||
|
|
||||||
xhr.open('POST', form.action, true);
|
|
||||||
xhr.send(fd);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -6,4 +6,118 @@
|
|||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
!function(e,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(e=e||self).FilePondPluginFileValidateSize=i()}(this,function(){"use strict";var e=function(e){var i=e.addFilter,E=e.utils,l=E.Type,_=E.replaceInString,n=E.toNaturalFileSize;return i("ALLOW_HOPPER_ITEM",function(e,i){var E=i.query;if(!E("GET_ALLOW_FILE_SIZE_VALIDATION"))return!0;var l=E("GET_MAX_FILE_SIZE");if(null!==l&&e.size>l)return!1;var _=E("GET_MIN_FILE_SIZE");return!(null!==_&&e.size<_)}),i("LOAD_FILE",function(e,i){var E=i.query;return new Promise(function(i,l){if(!E("GET_ALLOW_FILE_SIZE_VALIDATION"))return i(e);var I=E("GET_FILE_VALIDATE_SIZE_FILTER");if(I&&!I(e))return i(e);var t=E("GET_MAX_FILE_SIZE");if(null!==t&&e.size>t)l({status:{main:E("GET_LABEL_MAX_FILE_SIZE_EXCEEDED"),sub:_(E("GET_LABEL_MAX_FILE_SIZE"),{filesize:n(t,".",E("GET_FILE_SIZE_BASE"),E("GET_FILE_SIZE_LABELS",E))})}});else{var L=E("GET_MIN_FILE_SIZE");if(null!==L&&e.size<L)l({status:{main:E("GET_LABEL_MIN_FILE_SIZE_EXCEEDED"),sub:_(E("GET_LABEL_MIN_FILE_SIZE"),{filesize:n(L,".",E("GET_FILE_SIZE_BASE"),E("GET_FILE_SIZE_LABELS",E))})}});else{var a=E("GET_MAX_TOTAL_FILE_SIZE");if(null!==a)if(E("GET_ACTIVE_ITEMS").reduce(function(e,i){return e+i.fileSize},0)>a)return void l({status:{main:E("GET_LABEL_MAX_TOTAL_FILE_SIZE_EXCEEDED"),sub:_(E("GET_LABEL_MAX_TOTAL_FILE_SIZE"),{filesize:n(a,".",E("GET_FILE_SIZE_BASE"),E("GET_FILE_SIZE_LABELS",E))})}});i(e)}}})}),{options:{allowFileSizeValidation:[!0,l.BOOLEAN],maxFileSize:[null,l.INT],minFileSize:[null,l.INT],maxTotalFileSize:[null,l.INT],fileValidateSizeFilter:[null,l.FUNCTION],labelMinFileSizeExceeded:["File is too small",l.STRING],labelMinFileSize:["Minimum file size is {filesize}",l.STRING],labelMaxFileSizeExceeded:["File is too large",l.STRING],labelMaxFileSize:["Maximum file size is {filesize}",l.STRING],labelMaxTotalFileSizeExceeded:["Maximum total size exceeded",l.STRING],labelMaxTotalFileSize:["Maximum total file size is {filesize}",l.STRING]}}};return"undefined"!=typeof window&&void 0!==window.document&&document.dispatchEvent(new CustomEvent("FilePond:pluginloaded",{detail:e})),e});
|
!((e, i) => {
|
||||||
|
"object" == typeof exports && "undefined" != typeof module
|
||||||
|
? (module.exports = i())
|
||||||
|
: "function" == typeof define && define.amd
|
||||||
|
? define(i)
|
||||||
|
: ((e = e || self).FilePondPluginFileValidateSize = i());
|
||||||
|
})(this, () => {
|
||||||
|
var e = (e) => {
|
||||||
|
var i = e.addFilter,
|
||||||
|
E = e.utils,
|
||||||
|
l = E.Type,
|
||||||
|
_ = E.replaceInString,
|
||||||
|
n = E.toNaturalFileSize;
|
||||||
|
return (
|
||||||
|
i("ALLOW_HOPPER_ITEM", (e, i) => {
|
||||||
|
var E = i.query;
|
||||||
|
if (!E("GET_ALLOW_FILE_SIZE_VALIDATION")) return !0;
|
||||||
|
var l = E("GET_MAX_FILE_SIZE");
|
||||||
|
if (null !== l && e.size > l) return !1;
|
||||||
|
var _ = E("GET_MIN_FILE_SIZE");
|
||||||
|
return !(null !== _ && e.size < _);
|
||||||
|
}),
|
||||||
|
i("LOAD_FILE", (e, i) => {
|
||||||
|
var E = i.query;
|
||||||
|
return new Promise((i, l) => {
|
||||||
|
if (!E("GET_ALLOW_FILE_SIZE_VALIDATION")) return i(e);
|
||||||
|
var I = E("GET_FILE_VALIDATE_SIZE_FILTER");
|
||||||
|
if (I && !I(e)) return i(e);
|
||||||
|
var t = E("GET_MAX_FILE_SIZE");
|
||||||
|
if (null !== t && e.size > t)
|
||||||
|
l({
|
||||||
|
status: {
|
||||||
|
main: E("GET_LABEL_MAX_FILE_SIZE_EXCEEDED"),
|
||||||
|
sub: _(E("GET_LABEL_MAX_FILE_SIZE"), {
|
||||||
|
filesize: n(
|
||||||
|
t,
|
||||||
|
".",
|
||||||
|
E("GET_FILE_SIZE_BASE"),
|
||||||
|
E("GET_FILE_SIZE_LABELS", E),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
else {
|
||||||
|
var L = E("GET_MIN_FILE_SIZE");
|
||||||
|
if (null !== L && e.size < L)
|
||||||
|
l({
|
||||||
|
status: {
|
||||||
|
main: E("GET_LABEL_MIN_FILE_SIZE_EXCEEDED"),
|
||||||
|
sub: _(E("GET_LABEL_MIN_FILE_SIZE"), {
|
||||||
|
filesize: n(
|
||||||
|
L,
|
||||||
|
".",
|
||||||
|
E("GET_FILE_SIZE_BASE"),
|
||||||
|
E("GET_FILE_SIZE_LABELS", E),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
else {
|
||||||
|
var a = E("GET_MAX_TOTAL_FILE_SIZE");
|
||||||
|
if (null !== a)
|
||||||
|
if (
|
||||||
|
E("GET_ACTIVE_ITEMS").reduce((e, i) => e + i.fileSize, 0) > a
|
||||||
|
)
|
||||||
|
return void l({
|
||||||
|
status: {
|
||||||
|
main: E("GET_LABEL_MAX_TOTAL_FILE_SIZE_EXCEEDED"),
|
||||||
|
sub: _(E("GET_LABEL_MAX_TOTAL_FILE_SIZE"), {
|
||||||
|
filesize: n(
|
||||||
|
a,
|
||||||
|
".",
|
||||||
|
E("GET_FILE_SIZE_BASE"),
|
||||||
|
E("GET_FILE_SIZE_LABELS", E),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
i(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
options: {
|
||||||
|
allowFileSizeValidation: [!0, l.BOOLEAN],
|
||||||
|
maxFileSize: [null, l.INT],
|
||||||
|
minFileSize: [null, l.INT],
|
||||||
|
maxTotalFileSize: [null, l.INT],
|
||||||
|
fileValidateSizeFilter: [null, l.FUNCTION],
|
||||||
|
labelMinFileSizeExceeded: ["File is too small", l.STRING],
|
||||||
|
labelMinFileSize: ["Minimum file size is {filesize}", l.STRING],
|
||||||
|
labelMaxFileSizeExceeded: ["File is too large", l.STRING],
|
||||||
|
labelMaxFileSize: ["Maximum file size is {filesize}", l.STRING],
|
||||||
|
labelMaxTotalFileSizeExceeded: [
|
||||||
|
"Maximum total size exceeded",
|
||||||
|
l.STRING,
|
||||||
|
],
|
||||||
|
labelMaxTotalFileSize: [
|
||||||
|
"Maximum total file size is {filesize}",
|
||||||
|
l.STRING,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
"undefined" != typeof window &&
|
||||||
|
void 0 !== window.document &&
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent("FilePond:pluginloaded", { detail: e }),
|
||||||
|
),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,4 +6,113 @@
|
|||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).FilePondPluginFileValidateType=t()}(this,function(){"use strict";var e=function(e){var t=e.addFilter,n=e.utils,i=n.Type,T=n.isString,E=n.replaceInString,l=n.guesstimateMimeType,o=n.getExtensionFromFilename,r=n.getFilenameFromURL,u=function(e,t){return e.some(function(e){return/\*$/.test(e)?(n=e,(/^[^/]+/.exec(t)||[]).pop()===n.slice(0,-2)):e===t;var n})},a=function(e,t,n){if(0===t.length)return!0;var i=function(e){var t="";if(T(e)){var n=r(e),i=o(n);i&&(t=l(i))}else t=e.type;return t}(e);return n?new Promise(function(T,E){n(e,i).then(function(e){u(t,e)?T():E()}).catch(E)}):u(t,i)};return t("SET_ATTRIBUTE_TO_OPTION_MAP",function(e){return Object.assign(e,{accept:"acceptedFileTypes"})}),t("ALLOW_HOPPER_ITEM",function(e,t){var n=t.query;return!n("GET_ALLOW_FILE_TYPE_VALIDATION")||a(e,n("GET_ACCEPTED_FILE_TYPES"))}),t("LOAD_FILE",function(e,t){var n=t.query;return new Promise(function(t,i){if(n("GET_ALLOW_FILE_TYPE_VALIDATION")){var T=n("GET_ACCEPTED_FILE_TYPES"),l=n("GET_FILE_VALIDATE_TYPE_DETECT_TYPE"),o=a(e,T,l),r=function(){var e,t=T.map((e=n("GET_FILE_VALIDATE_TYPE_LABEL_EXPECTED_TYPES_MAP"),function(t){return null!==e[t]&&(e[t]||t)})).filter(function(e){return!1!==e}),l=t.filter(function(e,n){return t.indexOf(e)===n});i({status:{main:n("GET_LABEL_FILE_TYPE_NOT_ALLOWED"),sub:E(n("GET_FILE_VALIDATE_TYPE_LABEL_EXPECTED_TYPES"),{allTypes:l.join(", "),allButLastType:l.slice(0,-1).join(", "),lastType:l[l.length-1]})}})};if("boolean"==typeof o)return o?t(e):r();o.then(function(){t(e)}).catch(r)}else t(e)})}),{options:{allowFileTypeValidation:[!0,i.BOOLEAN],acceptedFileTypes:[[],i.ARRAY],labelFileTypeNotAllowed:["File is of invalid type",i.STRING],fileValidateTypeLabelExpectedTypes:["Expects {allButLastType} or {lastType}",i.STRING],fileValidateTypeLabelExpectedTypesMap:[{},i.OBJECT],fileValidateTypeDetectType:[null,i.FUNCTION]}}};return"undefined"!=typeof window&&void 0!==window.document&&document.dispatchEvent(new CustomEvent("FilePond:pluginloaded",{detail:e})),e});
|
!((e, t) => {
|
||||||
|
"object" == typeof exports && "undefined" != typeof module
|
||||||
|
? (module.exports = t())
|
||||||
|
: "function" == typeof define && define.amd
|
||||||
|
? define(t)
|
||||||
|
: ((e = e || self).FilePondPluginFileValidateType = t());
|
||||||
|
})(this, () => {
|
||||||
|
var e = (e) => {
|
||||||
|
var t = e.addFilter,
|
||||||
|
n = e.utils,
|
||||||
|
i = n.Type,
|
||||||
|
T = n.isString,
|
||||||
|
E = n.replaceInString,
|
||||||
|
l = n.guesstimateMimeType,
|
||||||
|
o = n.getExtensionFromFilename,
|
||||||
|
r = n.getFilenameFromURL,
|
||||||
|
u = (e, t) =>
|
||||||
|
e.some((e) =>
|
||||||
|
/\*$/.test(e)
|
||||||
|
? ((n = e), (/^[^/]+/.exec(t) || []).pop() === n.slice(0, -2))
|
||||||
|
: e === t,
|
||||||
|
),
|
||||||
|
a = (e, t, n) => {
|
||||||
|
if (0 === t.length) return !0;
|
||||||
|
var i = ((e) => {
|
||||||
|
var t = "";
|
||||||
|
if (T(e)) {
|
||||||
|
var n = r(e),
|
||||||
|
i = o(n);
|
||||||
|
i && (t = l(i));
|
||||||
|
} else t = e.type;
|
||||||
|
return t;
|
||||||
|
})(e);
|
||||||
|
return n
|
||||||
|
? new Promise((T, E) => {
|
||||||
|
n(e, i)
|
||||||
|
.then((e) => {
|
||||||
|
u(t, e) ? T() : E();
|
||||||
|
})
|
||||||
|
.catch(E);
|
||||||
|
})
|
||||||
|
: u(t, i);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
t("SET_ATTRIBUTE_TO_OPTION_MAP", (e) =>
|
||||||
|
Object.assign(e, { accept: "acceptedFileTypes" }),
|
||||||
|
),
|
||||||
|
t("ALLOW_HOPPER_ITEM", (e, t) => {
|
||||||
|
var n = t.query;
|
||||||
|
return (
|
||||||
|
!n("GET_ALLOW_FILE_TYPE_VALIDATION") ||
|
||||||
|
a(e, n("GET_ACCEPTED_FILE_TYPES"))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
t("LOAD_FILE", (e, t) => {
|
||||||
|
var n = t.query;
|
||||||
|
return new Promise((t, i) => {
|
||||||
|
if (n("GET_ALLOW_FILE_TYPE_VALIDATION")) {
|
||||||
|
var T = n("GET_ACCEPTED_FILE_TYPES"),
|
||||||
|
l = n("GET_FILE_VALIDATE_TYPE_DETECT_TYPE"),
|
||||||
|
o = a(e, T, l),
|
||||||
|
r = () => {
|
||||||
|
var e,
|
||||||
|
t = T.map(
|
||||||
|
((e = n("GET_FILE_VALIDATE_TYPE_LABEL_EXPECTED_TYPES_MAP")),
|
||||||
|
(t) => null !== e[t] && (e[t] || t)),
|
||||||
|
).filter((e) => !1 !== e),
|
||||||
|
l = t.filter((e, n) => t.indexOf(e) === n);
|
||||||
|
i({
|
||||||
|
status: {
|
||||||
|
main: n("GET_LABEL_FILE_TYPE_NOT_ALLOWED"),
|
||||||
|
sub: E(n("GET_FILE_VALIDATE_TYPE_LABEL_EXPECTED_TYPES"), {
|
||||||
|
allTypes: l.join(", "),
|
||||||
|
allButLastType: l.slice(0, -1).join(", "),
|
||||||
|
lastType: l[l.length - 1],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if ("boolean" == typeof o) return o ? t(e) : r();
|
||||||
|
o.then(() => {
|
||||||
|
t(e);
|
||||||
|
}).catch(r);
|
||||||
|
} else t(e);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
options: {
|
||||||
|
allowFileTypeValidation: [!0, i.BOOLEAN],
|
||||||
|
acceptedFileTypes: [[], i.ARRAY],
|
||||||
|
labelFileTypeNotAllowed: ["File is of invalid type", i.STRING],
|
||||||
|
fileValidateTypeLabelExpectedTypes: [
|
||||||
|
"Expects {allButLastType} or {lastType}",
|
||||||
|
i.STRING,
|
||||||
|
],
|
||||||
|
fileValidateTypeLabelExpectedTypesMap: [{}, i.OBJECT],
|
||||||
|
fileValidateTypeDetectType: [null, i.FUNCTION],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
"undefined" != typeof window &&
|
||||||
|
void 0 !== window.document &&
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent("FilePond:pluginloaded", { detail: e }),
|
||||||
|
),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,4 +6,92 @@
|
|||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
!function(A,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(A=A||self).FilePondPluginImageExifOrientation=e()}(this,function(){"use strict";var A=65496,e=65505,n=1165519206,t=18761,i=274,r=65280,o=function(A,e){var n=arguments.length>2&&void 0!==arguments[2]&&arguments[2];return A.getUint16(e,n)},a=function(A,e){var n=arguments.length>2&&void 0!==arguments[2]&&arguments[2];return A.getUint32(e,n)},u="undefined"!=typeof window&&void 0!==window.document,d=void 0,f=u?new Image:{};f.onload=function(){return d=f.naturalWidth>f.naturalHeight},f.src="data:image/jpg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/4QA6RXhpZgAATU0AKgAAAAgAAwESAAMAAAABAAYAAAEoAAMAAAABAAIAAAITAAMAAAABAAEAAAAAAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAIBASIA/8QAJgABAAAAAAAAAAAAAAAAAAAAAxABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAAPwBH/9k=";var l=function(u){var f=u.addFilter,l=u.utils,c=l.Type,g=l.isFile;return f("DID_LOAD_ITEM",function(u,f){var l=f.query;return new Promise(function(f,c){var s=u.file;if(!(g(s)&&function(A){return/^image\/jpeg/.test(A.type)}(s)&&l("GET_ALLOW_IMAGE_EXIF_ORIENTATION")&&d))return f(u);(function(u){return new Promise(function(d,f){var l=new FileReader;l.onload=function(u){var f=new DataView(u.target.result);if(o(f,0)===A){for(var l=f.byteLength,c=2;c<l;){var g=o(f,c);if(c+=2,g===e){if(a(f,c+=2)!==n)break;var s=o(f,c+=6)===t;c+=a(f,c+4,s);var v=o(f,c,s);c+=2;for(var w=0;w<v;w++)if(o(f,c+12*w,s)===i)return void d(o(f,c+12*w+8,s))}else{if((g&r)!==r)break;c+=o(f,c)}}d(-1)}else d(-1)},l.readAsArrayBuffer(u.slice(0,65536))})})(s).then(function(A){u.setMetadata("exif",{orientation:A}),f(u)})})}),{options:{allowImageExifOrientation:[!0,c.BOOLEAN]}}};return"undefined"!=typeof window&&void 0!==window.document&&document.dispatchEvent(new CustomEvent("FilePond:pluginloaded",{detail:l})),l});
|
!((A, e) => {
|
||||||
|
"object" == typeof exports && "undefined" != typeof module
|
||||||
|
? (module.exports = e())
|
||||||
|
: "function" == typeof define && define.amd
|
||||||
|
? define(e)
|
||||||
|
: ((A = A || self).FilePondPluginImageExifOrientation = e());
|
||||||
|
})(this, () => {
|
||||||
|
var A = 65496,
|
||||||
|
e = 65505,
|
||||||
|
n = 1165519206,
|
||||||
|
t = 18761,
|
||||||
|
i = 274,
|
||||||
|
r = 65280,
|
||||||
|
o = function (A, e) {
|
||||||
|
var n = arguments.length > 2 && void 0 !== arguments[2] && arguments[2];
|
||||||
|
return A.getUint16(e, n);
|
||||||
|
},
|
||||||
|
a = function (A, e) {
|
||||||
|
var n = arguments.length > 2 && void 0 !== arguments[2] && arguments[2];
|
||||||
|
return A.getUint32(e, n);
|
||||||
|
},
|
||||||
|
u = "undefined" != typeof window && void 0 !== window.document,
|
||||||
|
d = void 0,
|
||||||
|
f = u ? new Image() : {};
|
||||||
|
(f.onload = () => (d = f.naturalWidth > f.naturalHeight)),
|
||||||
|
(f.src =
|
||||||
|
"data:image/jpg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/4QA6RXhpZgAATU0AKgAAAAgAAwESAAMAAAABAAYAAAEoAAMAAAABAAIAAAITAAMAAAABAAEAAAAAAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAIBASIA/8QAJgABAAAAAAAAAAAAAAAAAAAAAxABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAAPwBH/9k=");
|
||||||
|
var l = (u) => {
|
||||||
|
var f = u.addFilter,
|
||||||
|
l = u.utils,
|
||||||
|
c = l.Type,
|
||||||
|
g = l.isFile;
|
||||||
|
return (
|
||||||
|
f("DID_LOAD_ITEM", (u, f) => {
|
||||||
|
var l = f.query;
|
||||||
|
return new Promise((f, c) => {
|
||||||
|
var s = u.file;
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
g(s) &&
|
||||||
|
((A) => /^image\/jpeg/.test(A.type))(s) &&
|
||||||
|
l("GET_ALLOW_IMAGE_EXIF_ORIENTATION") &&
|
||||||
|
d
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return f(u);
|
||||||
|
((u) =>
|
||||||
|
new Promise((d, f) => {
|
||||||
|
var l = new FileReader();
|
||||||
|
(l.onload = (u) => {
|
||||||
|
var f = new DataView(u.target.result);
|
||||||
|
if (o(f, 0) === A) {
|
||||||
|
for (var l = f.byteLength, c = 2; c < l; ) {
|
||||||
|
var g = o(f, c);
|
||||||
|
if (((c += 2), g === e)) {
|
||||||
|
if (a(f, (c += 2)) !== n) break;
|
||||||
|
var s = o(f, (c += 6)) === t;
|
||||||
|
c += a(f, c + 4, s);
|
||||||
|
var v = o(f, c, s);
|
||||||
|
c += 2;
|
||||||
|
for (var w = 0; w < v; w++)
|
||||||
|
if (o(f, c + 12 * w, s) === i)
|
||||||
|
return void d(o(f, c + 12 * w + 8, s));
|
||||||
|
} else {
|
||||||
|
if ((g & r) !== r) break;
|
||||||
|
c += o(f, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d(-1);
|
||||||
|
} else d(-1);
|
||||||
|
}),
|
||||||
|
l.readAsArrayBuffer(u.slice(0, 65536));
|
||||||
|
}))(s).then((A) => {
|
||||||
|
u.setMetadata("exif", { orientation: A }), f(u);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
{ options: { allowImageExifOrientation: [!0, c.BOOLEAN] } }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
"undefined" != typeof window &&
|
||||||
|
void 0 !== window.document &&
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent("FilePond:pluginloaded", { detail: l }),
|
||||||
|
),
|
||||||
|
l
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
98
app/public/partage/fragments/draft.php
Normal file
98
app/public/partage/fragments/draft.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Partagé autosave draft endpoint.
|
||||||
|
*
|
||||||
|
* POST — receive all form fields and persist them to the session draft store.
|
||||||
|
* GET — return all stored draft fields as JSON for page-load hydration.
|
||||||
|
*
|
||||||
|
* The draft is scoped to the share link slug, kept in $_SESSION, and
|
||||||
|
* cleared on successful form submission.
|
||||||
|
*
|
||||||
|
* Excluded field patterns (not persisted as drafts):
|
||||||
|
* - csrf_token, share_link_token, share_password*
|
||||||
|
* - FilePond metadata (filepond_mode, queue_file, filepond_*)
|
||||||
|
* - Files-related fields (couverture, note_intention, files, annexes, etc.)
|
||||||
|
* - Empty values
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../../../bootstrap.php';
|
||||||
|
App::boot();
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
// ── CSRF check ──────────────────────────────────────────────────────────
|
||||||
|
if ($method === 'POST') {
|
||||||
|
if (
|
||||||
|
!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||||
|
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
|
||||||
|
) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'Token de sécurité invalide.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slug validation ─────────────────────────────────────────────────────
|
||||||
|
$slug = $_GET['slug'] ?? ($_POST['slug'] ?? '');
|
||||||
|
if (!preg_match('#^\d{8}-[A-Z0-9+/]{8}$#', $slug)) {
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'Slug invalide.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draft storage key
|
||||||
|
$draftKey = 'partage_draft_' . $slug;
|
||||||
|
|
||||||
|
// ── POST: save all form fields ──────────────────────────────────────────
|
||||||
|
if ($method === 'POST') {
|
||||||
|
// Fields that should never be persisted as drafts
|
||||||
|
$excludePrefixes = [
|
||||||
|
'csrf_token', 'share_link_token', 'share_password',
|
||||||
|
'filepond_mode', 'queue_file', 'filepond_',
|
||||||
|
];
|
||||||
|
$excludeExact = ['slug', 'couverture', 'note_intention', 'files', 'annexes',
|
||||||
|
'peertube_video', 'peertube_audio', 'cover_remove',
|
||||||
|
'go', 'MAX_FILE_SIZE'];
|
||||||
|
|
||||||
|
$draft = [];
|
||||||
|
foreach ($_POST as $key => $value) {
|
||||||
|
// Skip excluded fields
|
||||||
|
if (in_array($key, $excludeExact, true)) continue;
|
||||||
|
$skip = false;
|
||||||
|
foreach ($excludePrefixes as $prefix) {
|
||||||
|
if (str_starts_with($key, $prefix)) { $skip = true; break; }
|
||||||
|
}
|
||||||
|
if ($skip) continue;
|
||||||
|
|
||||||
|
// Skip empty values (but keep '0' as valid)
|
||||||
|
if ($value === '' || $value === null || (is_array($value) && count($value) === 0)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION[$draftKey] = $draft;
|
||||||
|
|
||||||
|
// Rotate CSRF after mutation — keep share CSRF in sync
|
||||||
|
$newToken = bin2hex(random_bytes(32));
|
||||||
|
$_SESSION['csrf_token'] = $newToken;
|
||||||
|
$_SESSION['share_csrf_' . $slug] = $newToken;
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'csrf_token' => $newToken,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET: return draft fields for hydration ──────────────────────────────
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$draft = $_SESSION[$draftKey] ?? [];
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'draft' => $draft,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
@@ -8,15 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
class AppLogger
|
class AppLogger
|
||||||
{
|
{
|
||||||
private string $logDir;
|
public function __construct()
|
||||||
private string $logFile;
|
|
||||||
|
|
||||||
public function __construct(?string $logDir = null)
|
|
||||||
{
|
{
|
||||||
$this->logDir = $logDir ?? (defined('STORAGE_ROOT') ? STORAGE_ROOT . '/logs' : __DIR__ . '/../storage/logs');
|
|
||||||
|
|
||||||
// Keep for backward compat — actual file I/O is now handled by Monolog via Logger::get('app')
|
|
||||||
$this->logFile = $this->logDir . '/form-submissions.log';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ class ExportController
|
|||||||
$ptInstanceUrl = $this->getPeerTubeInstanceUrl();
|
$ptInstanceUrl = $this->getPeerTubeInstanceUrl();
|
||||||
}
|
}
|
||||||
$tid = (int) $f['thesis_id'];
|
$tid = (int) $f['thesis_id'];
|
||||||
$peertubeLinks[$tid] = $peertubeLinks[$tid] ?? ['dirname' => '', 'links' => []];
|
$peertubeLinks[$tid] ??= ['dirname' => '', 'links' => []];
|
||||||
$peertubeLinks[$tid]['links'][] = [
|
$peertubeLinks[$tid]['links'][] = [
|
||||||
'uuid' => $uuid,
|
'uuid' => $uuid,
|
||||||
'url' => $ptInstanceUrl !== '' ? rtrim($ptInstanceUrl, '/') . '/videos/watch/' . $uuid : '',
|
'url' => $ptInstanceUrl !== '' ? rtrim($ptInstanceUrl, '/') . '/videos/watch/' . $uuid : '',
|
||||||
|
|||||||
@@ -107,10 +107,14 @@ class TfeController
|
|||||||
. ' – XAMXAM';
|
. ' – XAMXAM';
|
||||||
|
|
||||||
// Editable messages
|
// Editable messages
|
||||||
$restrictedMessage = $this->db->getSetting('tfe_restricted_message',
|
$restrictedMessage = $this->db->getSetting(
|
||||||
'Les fichiers attachés à ce TFE sont réservés aux utilisateur·ices autorisé·es.');
|
'tfe_restricted_message',
|
||||||
$forbiddenMessage = $this->db->getSetting('tfe_forbidden_message',
|
'Les fichiers attachés à ce TFE sont réservés aux utilisateur·ices autorisé·es.'
|
||||||
"Ce TFE n'est pas disponible en ligne.");
|
);
|
||||||
|
$forbiddenMessage = $this->db->getSetting(
|
||||||
|
'tfe_forbidden_message',
|
||||||
|
"Ce TFE n'est pas disponible en ligne."
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Core data
|
// Core data
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class ThesisEditController
|
|||||||
$licenseTypes = $this->db->getAllLicenseTypes();
|
$licenseTypes = $this->db->getAllLicenseTypes();
|
||||||
$enabledAccessTypes = $this->db->getEnabledFormAccessTypes();
|
$enabledAccessTypes = $this->db->getEnabledFormAccessTypes();
|
||||||
|
|
||||||
$rawRow = $this->db->getThesisRawFields($thesisId);
|
$rawRow = $this->db->getThesisRawFields($thesisId) ?? [];
|
||||||
$currentLicenseId = $rawRow['license_id'] ?? null;
|
$currentLicenseId = $rawRow['license_id'] ?? null;
|
||||||
$currentAccessTypeId = $rawRow['access_type_id'] ?? null;
|
$currentAccessTypeId = $rawRow['access_type_id'] ?? null;
|
||||||
$currentContextNote = $rawRow['context_note'] ?? '';
|
$currentContextNote = $rawRow['context_note'] ?? '';
|
||||||
|
|||||||
@@ -2016,7 +2016,7 @@ class Database
|
|||||||
* Return the raw FK fields not exposed through v_theses_full string columns.
|
* Return the raw FK fields not exposed through v_theses_full string columns.
|
||||||
* Returns ['license_id', 'access_type_id', 'context_note'] or null if not found.
|
* Returns ['license_id', 'access_type_id', 'context_note'] or null if not found.
|
||||||
*
|
*
|
||||||
* @return array{license_id:int|null,access_type_id:int|null,context_note:string}|null
|
* @return array{license_id:int|null,access_type_id:int|null,context_note:string,contact_visible:int|null}|null
|
||||||
*/
|
*/
|
||||||
public function getThesisRawFields(int $thesisId): ?array
|
public function getThesisRawFields(int $thesisId): ?array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ class FilepondHandler
|
|||||||
|
|
||||||
// Track temp file in session so it survives page reloads
|
// Track temp file in session so it survives page reloads
|
||||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
$_SESSION['filepond_tmp'][$queueType] = $_SESSION['filepond_tmp'][$queueType] ?? [];
|
$_SESSION['filepond_tmp'][$queueType] ??= [];
|
||||||
$_SESSION['filepond_tmp'][$queueType][] = $fileId;
|
$_SESSION['filepond_tmp'][$queueType][] = $fileId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Monolog\Handler\RotatingFileHandler;
|
|
||||||
use Monolog\Handler\NullHandler;
|
|
||||||
use Monolog\Formatter\LineFormatter;
|
use Monolog\Formatter\LineFormatter;
|
||||||
|
use Monolog\Handler\NullHandler;
|
||||||
|
use Monolog\Handler\RotatingFileHandler;
|
||||||
use Monolog\Level;
|
use Monolog\Level;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ CREATE TABLE IF NOT EXISTS theses (
|
|||||||
finality_id INTEGER,
|
finality_id INTEGER,
|
||||||
synopsis TEXT,
|
synopsis TEXT,
|
||||||
context_note TEXT,
|
context_note TEXT,
|
||||||
|
contact_visible TEXT DEFAULT NULL,
|
||||||
remarks TEXT,
|
remarks TEXT,
|
||||||
access_type_id INTEGER,
|
access_type_id INTEGER,
|
||||||
license_id INTEGER,
|
license_id INTEGER,
|
||||||
@@ -407,6 +408,7 @@ SELECT
|
|||||||
ft.name as finality_type,
|
ft.name as finality_type,
|
||||||
t.synopsis,
|
t.synopsis,
|
||||||
t.context_note,
|
t.context_note,
|
||||||
|
t.contact_visible,
|
||||||
at.name as access_type,
|
at.name as access_type,
|
||||||
lt.name as license_type,
|
lt.name as license_type,
|
||||||
t.license_id,
|
t.license_id,
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
* ?string $contactPublic — contact visibility flag for edit mode
|
* ?string $contactPublic — contact visibility flag for edit mode
|
||||||
* ?string $contactInterne — contact email for edit mode
|
* ?string $contactInterne — contact email for edit mode
|
||||||
*
|
*
|
||||||
|
* Autosave:
|
||||||
|
* string $formExtraAttrs — extra HTML attributes to inject into the <form> tag
|
||||||
|
* bool $showAutosaveStatus — render the "Brouillon enregistré" status indicator
|
||||||
|
*
|
||||||
* Website:
|
* Website:
|
||||||
* string $existingWebsiteUrl
|
* string $existingWebsiteUrl
|
||||||
* string $existingWebsiteLabel
|
* string $existingWebsiteLabel
|
||||||
@@ -56,6 +60,8 @@
|
|||||||
|
|
||||||
// ── Defaults ──────────────────────────────────────────────────────────────────
|
// ── Defaults ──────────────────────────────────────────────────────────────────
|
||||||
$mode = $mode ?? 'add';
|
$mode = $mode ?? 'add';
|
||||||
|
$formExtraAttrs = $formExtraAttrs ?? '';
|
||||||
|
$showAutosaveStatus = $showAutosaveStatus ?? false;
|
||||||
// In admin add/edit, no field is required (admins can save partial records)
|
// In admin add/edit, no field is required (admins can save partial records)
|
||||||
$adminMode = ($mode === 'add' || $mode === 'edit');
|
$adminMode = ($mode === 'add' || $mode === 'edit');
|
||||||
$formAction = $formAction ?? '';
|
$formAction = $formAction ?? '';
|
||||||
@@ -146,7 +152,7 @@ $errorFieldName = $errorFieldName ?? null;
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form action="<?= $formAction ?>" method="post" enctype="multipart/form-data" class="admin-form" data-beforeunload-guard>
|
<form action="<?= $formAction ?>" method="post" enctype="multipart/form-data" class="admin-form" data-beforeunload-guard <?= $formExtraAttrs ?>>
|
||||||
<!-- Default: JS-disabled mode (disabled → not submitted → server uses $_FILES path).
|
<!-- Default: JS-disabled mode (disabled → not submitted → server uses $_FILES path).
|
||||||
On DOMContentLoaded, JS enables this input and sets value="1" → server uses FilePond path. -->
|
On DOMContentLoaded, JS enables this input and sets value="1" → server uses FilePond path. -->
|
||||||
<input type="hidden" name="filepond_mode" value="0" disabled>
|
<input type="hidden" name="filepond_mode" value="0" disabled>
|
||||||
@@ -540,6 +546,10 @@ if ($filesMode === 'add'): ?>
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($showAutosaveStatus): ?>
|
||||||
|
<div class="autosave-status" data-autosave-status></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="form-footer admin-form-footer">
|
<div class="form-footer admin-form-footer">
|
||||||
<button type="submit" name="go" class="btn btn--primary"><?= $mode === 'edit' ? 'Enregistrer' : 'Soumettre' ?></button>
|
<button type="submit" name="go" class="btn btn--primary"><?= $mode === 'edit' ? 'Enregistrer' : 'Soumettre' ?></button>
|
||||||
<?php if ($mode === 'add' || $mode === 'edit'): ?>
|
<?php if ($mode === 'add' || $mode === 'edit'): ?>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"**",
|
"**",
|
||||||
"!app/public/assets/js/htmx.min.js",
|
"!app/public/assets/js/htmx.min.js",
|
||||||
"!app/public/assets/js/overtype.min.js",
|
"!app/public/assets/js/overtype.min.js",
|
||||||
"!app/public/assets/js/sortable.min.js"
|
"!app/public/assets/js/sortable.min.js",
|
||||||
|
"!app/public/assets/js/vendor/**"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class TestDatabase
|
|||||||
}
|
}
|
||||||
// Re-seed tags (some tests rely on tags existing)
|
// Re-seed tags (some tests rely on tags existing)
|
||||||
try {
|
try {
|
||||||
$pdo->exec("DELETE FROM tags WHERE deleted_at IS NOT NULL");
|
$pdo->exec('DELETE FROM tags WHERE deleted_at IS NOT NULL');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// tags table already empty
|
// tags table already empty
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class AutofocusFieldForErrorTest extends TestCase
|
|||||||
|
|
||||||
public function testCreateAutofocusFinality(): void
|
public function testCreateAutofocusFinality(): void
|
||||||
{
|
{
|
||||||
$this->assertSame('finality', ThesisCreateController::autofocusFieldForError("La finalité est manquante."));
|
$this->assertSame('finality', ThesisCreateController::autofocusFieldForError('La finalité est manquante.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateAutofocusLanguages(): void
|
public function testCreateAutofocusLanguages(): void
|
||||||
|
|||||||
@@ -252,20 +252,20 @@ class DatabaseExtendedTest extends TestCase
|
|||||||
$pdo = TestDatabase::getPDO();
|
$pdo = TestDatabase::getPDO();
|
||||||
|
|
||||||
// Count seed languages first (français, anglais, néerlandais, italian)
|
// Count seed languages first (français, anglais, néerlandais, italian)
|
||||||
$seedCount = (int)$pdo->query("SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL")->fetchColumn();
|
$seedCount = (int)$pdo->query('SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL')->fetchColumn();
|
||||||
|
|
||||||
// Insert two languages that differ only by case
|
// Insert two languages that differ only by case
|
||||||
$pdo->prepare("INSERT INTO languages (name) VALUES ('TestLang')")->execute();
|
$pdo->prepare("INSERT INTO languages (name) VALUES ('TestLang')")->execute();
|
||||||
$pdo->prepare("INSERT INTO languages (name) VALUES ('testlang')")->execute();
|
$pdo->prepare("INSERT INTO languages (name) VALUES ('testlang')")->execute();
|
||||||
|
|
||||||
// Both seed + 2 new should exist before dedup
|
// Both seed + 2 new should exist before dedup
|
||||||
$before = $pdo->query("SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL")->fetchColumn();
|
$before = $pdo->query('SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL')->fetchColumn();
|
||||||
$this->assertSame($seedCount + 2, (int)$before);
|
$this->assertSame($seedCount + 2, (int)$before);
|
||||||
|
|
||||||
$this->db->deduplicateLanguages();
|
$this->db->deduplicateLanguages();
|
||||||
|
|
||||||
// One of the dupes should be soft-deleted
|
// One of the dupes should be soft-deleted
|
||||||
$after = $pdo->query("SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL")->fetchColumn();
|
$after = $pdo->query('SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL')->fetchColumn();
|
||||||
$this->assertSame($seedCount + 1, (int)$after);
|
$this->assertSame($seedCount + 1, (int)$after);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ class DatabaseExtendedTest extends TestCase
|
|||||||
|
|
||||||
// Create a thesis linked to 'Français'
|
// Create a thesis linked to 'Français'
|
||||||
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('Merge Test', 'Author', 2024);
|
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('Merge Test', 'Author', 2024);
|
||||||
$pdo->prepare("INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?, ?)")
|
$pdo->prepare('INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?, ?)')
|
||||||
->execute([$thesisId, $francaisId]);
|
->execute([$thesisId, $francaisId]);
|
||||||
|
|
||||||
// Merge Français → French
|
// Merge Français → French
|
||||||
@@ -333,7 +333,7 @@ class DatabaseExtendedTest extends TestCase
|
|||||||
$tagB = (int)$pdo->lastInsertId();
|
$tagB = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('Tag Merge', 'Author', 2024);
|
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('Tag Merge', 'Author', 2024);
|
||||||
$pdo->prepare("INSERT INTO thesis_tags (tag_id, thesis_id) VALUES (?, ?)")
|
$pdo->prepare('INSERT INTO thesis_tags (tag_id, thesis_id) VALUES (?, ?)')
|
||||||
->execute([$tagB, $thesisId]);
|
->execute([$tagB, $thesisId]);
|
||||||
|
|
||||||
$this->db->mergeTag($tagB, $tagA);
|
$this->db->mergeTag($tagB, $tagA);
|
||||||
|
|||||||
@@ -15,17 +15,26 @@ class PureLogicTest extends TestCase
|
|||||||
// We need a TfeController instance to test protected methods.
|
// We need a TfeController instance to test protected methods.
|
||||||
// Use the anonymous subclass pattern.
|
// Use the anonymous subclass pattern.
|
||||||
$db = TestDatabase::getInstance();
|
$db = TestDatabase::getInstance();
|
||||||
return new class($db) extends TfeController {
|
return new class ($db) extends TfeController {
|
||||||
public function exposedSplitJuryByRole(array $jury): array { return $this->splitJuryByRole($jury); }
|
public function exposedSplitJuryByRole(array $jury): array
|
||||||
public function exposedCollectCaptionPaths(array $files): array { return $this->collectCaptionPaths($files); }
|
{
|
||||||
|
return $this->splitJuryByRole($jury);
|
||||||
|
}
|
||||||
|
public function exposedCollectCaptionPaths(array $files): array
|
||||||
|
{
|
||||||
|
return $this->collectCaptionPaths($files);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getThesisCreateController(): ThesisCreateController
|
private function getThesisCreateController(): ThesisCreateController
|
||||||
{
|
{
|
||||||
$db = TestDatabase::getInstance();
|
$db = TestDatabase::getInstance();
|
||||||
return new class($db) extends ThesisCreateController {
|
return new class ($db) extends ThesisCreateController {
|
||||||
public function exposedDetectFileType(string $mimeType, string $ext): string { return $this->detectFileType($mimeType, $ext); }
|
public function exposedDetectFileType(string $mimeType, string $ext): string
|
||||||
|
{
|
||||||
|
return $this->detectFileType($mimeType, $ext);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class TfeControllerOgTest extends TestCase
|
|||||||
*/
|
*/
|
||||||
private static function makeController(): object
|
private static function makeController(): object
|
||||||
{
|
{
|
||||||
return new class extends TfeController {
|
return new class () extends TfeController {
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
// Skip parent constructor — we don't need DB for these pure methods
|
// Skip parent constructor — we don't need DB for these pure methods
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ class ThesisEditValidationTest extends TestCase
|
|||||||
|
|
||||||
$this->assertCount(3, $members);
|
$this->assertCount(3, $members);
|
||||||
|
|
||||||
$internes = array_filter($members, fn($m) => $m['is_external'] === 0 && $m['role'] === 'lecteur');
|
$internes = array_filter($members, fn ($m) => $m['is_external'] === 0 && $m['role'] === 'lecteur');
|
||||||
$externes = array_filter($members, fn($m) => $m['is_external'] === 1 && $m['role'] === 'lecteur');
|
$externes = array_filter($members, fn ($m) => $m['is_external'] === 1 && $m['role'] === 'lecteur');
|
||||||
|
|
||||||
$this->assertCount(2, $internes);
|
$this->assertCount(2, $internes);
|
||||||
$this->assertCount(1, $externes);
|
$this->assertCount(1, $externes);
|
||||||
|
|||||||
Reference in New Issue
Block a user