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:
Pontoporeia
2026-05-13 14:58:15 +02:00
parent 6f7a02244f
commit 79eddf5d5a
30 changed files with 191580 additions and 187 deletions

View File

@@ -1023,6 +1023,19 @@
pointer-events: none;
}
/* ── Jury autocomplete dropdown (positioned within .admin-jury-lecteurs) ──── */
.admin-jury-lecteurs[data-jury-autocomplete] {
position: relative;
}
.admin-jury-lecteurs .jury-suggestions {
position: absolute;
top: auto;
left: 0;
right: 0;
margin-top: 2px;
}
/* Individual suggestion item */
.tag-search-item {
display: flex;
@@ -1340,3 +1353,134 @@ legend {
color: var(--text-secondary);
font-style: italic;
}
/* ── File browser (relink) ──────────────────────────────────────── */
.file-browser-trigger {
margin-top: var(--space-2xs);
font-size: var(--step--2);
}
.relink-modal {
width: min(90vw, 700px);
max-height: 80vh;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-primary);
padding: 0;
overflow: hidden;
color: var(--text-primary);
}
.relink-modal::backdrop {
background: rgba(0,0,0,0.5);
}
.relink-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-s) var(--space-m);
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.relink-modal-header h3 {
margin: 0;
font-size: var(--step-0);
}
.relink-modal-footer {
padding: var(--space-xs) var(--space-m);
border-top: 1px solid var(--border);
background: var(--bg-secondary);
}
#relink-modal-body {
padding: var(--space-s) var(--space-m);
overflow-y: auto;
max-height: 60vh;
}
/* ── File browser tree ─────────────────────────────────────────── */
.file-browser {
font-size: var(--step--1);
}
.file-browser-hint,
.file-browser-empty,
.file-browser-loading,
.file-browser-error {
color: var(--text-secondary);
text-align: center;
padding: var(--space-m);
}
.file-browser-error {
color: var(--error);
}
.file-browser-breadcrumb {
display: flex;
align-items: center;
gap: var(--space-2xs);
flex-wrap: wrap;
padding-bottom: var(--space-s);
margin-bottom: var(--space-s);
border-bottom: 1px solid var(--border);
font-size: var(--step--2);
}
.file-browser-breadcrumb a {
color: var(--accent-blue, var(--link));
text-decoration: none;
}
.file-browser-breadcrumb a:hover {
text-decoration: underline;
}
.file-browser-sep {
color: var(--text-tertiary);
}
.file-browser-list {
list-style: none;
margin: 0;
padding: 0;
}
.file-browser-entry {
border-bottom: 1px solid var(--border-subtle);
}
.file-browser-entry a,
.file-browser-select-btn {
display: flex;
align-items: center;
gap: var(--space-xs);
width: 100%;
padding: var(--space-xs) var(--space-2xs);
text-decoration: none;
color: var(--text-primary);
background: none;
border: none;
cursor: pointer;
font-size: var(--step--1);
text-align: left;
transition: background 0.15s;
}
.file-browser-entry a:hover,
.file-browser-select-btn:hover {
background: var(--bg-secondary);
}
.file-browser-icon {
flex-shrink: 0;
font-size: var(--step-0);
}
.file-browser-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-browser-size {
flex-shrink: 0;
color: var(--text-tertiary);
font-size: var(--step--2);
font-variant-numeric: tabular-nums;
}
.file-browser-file .file-browser-select-btn {
color: var(--accent-green, var(--success));
}
.file-browser-file .file-browser-select-btn:hover {
background: var(--bg-secondary);
}

View File

@@ -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>';
});
};
})();

View 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;
}
});
}
})();