mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
Replace custom file-upload-queue.js with FilePond
- Delete file-upload-queue.js (495 lines of custom queue logic) - Delete sortable.min.js dependency - Add file-upload-filepond.js: thin wrapper that upgrades .tfe-file-picker inputs to FilePond instances with storeAsFile:true for native multipart form submission (no form-submit interception needed) - Update fichiers-fragment.php: replace queue container <ul> elements and empty-state <p> with bare <input> elements that FilePond upgrades; change name attributes to queue_file[tfe][] etc. for PHP compatibility - Update add.php, edit.php, partage/index.php: swap JS/CSS refs - Clean up form.css: remove .fq-* and .tfe-file-queue custom styles, add FilePond theme overrides matching xamxam design tokens - Update dead-code fieldset-files.php for consistency Server-side stays unchanged: PHP receives ['queue_file']['tfe'][] exactly as before through native multipart submission.
This commit is contained in:
@@ -54,8 +54,8 @@ function wasSelected($key, $value) {
|
||||
|
||||
$isAdmin = true;
|
||||
$bodyClass = 'admin-body';
|
||||
$extraCss = ['/assets/css/form.css'];
|
||||
$extraJs = ['/assets/js/sortable.min.js', '/assets/js/file-upload-queue.js', '/assets/js/beforeunload-guard.js'];
|
||||
$extraCss = ['/assets/css/form.css', '/assets/css/filepond.min.css'];
|
||||
$extraJs = ['/assets/js/filepond.min.js', '/assets/js/file-upload-filepond.js', '/assets/js/beforeunload-guard.js'];
|
||||
require_once APP_ROOT . '/templates/head.php';
|
||||
include APP_ROOT . '/templates/header.php';
|
||||
include APP_ROOT . '/templates/admin/add.php';
|
||||
|
||||
@@ -39,8 +39,8 @@ try {
|
||||
}
|
||||
|
||||
$isAdmin = true; $bodyClass = 'admin-body';
|
||||
$extraCss = ['/assets/css/form.css'];
|
||||
$extraJs = ['/assets/js/sortable.min.js', '/assets/js/file-upload-queue.js', '/assets/js/beforeunload-guard.js'];
|
||||
$extraCss = ['/assets/css/form.css', '/assets/css/filepond.min.css'];
|
||||
$extraJs = ['/assets/js/filepond.min.js', '/assets/js/file-upload-filepond.js', '/assets/js/beforeunload-guard.js'];
|
||||
require_once APP_ROOT . '/templates/head.php';
|
||||
include APP_ROOT . '/templates/header.php';
|
||||
include APP_ROOT . '/templates/admin/edit.php';
|
||||
|
||||
@@ -532,7 +532,7 @@
|
||||
color: var(--danger, #c1121f);
|
||||
}
|
||||
|
||||
/* ── TFE file upload queue (.tfe-file-queue) ────────────────────────────── */
|
||||
/* ── TFE file upload (FilePond) ────────────────────────────────────────── */
|
||||
|
||||
.admin-files-fieldgroup {
|
||||
display: flex;
|
||||
@@ -540,124 +540,46 @@
|
||||
gap: var(--space-3xs);
|
||||
}
|
||||
|
||||
.tfe-file-picker {
|
||||
font-size: var(--step--1);
|
||||
background: transparent;
|
||||
/* FilePond overrides to match xamxam theme */
|
||||
.filepond--root {
|
||||
font-family: var(--font-body, inherit);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filepond--panel-root {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-primary);
|
||||
padding: var(--space-3xs) var(--space-2xs);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tfe-file-picker:hover {
|
||||
border-color: var(--accent-primary);
|
||||
.filepond--drop-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
|
||||
/* New-file queue items */
|
||||
.tfe-file-queue {
|
||||
list-style: none;
|
||||
margin: var(--space-2xs) 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xs);
|
||||
min-width: 0;
|
||||
.filepond--label-action {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tfe-queue-empty {
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-tertiary);
|
||||
margin: var(--space-3xs) 0 0;
|
||||
}
|
||||
|
||||
.tfe-file-queue:not(:empty) + .tfe-queue-empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fq-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-3xs) var(--space-xs);
|
||||
.filepond--item-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fq-icon {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fq-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3xs);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fq-name {
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 0;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.fq-size {
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.fq-label,
|
||||
.admin-file-label-input {
|
||||
font-size: var(--step--2);
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
padding: 2px 0;
|
||||
width: 100%;
|
||||
.filepond--file-info-main {
|
||||
color: var(--text-primary);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.fq-label:focus,
|
||||
.admin-file-label-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.fq-remove {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fq-drag-handle {
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
.filepond--file-info-sub {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
padding: 0 var(--space-3xs);
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.fq-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
.filepond--file-action-button {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.fq-container {
|
||||
/* wrapper div emitted by renderQueueFragment */
|
||||
.filepond--file-action-button:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* ── Existing-files list (edit form) ─────────────────────────────────────── */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Beforeunload guard — prompts the user before navigating away from unsaved changes.
|
||||
*
|
||||
* Attach to any form with a data-beforeunload-guard attribute.
|
||||
* Also watches window.__xamxamDirty (set by file-upload-queue.js).
|
||||
* Also watches window.__xamxamDirty (set by file-upload-filepond.js on FilePond events).
|
||||
* No effect when JavaScript is unavailable (form posts normally).
|
||||
*/
|
||||
(() => {
|
||||
|
||||
221
app/public/assets/js/file-upload-filepond.js
Normal file
221
app/public/assets/js/file-upload-filepond.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* file-upload-filepond.js
|
||||
*
|
||||
* Thin FilePond wrapper — replaces the old custom file-upload-queue.js.
|
||||
*
|
||||
* Architecture:
|
||||
* 1. Each <input type="file" class="tfe-file-picker"> is upgraded to a FilePond instance.
|
||||
* 2. FilePond handles drag-to-reorder, thumbnails, remove, validation — zero custom DOM.
|
||||
* 3. storeAsFile: true preserves native multipart form submission.
|
||||
* Server receives files via $_FILES indexed by each input's name attribute
|
||||
* (e.g. queue_file[tfe][], queue_file[video][], etc.).
|
||||
* 4. Validation rules are derived from ALLOWED_BY_TYPE (same as before).
|
||||
*/
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// ── Constants (mirrors file-upload-queue.js ALLOWED_BY_TYPE) ──────────
|
||||
|
||||
var ALLOWED_BY_TYPE = {
|
||||
tfe: {
|
||||
exts: ["jpg","jpeg","png","gif","webp","pdf","mp4","webm","ogv","mov","mp3","ogg","oga","wav","flac","aac","m4a","vtt","zip","tar","gz","tgz"],
|
||||
maxSize: function (f) { return (/\.pdf$/i.test(f.name) ? 100 : 500) * 1024 * 1024; },
|
||||
},
|
||||
video: {
|
||||
exts: ["mp4","webm","ogv","mov"],
|
||||
maxSize: function () { return 500 * 1024 * 1024; },
|
||||
},
|
||||
audio: {
|
||||
exts: ["mp3","ogg","oga","wav","flac","aac","m4a"],
|
||||
maxSize: function () { return 500 * 1024 * 1024; },
|
||||
},
|
||||
annexe: {
|
||||
exts: ["pdf","zip","tar","gz","tgz"],
|
||||
maxSize: function () { return 500 * 1024 * 1024; },
|
||||
},
|
||||
};
|
||||
|
||||
// Map input id → queue type
|
||||
var INPUT_ID_TO_TYPE = {
|
||||
"tfe-files-input": "tfe",
|
||||
"tfe-files-input-2": "tfe",
|
||||
"video-files-input": "video",
|
||||
"audio-files-input": "audio",
|
||||
"annexe-files-input": "annexe",
|
||||
};
|
||||
|
||||
function ext(fn) {
|
||||
var m = fn.match(/\.([^./]+)$/);
|
||||
return m ? m[1].toLowerCase() : "";
|
||||
}
|
||||
|
||||
// ── FilePond configuration per queue type ─────────────────────────────
|
||||
|
||||
function buildFilePondOptions(queueType, input) {
|
||||
var rules = ALLOWED_BY_TYPE[queueType];
|
||||
if (!rules) return null;
|
||||
|
||||
// Build acceptedFileTypes from extensions
|
||||
var mimeMap = {
|
||||
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
|
||||
gif: "image/gif", webp: "image/webp",
|
||||
pdf: "application/pdf",
|
||||
mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg", mov: "video/quicktime",
|
||||
mp3: "audio/mpeg", ogg: "audio/ogg", oga: "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", tgz: "application/gzip",
|
||||
};
|
||||
var accepted = rules.exts.map(function(e) { return mimeMap[e] || ("." + e); });
|
||||
|
||||
return {
|
||||
allowMultiple: (queueType !== "video" && queueType !== "audio"),
|
||||
allowReorder: true,
|
||||
storeAsFile: true,
|
||||
labelIdle: "Glissez-déposez vos fichiers ou <span class='filepond--label-action'>Parcourir</span>",
|
||||
acceptedFileTypes: accepted,
|
||||
labelFileTypeNotAllowed: "Type de fichier non accepté",
|
||||
fileValidateTypeLabelExpectedTypes: "Types acceptés : " + rules.exts.map(function(e) { return "." + e; }).join(", "),
|
||||
fileValidateSizeLabelMaxFileSize: function (fileSize) {
|
||||
var max = rules.maxSize({name: "", size: 0});
|
||||
return "Taille maximale : " + Math.round(max / 1024 / 1024) + " MB";
|
||||
},
|
||||
maxFileSize: function () {
|
||||
// We can't do per-file max based on extension easily with FilePond.
|
||||
// Use the larger limit and validate PDFs as a special case in the
|
||||
// beforeAddFile callback.
|
||||
return "500MB";
|
||||
},
|
||||
beforeAddFile: function (item) {
|
||||
var f = item.file;
|
||||
var max = rules.maxSize(f);
|
||||
if (f.size > max) {
|
||||
var maxMb = Math.round(max / 1024 / 1024);
|
||||
return {
|
||||
status: "error",
|
||||
main: "Fichier trop volumineux (" + (f.size / 1024 / 1024).toFixed(1) + " MB)",
|
||||
sub: "Maximum : " + maxMb + " MB."
|
||||
};
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Instance tracking ────────────────────────────────────────────────
|
||||
|
||||
var _ponds = {};
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upgrade .tfe-file-picker inputs to FilePond instances.
|
||||
* Called on page load and after HTMX swaps.
|
||||
*/
|
||||
function initFilePonds() {
|
||||
document.querySelectorAll(".tfe-file-picker").forEach(function (input) {
|
||||
// Skip already upgraded inputs
|
||||
if (input.dataset.filepondUpgraded) return;
|
||||
// Skip if input is inside an existing FilePond root
|
||||
if (input.closest(".filepond--root")) return;
|
||||
|
||||
var id = input.id;
|
||||
var queueType = INPUT_ID_TO_TYPE[id];
|
||||
if (!queueType) {
|
||||
// Try to infer from data attr on the container
|
||||
var container = input.closest("[data-queue-type]");
|
||||
if (container) queueType = container.dataset.queueType;
|
||||
}
|
||||
if (!queueType) return;
|
||||
|
||||
var options = buildFilePondOptions(queueType, input);
|
||||
if (!options) return;
|
||||
|
||||
// Preserve the input's original name for form submission
|
||||
options.name = input.getAttribute("name") || input.name || "";
|
||||
|
||||
var pond = FilePond.create(input, options);
|
||||
input.dataset.filepondUpgraded = "1";
|
||||
|
||||
// Track by id for cleanup
|
||||
var key = id || queueType;
|
||||
_ponds[key] = pond;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all FilePond instances and restore original inputs.
|
||||
* Called before HTMX swaps that replace the file block.
|
||||
*/
|
||||
function destroyFilePonds() {
|
||||
Object.keys(_ponds).forEach(function (key) {
|
||||
try {
|
||||
_ponds[key].destroy();
|
||||
} catch (_) { /* ignore */ }
|
||||
delete _ponds[key];
|
||||
});
|
||||
// Also catch any stray instances (HTMX may have replaced DOM)
|
||||
document.querySelectorAll(".tfe-file-picker[data-filepond-upgraded]").forEach(function (input) {
|
||||
delete input.dataset.filepondUpgraded;
|
||||
});
|
||||
}
|
||||
|
||||
// ── HTMX integration ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Called before HTMX replaces the #format-fichiers-block.
|
||||
* We must destroy FilePond instances on the soon-to-be-removed DOM nodes
|
||||
* to avoid leaks and file-state conflicts.
|
||||
*/
|
||||
function onHtmxBeforeSwap(evt) {
|
||||
// Only care about format-fichiers-block swaps
|
||||
if (evt.detail.target && (
|
||||
evt.detail.target.id === "format-fichiers-block" ||
|
||||
evt.detail.target.closest && evt.detail.target.closest("#format-fichiers-block")
|
||||
)) {
|
||||
destroyFilePonds();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────
|
||||
|
||||
// Hook into HTMX events if htmx is loaded
|
||||
if (window.htmx) {
|
||||
window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap);
|
||||
}
|
||||
|
||||
// Initialise on DOM ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initFilePonds();
|
||||
// Re-init handles HTMX after-swap
|
||||
if (window.htmx) {
|
||||
window.htmx.on("htmx:afterSwap", function () {
|
||||
initFilePonds();
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
initFilePonds();
|
||||
if (window.htmx) {
|
||||
window.htmx.on("htmx:afterSwap", function () {
|
||||
initFilePonds();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mark form dirty on FilePond changes (beforeunload guard) ─────────
|
||||
document.addEventListener("FilePond:addfile", function () {
|
||||
window.__xamxamDirty = true;
|
||||
});
|
||||
|
||||
// Clean dirty flag on form submit (matches beforeunload-guard.js)
|
||||
document.addEventListener("submit", function (e) {
|
||||
var form = e.target;
|
||||
if (form && form.hasAttribute && form.hasAttribute("data-beforeunload-guard")) {
|
||||
window.__xamxamDirty = false;
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -1,495 +0,0 @@
|
||||
/**
|
||||
* file-upload-queue.js
|
||||
*
|
||||
* Client-side file upload queues for TFE, Video, Audio, and Annexe files.
|
||||
* Replaces the old HTMX+PHP session-backed queue system.
|
||||
*
|
||||
* Queues:
|
||||
* tfe — main thesis files (multi-format)
|
||||
* video — video files (non-PeerTube path)
|
||||
* audio — audio files (non-PeerTube path)
|
||||
* annexe — annex files
|
||||
*
|
||||
* Architecture:
|
||||
* 1. Intercept 'change' on all .tfe-file-picker inputs.
|
||||
* 2. Validate MIME/extension/size client-side.
|
||||
* 3. Store File objects in window.__xamxamQueues.
|
||||
* 4. Render queue UI with SortableJS drag-to-reorder.
|
||||
* 5. On form submit: inject all queued files into FormData and POST normally.
|
||||
*
|
||||
* The queue containers are rendered server-side by fichiers-fragment.php
|
||||
* as empty <ul> elements with standard IDs. This script populates them.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
var ICON = {
|
||||
pdf: "\uD83D\uDCC4",
|
||||
video: "\uD83C\uDFAC",
|
||||
audio: "\uD83D\uDD0A",
|
||||
zip: "\uD83D\uDDDC\uFE0F",
|
||||
vtt: "\uD83D\uDCAC",
|
||||
image: "\uD83D\uDDBC\uFE0F",
|
||||
other: "\uD83D\uDCCE",
|
||||
};
|
||||
|
||||
var QUEUE_TYPES = ["tfe", "video", "audio", "annexe"];
|
||||
|
||||
var ALLOWED_BY_TYPE = {
|
||||
tfe: {
|
||||
exts: ["jpg","jpeg","png","gif","webp","pdf","mp4","webm","ogv","mov","mp3","ogg","oga","wav","flac","aac","m4a","vtt","zip","tar","gz","tgz"],
|
||||
maxSize: function (f) { return (/\.pdf$/i.test(f.name) ? 100 : 500) * 1024 * 1024; },
|
||||
},
|
||||
video: {
|
||||
exts: ["mp4","webm","ogv","mov"],
|
||||
maxSize: function () { return 500 * 1024 * 1024; },
|
||||
},
|
||||
audio: {
|
||||
exts: ["mp3","ogg","oga","wav","flac","aac","m4a"],
|
||||
maxSize: function () { return 500 * 1024 * 1024; },
|
||||
},
|
||||
annexe: {
|
||||
exts: ["pdf","zip","tar","gz","tgz"],
|
||||
maxSize: function () { return 500 * 1024 * 1024; },
|
||||
},
|
||||
};
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
|
||||
if (!window.__xamxamQueues) {
|
||||
window.__xamxamQueues = {};
|
||||
}
|
||||
var queues = window.__xamxamQueues;
|
||||
QUEUE_TYPES.forEach(function (qt) {
|
||||
if (!Array.isArray(queues[qt])) queues[qt] = [];
|
||||
});
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function iconFor(file) {
|
||||
var t = (file.type || "").toLowerCase(),
|
||||
n = (file.name || "").toLowerCase();
|
||||
if (/^image\//.test(t)) return ICON.image;
|
||||
if (t === "application/pdf" || /\.pdf$/.test(n)) return ICON.pdf;
|
||||
if (/^video\//.test(t) || /\.(mp4|webm|mov|ogv)$/.test(n)) return ICON.video;
|
||||
if (/^audio\//.test(t) || /\.(mp3|ogg|oga|wav|flac|aac|m4a)$/.test(n)) return ICON.audio;
|
||||
if (/\.(zip|tar|gz|tgz)$/.test(n)) return ICON.zip;
|
||||
if (/\.vtt$/.test(n)) return ICON.vtt;
|
||||
return ICON.other;
|
||||
}
|
||||
|
||||
function humanSize(b) {
|
||||
return b >= 1073741824
|
||||
? (b / 1073741824).toFixed(2) + " GB"
|
||||
: b >= 1048576
|
||||
? (b / 1048576).toFixed(2) + " MB"
|
||||
: b >= 1024
|
||||
? (b / 1024).toFixed(1) + " KB"
|
||||
: b + " B";
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/[&<>"]/g, function (c) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function ext(fn) {
|
||||
var m = fn.match(/\.([^./]+)$/);
|
||||
return m ? m[1].toLowerCase() : "";
|
||||
}
|
||||
|
||||
function genFileId() {
|
||||
return "fq_" + Math.random().toString(36).slice(2, 10) + "_" + Date.now().toString(36);
|
||||
}
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────────────
|
||||
|
||||
function validateFile(file, queueType) {
|
||||
var rules = ALLOWED_BY_TYPE[queueType];
|
||||
if (!rules) return "Type de file d'attente inconnu.";
|
||||
|
||||
var fileExt = ext(file.name);
|
||||
if (fileExt && rules.exts.indexOf(fileExt) === -1) {
|
||||
return "Type de fichier non accepté : ." + fileExt + " (" + file.name + ")";
|
||||
}
|
||||
|
||||
var max = rules.maxSize(file);
|
||||
if (file.size > max) {
|
||||
var maxMb = Math.round(max / 1024 / 1024);
|
||||
return "Fichier trop volumineux (" + (file.size / 1024 / 1024).toFixed(1) + " MB). Maximum : " + maxMb + " MB.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────
|
||||
|
||||
function getQueueContainerId(qt) {
|
||||
return {
|
||||
tfe: "tfe-file-queue-container",
|
||||
video: "video-file-queue-container",
|
||||
audio: "audio-file-queue-container",
|
||||
annexe: "annexe-file-queue-container",
|
||||
}[qt];
|
||||
}
|
||||
|
||||
function getQueueUlId(qt) {
|
||||
return {
|
||||
tfe: "tfe-file-queue",
|
||||
video: "video-file-queue",
|
||||
audio: "audio-file-queue",
|
||||
annexe: "annexe-file-queue",
|
||||
}[qt];
|
||||
}
|
||||
|
||||
function getEmptyId(qt) {
|
||||
return {
|
||||
tfe: "tfe-file-queue-empty",
|
||||
video: "video-file-queue-empty",
|
||||
audio: "audio-file-queue-empty",
|
||||
annexe: "annexe-file-queue-empty",
|
||||
}[qt];
|
||||
}
|
||||
|
||||
function renderQueue(queueType) {
|
||||
var container = document.getElementById(getQueueContainerId(queueType));
|
||||
if (!container) return;
|
||||
|
||||
var files = queues[queueType];
|
||||
var ulId = getQueueUlId(queueType);
|
||||
var emptyId = getEmptyId(queueType);
|
||||
|
||||
var html = '<ul id="' + ulId + '" class="tfe-file-queue" aria-label="Fichiers sélectionnés">';
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
var f = files[i];
|
||||
var ic = iconFor(f);
|
||||
var id = f.__xamxamId || "";
|
||||
html += '<li class="fq-item" data-fq-id="' + id + '">' +
|
||||
'<span class="fq-drag-handle" aria-hidden="true">⋮</span>' +
|
||||
'<span class="fq-icon">' + ic + '</span>' +
|
||||
'<span class="fq-info">' +
|
||||
'<span class="fq-name">' + esc(f.name) + '</span>' +
|
||||
'<span class="fq-size">' + humanSize(f.size) + '</span>' +
|
||||
'</span>' +
|
||||
'<button type="button" class="admin-btn-remove fq-remove"' +
|
||||
' aria-label="Retirer ' + esc(f.name) + '"' +
|
||||
' data-action="xamxam-remove"' +
|
||||
' data-queue="' + queueType + '"' +
|
||||
' data-file-id="' + id + '">✕</button>' +
|
||||
'</li>';
|
||||
}
|
||||
html += '</ul>';
|
||||
|
||||
var emptyStyle = files.length > 0 ? ' style="display:none"' : '';
|
||||
html += '<p id="' + emptyId + '" class="tfe-queue-empty"' + emptyStyle + '>Aucun fichier sélectionné.</p>';
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Wire Sortable
|
||||
if (window.Sortable && files.length > 1) {
|
||||
var list = container.querySelector("ul.tfe-file-queue");
|
||||
if (list) {
|
||||
Sortable.create(list, {
|
||||
handle: ".fq-drag-handle",
|
||||
animation: 150,
|
||||
onEnd: function () {
|
||||
reorderFromDOM(queueType);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mark form as dirty
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function reorderFromDOM(queueType) {
|
||||
var container = document.getElementById(getQueueContainerId(queueType));
|
||||
if (!container) return;
|
||||
var ids = Array.from(container.querySelectorAll("[data-fq-id]")).map(function (li) { return li.dataset.fqId; });
|
||||
var files = queues[queueType];
|
||||
var byId = {};
|
||||
files.forEach(function (f) { byId[f.__xamxamId] = f; });
|
||||
var reordered = ids.map(function (id) { return byId[id]; }).filter(Boolean);
|
||||
files.forEach(function (f) {
|
||||
if (reordered.indexOf(f) === -1) reordered.push(f);
|
||||
});
|
||||
queues[queueType] = reordered;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
// ── Operations ─────────────────────────────────────────────────────────
|
||||
|
||||
function addFiles(queueType, newFiles) {
|
||||
var errors = [];
|
||||
var addedFiles = [];
|
||||
for (var i = 0; i < newFiles.length; i++) {
|
||||
var err = validateFile(newFiles[i], queueType);
|
||||
if (err) {
|
||||
errors.push(err);
|
||||
} else {
|
||||
newFiles[i].__xamxamId = genFileId();
|
||||
addedFiles.push(newFiles[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (addedFiles.length > 0) {
|
||||
queues[queueType] = queues[queueType].concat(addedFiles);
|
||||
renderQueue(queueType);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function removeFile(queueType, fileId) {
|
||||
var idx = -1;
|
||||
for (var i = 0; i < queues[queueType].length; i++) {
|
||||
if (queues[queueType][i].__xamxamId === fileId) { idx = i; break; }
|
||||
}
|
||||
if (idx >= 0) {
|
||||
queues[queueType].splice(idx, 1);
|
||||
renderQueue(queueType);
|
||||
}
|
||||
}
|
||||
|
||||
// ── FormData injection on submit ───────────────────────────────────────
|
||||
|
||||
function injectQueuesIntoFormData(form, nativeFormData) {
|
||||
var fd = nativeFormData || new FormData();
|
||||
QUEUE_TYPES.forEach(function (qt) {
|
||||
var files = queues[qt];
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
fd.append("queue_file[" + qt + "][]", files[i], files[i].name);
|
||||
}
|
||||
});
|
||||
|
||||
// Append queue order hints so the server can validate user ordering
|
||||
QUEUE_TYPES.forEach(function (qt) {
|
||||
var ids = queues[qt].map(function (f) { return f.__xamxamId; });
|
||||
if (ids.length > 0) {
|
||||
fd.append("queue_order[" + qt + "]", JSON.stringify(ids));
|
||||
}
|
||||
});
|
||||
|
||||
return fd;
|
||||
}
|
||||
|
||||
// ── Dirty tracking integration ─────────────────────────────────────────
|
||||
|
||||
function markDirty() {
|
||||
// Set a global flag that beforeunload-guard.js can check
|
||||
window.__xamxamDirty = true;
|
||||
}
|
||||
|
||||
function markClean() {
|
||||
window.__xamxamDirty = false;
|
||||
}
|
||||
|
||||
// ── Validate callback for inline validation fragments ──────────────────
|
||||
|
||||
function showValidationMsg(input, msg) {
|
||||
// Find the .file-validation-msg element scoped to this input's form
|
||||
var form = input.closest(".file-validation-form");
|
||||
var msgEl = form ? form.querySelector(".file-validation-msg") : null;
|
||||
if (msgEl) {
|
||||
msgEl.innerHTML = '<span class="file-validation-error">' + esc(msg) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function clearValidationMsg(input) {
|
||||
var form = input.closest(".file-validation-form");
|
||||
var msgEl = form ? form.querySelector(".file-validation-msg") : null;
|
||||
if (msgEl) {
|
||||
msgEl.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Binding ────────────────────────────────────────────────────────────
|
||||
|
||||
function bindQueueInputs() {
|
||||
document.querySelectorAll(".tfe-file-picker").forEach(function (input) {
|
||||
if (input.dataset.xamxamBound) return;
|
||||
input.dataset.xamxamBound = "1";
|
||||
|
||||
var queueType = null;
|
||||
// Determine queue type from input attributes
|
||||
if (input.id === "tfe-files-input") queueType = "tfe";
|
||||
else if (input.id === "video-files-input") queueType = "video";
|
||||
else if (input.id === "audio-files-input") queueType = "audio";
|
||||
else if (input.id === "annexe-files-input") queueType = "annexe";
|
||||
else if (input.getAttribute("hx-vals")) {
|
||||
// Legacy: try to parse from hx-vals
|
||||
try {
|
||||
var vals = JSON.parse(input.getAttribute("hx-vals"));
|
||||
if (vals && vals.queue_type && QUEUE_TYPES.indexOf(vals.queue_type) >= 0) {
|
||||
queueType = vals.queue_type;
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!queueType) return;
|
||||
|
||||
input.addEventListener("change", function () {
|
||||
clearValidationMsg(input);
|
||||
var files = Array.from(input.files || []);
|
||||
var errors = addFiles(queueType, files);
|
||||
if (errors.length > 0) {
|
||||
showValidationMsg(input, errors.join("; "));
|
||||
}
|
||||
// Reset file input so the same file can be re-selected
|
||||
input.value = "";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate click events for remove buttons (these are regenerated on
|
||||
* every renderQueue call, so live delegation is needed).
|
||||
*/
|
||||
function bindRemoveButtons() {
|
||||
document.addEventListener("click", function (e) {
|
||||
var btn = e.target.closest("button[data-action='xamxam-remove']");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
var qt = btn.getAttribute("data-queue");
|
||||
var fid = btn.getAttribute("data-file-id");
|
||||
if (qt && fid) removeFile(qt, fid);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Form submit interception ───────────────────────────────────────────
|
||||
|
||||
// Track whether we've already intercepted this submit to prevent double-submit
|
||||
var _xamxamActiveSubmit = false;
|
||||
|
||||
function bindFormSubmit() {
|
||||
document.querySelectorAll("form[data-beforeunload-guard]").forEach(function (form) {
|
||||
if (form.dataset.xamxamFormBound) return;
|
||||
form.dataset.xamxamFormBound = "1";
|
||||
|
||||
form.addEventListener("submit", function (e) {
|
||||
var hasFiles = QUEUE_TYPES.some(function (qt) { return queues[qt].length > 0; });
|
||||
|
||||
console.log("[file-upload-queue] submit event fired | action=" + (form.getAttribute("action") || "") + " | hasFiles=" + hasFiles + " | enctype=" + form.enctype);
|
||||
|
||||
if (!hasFiles) {
|
||||
console.log("[file-upload-queue] no queued files, passing through native submit");
|
||||
markClean();
|
||||
return; // Normal submit
|
||||
}
|
||||
|
||||
// Check if the form can accept multipart
|
||||
if (form.enctype !== "multipart/form-data") {
|
||||
console.log("[file-upload-queue] form enctype is not multipart/form-data, passing through");
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent double-submit (in case native submit falls through)
|
||||
if (_xamxamActiveSubmit) {
|
||||
console.log("[file-upload-queue] already submitting, skipping");
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
_xamxamActiveSubmit = true;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// Build FormData from form fields + inject queued files
|
||||
var fd = new FormData(form);
|
||||
|
||||
// Remove stale file input artifacts (consumed by change handler)
|
||||
QUEUE_TYPES.forEach(function (qt) {
|
||||
fd.delete("tfe");
|
||||
fd.delete("video");
|
||||
fd.delete("audio");
|
||||
fd.delete("annexe");
|
||||
});
|
||||
|
||||
fd = injectQueuesIntoFormData(form, fd);
|
||||
|
||||
markClean();
|
||||
|
||||
console.log("[file-upload-queue] injecting " + QUEUE_TYPES.map(function(qt) { return qt + ":" + queues[qt].length; }).join(", ") + " files into FormData");
|
||||
|
||||
// Use XMLHttpRequest instead of fetch so we can reliably read the
|
||||
// final URL after redirects (fetch with redirect:manual hides headers).
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", form.getAttribute("action") || "", true);
|
||||
xhr.responseType = "text";
|
||||
|
||||
xhr.onload = function () {
|
||||
_xamxamActiveSubmit = false;
|
||||
var finalUrl = xhr.responseURL || "";
|
||||
|
||||
console.log("[file-upload-queue] XHR status=" + xhr.status + " | finalUrl=" + finalUrl + " | responseLength=" + (xhr.responseText ? xhr.responseText.length : 0));
|
||||
|
||||
// If the server redirected us (responseURL differs from action), navigate there.
|
||||
// This handles the 302 redirect pattern used by formulaire.php and edit.php.
|
||||
var actionUrl = form.getAttribute("action") || "";
|
||||
if (finalUrl && finalUrl !== actionUrl && finalUrl !== window.location.href) {
|
||||
console.log("[file-upload-queue] redirecting to " + finalUrl);
|
||||
window.location.href = finalUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
// 200 with HTML — likely form errors, replace page
|
||||
if (xhr.status >= 200 && xhr.status < 300 && xhr.responseText) {
|
||||
console.log("[file-upload-queue] rendering error response HTML");
|
||||
document.open();
|
||||
document.write(xhr.responseText);
|
||||
document.close();
|
||||
// Re-bind after DOM replacement (for error re-renders)
|
||||
setTimeout(window.XamxamInitFileUploads, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: reload current page
|
||||
console.log("[file-upload-queue] unexpected response, reloading page");
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
_xamxamActiveSubmit = false;
|
||||
console.error("[file-upload-queue] XHR network error, falling back to native submit");
|
||||
// Fall back to native submit
|
||||
form.submit();
|
||||
};
|
||||
|
||||
xhr.send(fd);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Public API (called by HTMX afterSwap scripts) ──────────────────────
|
||||
|
||||
window.XamxamInitFileUploads = function () {
|
||||
bindQueueInputs();
|
||||
};
|
||||
|
||||
window.XamxamInitQueues = function () {
|
||||
// Re-render all queues from in-memory state (used when format switching
|
||||
// replaces the queues container via HTMX).
|
||||
QUEUE_TYPES.forEach(function (qt) {
|
||||
var container = document.getElementById(getQueueContainerId(qt));
|
||||
if (container && queues[qt].length > 0) {
|
||||
renderQueue(qt);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ── Bootstrap ──────────────────────────────────────────────────────────
|
||||
|
||||
bindRemoveButtons();
|
||||
bindFormSubmit();
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", window.XamxamInitFileUploads);
|
||||
} else {
|
||||
window.XamxamInitFileUploads();
|
||||
}
|
||||
})();
|
||||
2
app/public/assets/js/sortable.min.js
vendored
2
app/public/assets/js/sortable.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -8,17 +8,19 @@
|
||||
* Fixed inputs (always present in #format-fichiers-block):
|
||||
* 1. Image de couverture (optional) — single file, plain input
|
||||
* 2. Note d'intention (PDF, required) — single file, plain input
|
||||
* 3. TFE — multi-file JS queue — client-side, orderable
|
||||
* 4. Annexes checkbox + JS queue — client-side, orderable
|
||||
* 3. TFE — multi-file, FilePond-powered — client-side, orderable
|
||||
* 4. Annexes checkbox + FilePond-powered queue — client-side, orderable
|
||||
*
|
||||
* Format-specific extra inputs (#format-extras-block):
|
||||
* - Site web → URL field only
|
||||
* - Vidéo → PeerTube single upload OR multi-file JS queue
|
||||
* - Audio → PeerTube single upload OR multi-file JS queue
|
||||
* - Vidéo → PeerTube single upload OR FilePond multi-file upload
|
||||
* - Audio → PeerTube single upload OR FilePond multi-file upload
|
||||
*
|
||||
* File queues are managed entirely client-side by file-upload-queue.js.
|
||||
* This fragment only provides the empty container elements and the file
|
||||
* picker inputs.
|
||||
* File uploads are managed by FilePond (file-upload-filepond.js).
|
||||
* Each .tfe-file-picker input is upgraded to a FilePond instance.
|
||||
* storeAsFile:true preserves native multipart form submission;
|
||||
* server receives files via $_FILES indexed by name attribute
|
||||
* (e.g. queue_file[tfe][], queue_file[video][], etc.).
|
||||
*
|
||||
* Expected POST:
|
||||
* formats[] — array of selected format_type IDs
|
||||
@@ -220,29 +222,25 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']);
|
||||
?>
|
||||
</div>
|
||||
|
||||
<!-- ── 3. TFE — multi-file client-side JS queue (always) ── -->
|
||||
<!-- ── 3. TFE — multi-file upload (FilePond) ── -->
|
||||
<div class="admin-form-group admin-files-fieldgroup">
|
||||
<label for="tfe-files-input">TFE<?= !$adminMode ? ' <span class="asterisk">*</span>' : '' ?></label>
|
||||
<div class="admin-file-input">
|
||||
<input type="file" id="tfe-files-input"
|
||||
name="tfe"
|
||||
name="queue_file[tfe][]"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.mp4,.webm,.ogv,.mov,.mp3,.ogg,.oga,.wav,.flac,.aac,.m4a,.vtt,.zip,.tar,.gz,.tgz"
|
||||
class="tfe-file-picker"
|
||||
<?= !$adminMode ? 'required' : '' ?>>
|
||||
<progress id="tfe-upload-progress" value="0" max="100" style="display:none;width:100%;margin-top:var(--space-3xs);"></progress>
|
||||
<small class="admin-file-hint">
|
||||
PDF (max 100 MB) · Images (JPG/PNG/GIF/WEBP). Glissez pour réordonner.
|
||||
PDF (max 100 MB) · Images (JPG/PNG/GIF/WEBP) · Vidéo · Audio · VTT · Archives.
|
||||
Glissez pour réordonner.
|
||||
PDFs trop lourds ? <a href="https://www.bentopdf.com" target="_blank" rel="noopener">https://bentopdf.com/</a>
|
||||
</small>
|
||||
<!-- Queue container — populated by file-upload-queue.js -->
|
||||
<div id="tfe-file-queue-container" class="fq-container" data-queue-type="tfe">
|
||||
<ul id="tfe-file-queue" class="tfe-file-queue" aria-label="Fichiers sélectionnés"></ul>
|
||||
<p id="tfe-file-queue-empty" class="tfe-queue-empty">Aucun fichier sélectionné.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 4. Annexes — multi-file client-side JS queue ── -->
|
||||
<!-- ── 4. Annexes — multi-file upload (FilePond) ── -->
|
||||
<div id="annexes-input-block">
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-checkbox-label">
|
||||
@@ -262,19 +260,15 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']);
|
||||
<label for="annexe-files-input">Annexes<?= !$adminMode ? ' <span class="asterisk">*</span>' : '' ?></label>
|
||||
<div class="admin-file-input">
|
||||
<input type="file" id="annexe-files-input"
|
||||
name="annexe" multiple
|
||||
name="queue_file[annexe][]"
|
||||
multiple
|
||||
accept=".pdf,.zip,.tar,.gz"
|
||||
class="tfe-file-picker"
|
||||
<?= !$adminMode ? 'required' : '' ?>>
|
||||
<small class="admin-file-hint">PDF ou archives ZIP/TAR. Max 500 MB. Glissez pour réordonner.</small>
|
||||
<!-- Queue container — populated by file-upload-queue.js -->
|
||||
<div id="annexe-file-queue-container" class="fq-container" data-queue-type="annexe">
|
||||
<ul id="annexe-file-queue" class="tfe-file-queue" aria-label="Annexes sélectionnées"></ul>
|
||||
<p id="annexe-file-queue-empty" class="tfe-queue-empty">Aucun fichier sélectionné.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>if(window.XamxamInitQueues)window.XamxamInitQueues();</script>
|
||||
<script>if(window.XamxamInitFilePonds)window.XamxamInitFilePonds();</script>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -309,16 +303,12 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']);
|
||||
<label for="video-files-input">Vidéo<?= !$adminMode ? ' <span class="asterisk">*</span>' : '' ?></label>
|
||||
<div class="admin-file-input">
|
||||
<input type="file" id="video-files-input"
|
||||
name="video"
|
||||
name="queue_file[video][]"
|
||||
multiple
|
||||
accept="video/mp4,video/webm,video/ogg,video/quicktime,.mp4,.webm,.ogv,.mov"
|
||||
class="tfe-file-picker"
|
||||
<?= !$adminMode ? 'required' : '' ?>>
|
||||
<small class="admin-file-hint">MP4, WebM ou MOV. Max 500 MB par fichier. Glissez pour réordonner.</small>
|
||||
<!-- Queue container — populated by file-upload-queue.js -->
|
||||
<div id="video-file-queue-container" class="fq-container" data-queue-type="video">
|
||||
<ul id="video-file-queue" class="tfe-file-queue" aria-label="Fichiers sélectionnés"></ul>
|
||||
<p id="video-file-queue-empty" class="tfe-queue-empty">Aucun fichier sélectionné.</p>
|
||||
</div>
|
||||
<small class="admin-file-hint">MP4, WebM ou MOV. Max 500 MB. Glissez pour réordonner.</small>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -340,27 +330,23 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']);
|
||||
<label for="audio-files-input">Audio<?= !$adminMode ? ' <span class="asterisk">*</span>' : '' ?></label>
|
||||
<div class="admin-file-input">
|
||||
<input type="file" id="audio-files-input"
|
||||
name="audio"
|
||||
name="queue_file[audio][]"
|
||||
multiple
|
||||
accept="audio/mpeg,audio/ogg,audio/wav,audio/flac,audio/aac,audio/mp4,.mp3,.ogg,.oga,.wav,.flac,.aac,.m4a"
|
||||
class="tfe-file-picker"
|
||||
<?= !$adminMode ? 'required' : '' ?>>
|
||||
<small class="admin-file-hint">MP3, OGG, WAV, FLAC ou AAC. Max 500 MB par fichier. Glissez pour réordonner.</small>
|
||||
<!-- Queue container — populated by file-upload-queue.js -->
|
||||
<div id="audio-file-queue-container" class="fq-container" data-queue-type="audio">
|
||||
<ul id="audio-file-queue" class="tfe-file-queue" aria-label="Fichiers sélectionnés"></ul>
|
||||
<p id="audio-file-queue-empty" class="tfe-queue-empty">Aucun fichier sélectionné.</p>
|
||||
</div>
|
||||
<small class="admin-file-hint">MP3, OGG, WAV, FLAC ou AAC. Max 500 MB. Glissez pour réordonner.</small>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>if(window.XamxamInitQueues)window.XamxamInitQueues();</script>
|
||||
<script>if(window.XamxamInitFilePonds)window.XamxamInitFilePonds();</script>
|
||||
</div>
|
||||
|
||||
</fieldset><!-- /Fichiers -->
|
||||
|
||||
<script>
|
||||
if(window.XamxamInitFileUploads)window.XamxamInitFileUploads();
|
||||
if(window.XamxamInitFilePonds)window.XamxamInitFilePonds();
|
||||
</script>
|
||||
</div><!-- #format-fichiers-block -->
|
||||
|
||||
@@ -375,8 +375,9 @@ function renderShareLinkForm(string $slug, array $link): void
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<link rel="stylesheet" href="<?= App::assetV('/assets/css/common.css') ?>">
|
||||
<link rel="stylesheet" href="<?= App::assetV('/assets/css/form.css') ?>">
|
||||
<script src="<?= App::assetV('/assets/js/sortable.min.js') ?>" defer></script>
|
||||
<script src="<?= App::assetV('/assets/js/file-upload-queue.js') ?>" defer></script>
|
||||
<link rel="stylesheet" href="<?= App::assetV('/assets/css/filepond.min.css') ?>">
|
||||
<script src="<?= App::assetV('/assets/js/filepond.min.js') ?>" defer></script>
|
||||
<script src="<?= App::assetV('/assets/js/file-upload-filepond.js') ?>" defer></script>
|
||||
<script src="<?= App::assetV('/assets/js/beforeunload-guard.js') ?>" defer></script>
|
||||
<script src="<?= App::assetV('/assets/js/htmx.min.js') ?>" defer></script>
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user