mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
feat: fix file deletion on save + trash policy + documents/ prefix + relink browser
1. note_intention: Delete old file only when a genuinely new upload arrives
(32-char hex file_id), not when the FilePond pool preserves an existing
file by sending its DB integer ID. Previously the DB integer ID
triggered $hasNewNote=true, which deleted the existing note_intention
from disk+DB, then handleFilePondSingleFile couldn't re-process it
because the regex requires a hex pattern. Same fix applied to cover.
2. All file deletions now use deleteThesisFileToTrash() which renames
files to tmp/_trash/ instead of unlinking. The trash preserves
original filenames prefixed with DB id for traceability. Skips
website URLs and PeerTube refs (no disk file).
3. Storage prefix changed from theses/ to documents/ to reflect that
the folder holds all document types (determined by file_type in DB).
MediaController visibility gate supports both prefixes for backward
compat with existing files.
4. File browser + relink feature for orphaned files:
- /admin/fragments/file-browser.php — HTMX tree browser for
storage/documents/ and storage/theses/
- /admin/actions/filepond/relink.php — POST endpoint that inserts
a thesis_files row pointing to existing on-disk file
- Per-pool "📂 Relier" buttons (edit mode only)
- JS: XamxamOpenFileBrowser / XamxamRelinkFile with FilePond integration
- CSS: .relink-modal dialog + .file-browser tree styles
This commit is contained in:
@@ -550,4 +550,131 @@
|
||||
window.__xamxamDirty = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Relink file browser ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the file browser modal for a specific queue type.
|
||||
* Triggered by the "📂 Relier un fichier" button.
|
||||
*/
|
||||
window.XamxamOpenFileBrowser = (btn) => {
|
||||
var queueType = btn.dataset.queueType;
|
||||
var thesisId = btn.dataset.thesisId;
|
||||
|
||||
// Store context for the relink callback
|
||||
window.__xamxamRelinkCtx = {
|
||||
queueType: queueType,
|
||||
thesisId: thesisId,
|
||||
};
|
||||
|
||||
var modal = document.getElementById('relink-modal');
|
||||
if (!modal) {
|
||||
console.error('[relink] modal #relink-modal not found');
|
||||
return;
|
||||
}
|
||||
|
||||
var body = document.getElementById('relink-modal-body');
|
||||
body.innerHTML = '<p class="file-browser-loading">Chargement…</p>';
|
||||
|
||||
modal.showModal();
|
||||
|
||||
// Load the file browser via HTMX (or fetch if htmx not available)
|
||||
if (window.htmx) {
|
||||
window.htmx.ajax('GET', '/admin/fragments/file-browser.php', {
|
||||
target: '#relink-modal-body',
|
||||
swap: 'innerHTML',
|
||||
});
|
||||
} else {
|
||||
fetch('/admin/fragments/file-browser.php')
|
||||
.then(r => r.text())
|
||||
.then(html => { body.innerHTML = html; })
|
||||
.catch(() => { body.innerHTML = '<p class="file-browser-error">Erreur de chargement.</p>'; });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Relink a selected file to the thesis.
|
||||
* Triggered when a file is clicked in the file browser.
|
||||
*/
|
||||
window.XamxamRelinkFile = (el) => {
|
||||
var li = el.closest('.file-browser-entry');
|
||||
if (!li) return;
|
||||
|
||||
var ctx = window.__xamxamRelinkCtx || {};
|
||||
var thesisId = ctx.thesisId;
|
||||
var queueType = ctx.queueType;
|
||||
|
||||
var filePath = li.dataset.filePath;
|
||||
var fileName = li.dataset.fileName;
|
||||
var fileSize = parseInt(li.dataset.fileSize, 10) || 0;
|
||||
var ext = li.dataset.fileExt || '';
|
||||
|
||||
if (!filePath || !thesisId || !queueType) {
|
||||
console.error('[relink] missing data', { filePath, thesisId, queueType });
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine MIME from extension
|
||||
var mimeMap = {
|
||||
pdf: 'application/pdf',
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif',
|
||||
mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', mov: 'video/quicktime',
|
||||
mp3: 'audio/mpeg', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac', aac: 'audio/aac', m4a: 'audio/mp4',
|
||||
vtt: 'text/vtt',
|
||||
zip: 'application/zip', tar: 'application/x-tar', gz: 'application/gzip',
|
||||
};
|
||||
var mimeType = mimeMap[ext] || 'application/octet-stream';
|
||||
|
||||
var csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
||||
|
||||
var bodyEl = document.getElementById('relink-modal-body');
|
||||
if (bodyEl) bodyEl.innerHTML = '<p class="file-browser-loading">Reliage en cours…</p>';
|
||||
|
||||
fetch('/admin/actions/filepond/relink.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
thesis_id: parseInt(thesisId, 10),
|
||||
file_path: filePath,
|
||||
file_name: fileName,
|
||||
file_size: fileSize,
|
||||
mime_type: mimeType,
|
||||
queue_type: queueType,
|
||||
}),
|
||||
})
|
||||
.then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data })))
|
||||
.then(({ ok, status, data }) => {
|
||||
if (!ok) {
|
||||
if (bodyEl) bodyEl.innerHTML = `<p class="file-browser-error">Erreur : ${data}</p>`;
|
||||
return;
|
||||
}
|
||||
console.log('[relink] success | new_id=' + data.id);
|
||||
|
||||
// Add the new file to the FilePond pool
|
||||
var input = document.querySelector(`.tfe-file-picker[data-queue-type="${queueType}"]`);
|
||||
if (input) {
|
||||
var pond = FilePond.find(input);
|
||||
if (pond) {
|
||||
pond.addFile({
|
||||
source: String(data.id),
|
||||
options: { type: 'local' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal
|
||||
var modal = document.getElementById('relink-modal');
|
||||
if (modal) modal.close();
|
||||
|
||||
// Mark form dirty
|
||||
window.__xamxamDirty = true;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[relink] fetch error', err);
|
||||
if (bodyEl) bodyEl.innerHTML = '<p class="file-browser-error">Erreur réseau.</p>';
|
||||
});
|
||||
};
|
||||
})();
|
||||
|
||||
140
app/public/assets/js/app/jury-autocomplete.js
Normal file
140
app/public/assets/js/app/jury-autocomplete.js
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* jury-autocomplete.js — inlines autocomplete for jury member text inputs.
|
||||
*
|
||||
* Each jury sub-fieldset (<fieldset class="admin-jury-lecteurs">) gains a shared
|
||||
* suggestion dropdown positioned below the last input. Typing in any input within
|
||||
* that fieldset triggers an HTMX POST to the pill-search fragment (type=supervisor).
|
||||
* Clicking a suggestion fills the input that triggered the search.
|
||||
*
|
||||
* Data attributes on the fieldset:
|
||||
* data-jury-autocomplete — marks the fieldset for initialisation
|
||||
* data-jury-hx-post — HTMX endpoint URL (required)
|
||||
* 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(function (fieldset) {
|
||||
fieldset.setAttribute('data-jury-autocomplete-initialized', '1');
|
||||
initFieldset(fieldset);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initAll);
|
||||
document.body.addEventListener('htmx:afterSwap', initAll);
|
||||
|
||||
function initFieldset(fieldset) {
|
||||
var hxPost = fieldset.getAttribute('data-jury-hx-post') || '/admin/fragments/pill-search.php';
|
||||
var role = fieldset.getAttribute('data-jury-role') || '';
|
||||
var dropdown = fieldset.querySelector('.jury-suggestions');
|
||||
if (!dropdown) {
|
||||
dropdown = document.createElement('div');
|
||||
dropdown.className = 'jury-suggestions tag-search-suggestions';
|
||||
dropdown.setAttribute('role', 'listbox');
|
||||
// Insert after the list container
|
||||
var list = fieldset.querySelector('.admin-jury-list');
|
||||
if (list) {
|
||||
list.insertAdjacentElement('afterend', dropdown);
|
||||
} else {
|
||||
fieldset.appendChild(dropdown);
|
||||
}
|
||||
}
|
||||
|
||||
var activeInput = null;
|
||||
var selectedIdx = -1;
|
||||
var debounceTimer = null;
|
||||
|
||||
// Click on suggestion → fill the active input
|
||||
dropdown.addEventListener('click', function (e) {
|
||||
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();
|
||||
});
|
||||
|
||||
// Highlighting helper
|
||||
function highlight(idx) {
|
||||
var items = dropdown.querySelectorAll('.tag-search-item');
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
items[i].classList.toggle('tag-search-item--highlight', i === idx);
|
||||
}
|
||||
}
|
||||
|
||||
fieldset.addEventListener('input', function (e) {
|
||||
var inp = e.target.closest('input[type="text"]');
|
||||
if (!inp) return;
|
||||
|
||||
activeInput = inp;
|
||||
var q = inp.value.trim();
|
||||
|
||||
// Build the hx-include query — include hidden type=supervisor
|
||||
var typeInput = fieldset.querySelector('input[name="type"][value="supervisor"]');
|
||||
var includeSelector = typeInput ? '[name="type"][value="supervisor"]' : '';
|
||||
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(function () {
|
||||
if (q === '') {
|
||||
dropdown.innerHTML = '';
|
||||
selectedIdx = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Manual HTMX POST
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', hxPost);
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||
xhr.setRequestHeader('HX-Request', 'true');
|
||||
xhr.onload = function () {
|
||||
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);
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
fieldset.addEventListener('keydown', function (e) {
|
||||
var items = dropdown.querySelectorAll('.tag-search-item');
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
if (items.length === 0) return;
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user