Files
xamxam/app/templates/admin/index.php
Pontoporeia 678f9fc804 Index page: remove Mots-clés button, move export to bulk selection, fix ZipArchive error, move DB export to paramètres, sticky thead
- Remove 'Mots-clés' button from toolbar (redundant with admin sidebar tags)
- Replace export dialog with 'Exporter CSV' + 'Exporter fichiers' buttons in bulk selection bar
- Export dispatcher now accepts ?ids=1,2,3 for per-selection export
- All ExportController/Database methods accept optional thesisIds array
- Graceful error message when ZipArchive extension is missing on server
- Move DB export (SQLite download) to paramètres → Maintenance section
- Sticky table column headers (position: sticky, top: 0, z-index: 5) for index page table
2026-05-19 23:58:51 +02:00

367 lines
21 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
function toggleAll(src){document.querySelectorAll('input[name="selected_theses[]"]').forEach(cb=>cb.checked=src.checked);updateBulk();}
function updateBulk(){const n=document.querySelectorAll('input[name="selected_theses[]"]:checked').length;const b=document.getElementById('bulk-actions');document.getElementById('selected-count').textContent=n;b.style.display=n>0?'flex':'none';}
function getSelectedIds(){return Array.from(document.querySelectorAll('input[name="selected_theses[]"]:checked')).map(cb=>cb.value);}
function confirmBulk(act){const ids=getSelectedIds();if(!ids.length){document.getElementById('no-selection-dialog').showModal();return;}const n=ids.length;document.getElementById('bulk-action-input').value=act;if(act==='delete'){document.getElementById('bulk-delete-count').textContent=n;document.getElementById('bulk-delete-dialog').showModal();}else{document.getElementById('bulk-confirm-word').textContent=act=='publish'?'Publier':'Dépublier';document.getElementById('bulk-confirm-count').textContent=n;document.getElementById('bulk-confirm-dialog').showModal();}}
function execBulk(){const a=document.getElementById('bulk-action-input').value;const f=document.getElementById('bulk-form');f.action = a=='delete' ? 'actions/delete.php' : 'actions/publish.php';const c=document.getElementById('bulk-checkboxes');c.innerHTML='';getSelectedIds().forEach(id=>{const inp=document.createElement('input');inp.type='hidden';inp.name='selected_theses[]';inp.value=id;c.appendChild(inp);});f.submit();}
function confirmExport(){const ids=getSelectedIds();if(!ids.length){document.getElementById('no-selection-dialog').showModal();return;}window.location.href='/admin/actions/export.php?csv=1&ids='+ids.join(',');}
function confirmExportFiles(){const ids=getSelectedIds();if(!ids.length){document.getElementById('no-selection-dialog').showModal();return;}window.location.href='/admin/actions/export.php?files=1&ids='+ids.join(',');}
function confirmDelete(id,title){document.getElementById('delete-thesis-title').textContent=title;document.getElementById('delete-thesis-dialog').showModal();document.getElementById('delete-dialog-confirm').onclick=function(){document.getElementById('delete-form-'+id).submit();};}
document.addEventListener('DOMContentLoaded',()=>{document.querySelectorAll('input[name="selected_theses[]"]').forEach(cb=>cb.addEventListener('change',updateBulk));});
document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input[name="selected_theses[]"]').forEach(cb=>cb.addEventListener('change',updateBulk));updateBulk();});
</script>
<main id="main-content" class="admin-main--list">
<!-- Title + filters + stats + import all in one toolbar row -->
<div class="admin-list-toolbar admin-list-toolbar--list">
<div class="admin-toolbar-top">
<div class="admin-toolbar-title-row">
<h1>Liste des TFE</h1>
<div class="admin-stats">
<fieldset class="admin-stat">
<legend class="admin-stat__label">Total</legend>
<span class="admin-stat__number"><?= $stats['total'] ?></span>
</fieldset>
<fieldset class="admin-stat admin-stat--pub">
<legend class="admin-stat__label">Publiés</legend>
<span class="admin-stat__number"><?= $stats['published'] ?></span>
</fieldset>
<fieldset class="admin-stat admin-stat--pend">
<legend class="admin-stat__label">Attente</legend>
<span class="admin-stat__number"><?= $stats['pending'] ?></span>
</fieldset>
</div>
</div>
<div class="admin-btn-group">
<a href="/admin/add.php" class="btn btn--primary btn--sm">+ Ajouter un TFE</a>
<?php if ($trashCount > 0): ?>
<a href="/admin/index.php?tab=trash" class="btn btn--sm <?= $tab === 'trash' ? 'btn--primary' : 'btn--secondary' ?>">
Corbeille (<?= $trashCount ?>)
</a>
<?php endif; ?>
<?php if ($tmpTotalCount > 0): ?>
<button type="button" class="btn btn--sm btn--secondary" id="tmp-cleanup-btn"
onclick="document.getElementById('tmp-cleanup-dialog').showModal(); fetchTmpStats()">
Nettoyer (<?= $tmpTotalCount ?>)
</button>
<?php endif; ?>
<button type="button" class="btn btn--primary btn--sm" id="import-dialog-btn"
onclick="document.getElementById('import-dialog').showModal()">
Importer
</button>
</div>
</div>
<form id="admin-filter-form" class="admin-filters" method="get" action="/admin/"
hx-get="/admin/"
hx-trigger="change from:select, input changed delay:300ms from:input[name=search], keyup[key=='Enter'] from:input[name=search]"
hx-target="#admin-table-wrap"
hx-swap="innerHTML"
hx-indicator="#admin-search-indicator"
hx-include="#admin-filter-form, #admin-table-wrap input[name=sort], #admin-table-wrap input[name=dir]"
hx-push-url="true"
hx-sync="#admin-filter-form:replace">
<input type="text" name="search" placeholder="Titre, auteur..."
value="<?= htmlspecialchars($searchQuery) ?>">
<select name="year">
<option value="">Année</option>
<?php foreach ($years as $y): ?>
<option value="<?= $y ?>" <?= $yearFilter == $y ? 'selected' : '' ?>><?= $y ?></option>
<?php endforeach; ?>
</select>
<select name="orientation">
<option value="">Orientation</option>
<?php foreach ($orientations as $o): ?>
<option value="<?= $o['id'] ?>" <?= $orientationFilter == $o['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($o['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="ap">
<option value="">AP</option>
<?php foreach ($apPrograms as $ap): ?>
<option value="<?= $ap['id'] ?>" <?= $apFilter == $ap['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($ap['code'] ?? $ap['name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($searchQuery || $yearFilter || $orientationFilter || $apFilter): ?>
<a href="/admin/" class="btn btn--secondary btn--sm admin-filters-reset">&#x2715; Réinitialiser</a>
<?php endif; ?>
</form>
<!-- Loading indicator bar -->
<div id="admin-search-indicator" class="admin-search-indicator"></div>
</div>
<?php include APP_ROOT . '/templates/admin/index-table.php'; ?>
</main>
<!-- ══════════════════════════════════════════════════════════════
CONFIRM DIALOGS (replacing browser alert/confirm)
══════════════════════════════════════════════════════════════ -->
<!-- No-selection alert -->
<dialog id="no-selection-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="no-sel-title">
<div class="admin-dialog__header">
<h2 id="no-sel-title">Aucune sélection</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="this.closest('dialog').close()">&#x2715;</button>
</div>
<div class="admin-dialog__alert">
<p>Sélectionnez au moins un TFE avant d'effectuer une action groupée.</p>
</div>
<div class="admin-dialog__footer">
<button type="button" class="btn btn--primary" onclick="this.closest('dialog').close()">OK</button>
</div>
</dialog>
<!-- Bulk publish/unpublish confirm -->
<dialog id="bulk-confirm-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="bulk-confirm-title">
<div class="admin-dialog__header">
<h2 id="bulk-confirm-title">Confirmation</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="this.closest('dialog').close()">&#x2715;</button>
</div>
<div class="admin-dialog__alert">
<p><span id="bulk-confirm-word"></span> <span id="bulk-confirm-count"></span> TFE(s) ?</p>
</div>
<div class="admin-dialog__footer">
<button type="button" class="btn btn--primary" onclick="this.closest('dialog').close(); execBulk()">Confirmer</button>
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div>
</dialog>
<!-- Bulk delete confirm -->
<dialog id="bulk-delete-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="bulk-delete-title">
<div class="admin-dialog__header">
<h2 id="bulk-delete-title">Supprimer des TFE</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="this.closest('dialog').close()">&#x2715;</button>
</div>
<div class="admin-dialog__alert">
<p>Supprimer définitivement <strong><span id="bulk-delete-count"></span> TFE(s)</strong> ? Cette action est irréversible.</p>
</div>
<div class="admin-dialog__footer">
<button type="button" class="btn btn--danger" onclick="this.closest('dialog').close(); execBulk()">Supprimer</button>
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div>
</dialog>
<!-- Single thesis delete confirm -->
<dialog id="delete-thesis-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="delete-thesis-title-label">
<div class="admin-dialog__header">
<h2 id="delete-thesis-title-label">Supprimer ce TFE</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="this.closest('dialog').close()">&#x2715;</button>
</div>
<div class="admin-dialog__alert">
<p>Supprimer « <strong id="delete-thesis-title"></strong> » ? Cette action est irréversible.</p>
</div>
<div class="admin-dialog__footer">
<button type="button" class="btn btn--danger" id="delete-dialog-confirm" onclick="this.closest('dialog').close()">Supprimer</button>
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div>
</dialog>
<!-- ══════════════════════════════════════════════════════════════
IMPORT DIALOG
══════════════════════════════════════════════════════════════ -->
<dialog id="import-dialog" class="admin-dialog" aria-labelledby="import-dialog-title">
<div class="admin-dialog__header">
<h2 id="import-dialog-title">Importer une liste de TFE</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="document.getElementById('import-dialog').close()">&#x2715;</button>
</div>
<?php if ($importMessage || !empty($importErrors)): ?>
<div class="admin-import-status-card">
<?php if (!empty($importErrors)): ?>
<div class="toast admin-import-status-card__errors" role="alert" data-type="error">
<strong>⚠ Erreurs :</strong>
<ul class="admin-error-list">
<?php foreach ($importErrors as $err): ?>
<li><?= htmlspecialchars($err) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if ($importMessage): ?>
<p class="admin-import-status-card__success" role="status">✓ <?= htmlspecialchars($importMessage) ?></p>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($importMessage): ?>
<?php if (!empty($importResults)): ?>
<details class="admin-import-log-details">
<summary>Logs d'importation (<?= count($importResults) ?> entrées)</summary>
<ul class="admin-import-log">
<?php foreach ($importResults as $r): ?>
<li class="admin-import-log__item admin-import-log__item--<?= $r['type'] ?>"><?= htmlspecialchars($r['msg']) ?></li>
<?php endforeach; ?>
</ul>
</details>
<?php endif; ?>
<div class="admin-form-footer">
<button type="button" class="btn btn--primary"
onclick="document.getElementById('import-dialog').close(); window.location.href = window.location.pathname">Terminé</button>
</div>
<?php else: ?>
<form method="post" enctype="multipart/form-data" class="admin-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<div>
<label for="csv_file">Fichier CSV</label>
<div class="admin-file-input">
<input type="file" id="csv_file" name="csv_file" accept=".csv" required>
<small class="admin-file-hint">
Colonnes : Identifiant, Titre, Sous-titre, Auteur·ice(s), Contact, Promoteur·ice(s), Format, Année, AP, Orientation, Finalité, Mots-clés, Synopsis, Contexte, Remarques, Langue, Autorisation, License, taille, Points sur 20, lien BAIU<br>
Quatre premières lignes ignorées — Séparateur : virgule — UTF-8
</small>
</div>
</div>
<div class="admin-form-footer">
<button type="submit" class="btn btn--primary">Importer</button>
<button type="button" class="btn btn--secondary"
onclick="document.getElementById('import-dialog').close()">Annuler</button>
</div>
</form>
<?php if (!empty($importResults)): ?>
<details class="admin-import-log-details">
<summary>Logs d'importation (<?= count($importResults) ?> entrées)</summary>
<ul class="admin-import-log">
<?php foreach ($importResults as $r): ?>
<li class="admin-import-log__item admin-import-log__item--<?= $r['type'] ?>"><?= htmlspecialchars($r['msg']) ?></li>
<?php endforeach; ?>
</ul>
</details>
<?php endif; ?>
<?php endif; ?>
</dialog>
<!-- ══════════════════════════════════════════════════════════════
TMP CLEANUP DIALOG
══════════════════════════════════════════════════════════════ -->
<dialog id="tmp-cleanup-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="tmp-cleanup-title">
<div class="admin-dialog__header">
<h2 id="tmp-cleanup-title">Nettoyer les fichiers temporaires</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="document.getElementById('tmp-cleanup-dialog').close()">&#x2715;</button>
</div>
<div class="admin-dialog__body">
<p>Les fichiers temporaires s'accumulent lorsque des téléversements sont abandonnés (formulaire fermé avant envoi).</p>
<div id="tmp-cleanup-stats" class="admin-dialog__stats">
Chargement…
</div>
<p class="admin-dialog__hint">
Seuls les fichiers de plus de 2 heures (FilePond) et 30 jours (corbeille) seront supprimés.
Les téléversements récents sont conservés.
</p>
<div id="tmp-cleanup-result" style="display:none"></div>
</div>
<div class="admin-dialog__footer">
<button type="button" class="btn btn--danger" id="tmp-cleanup-confirm"
onclick="executeTmpCleanup()">
Nettoyer
</button>
<button type="button" class="btn btn--secondary"
onclick="document.getElementById('tmp-cleanup-dialog').close()">Annuler</button>
</div>
</dialog>
<script>
async function fetchTmpStats() {
const el = document.getElementById('tmp-cleanup-stats');
try {
const resp = await fetch('/admin/actions/cleanup-stats.php');
const data = await resp.json();
let html = '';
const totalStale = data.filepond_stale_count + data.trash_stale_count;
if (totalStale === 0) {
html = '<p style="margin:0;color:var(--accent-green)">✓ Aucun fichier obsolète à nettoyer.</p>';
if ((data.filepond_active_count || 0) + (data.trash_active_count || 0) > 0) {
html += '<p style="margin:var(--space-xs) 0 0 0;font-size:0.85em;color:var(--text-secondary)">';
if (data.filepond_active_count) html += `📁 ${data.filepond_active_count} téléversement(s) actif(s) (session existante) — ${data.filepond_active_human}<br>`;
if (data.trash_active_count) html += `🗑️ ${data.trash_active_count} fichier(s) récent(s) en corbeille — ${data.trash_active_human}`;
html += '</p>';
}
} else {
html = `<p style="margin:0 0 var(--space-xs) 0;font-weight:600">⚠️ ${totalStale} élément(s) obsolète(s) à nettoyer :</p>`;
if (data.filepond_stale_count) html += `<p style="margin:0 0 var(--space-xs) 0">📁 <strong>Téléversements abandonnés</strong> : ${data.filepond_stale_count} dossier(s) — ${data.filepond_stale_human} <span style="font-size:0.85em;color:var(--text-secondary)">(session expirée ou >2h)</span></p>`;
if (data.trash_stale_count) html += `<p style="margin:0 0 var(--space-xs) 0">🗑️ <strong>Fichiers supprimés orphelins</strong> : ${data.trash_stale_count} fichier(s) — ${data.trash_stale_human} <span style="font-size:0.85em;color:var(--text-secondary)">(référence DB disparue ou >30j)</span></p>`;
if (data.filepond_active_count || data.trash_active_count) {
html += '<p style="margin:var(--space-xs) 0 0 0;font-size:0.85em;color:var(--text-secondary)">Conservés : ';
if (data.filepond_active_count) html += `${data.filepond_active_count} téléversement(s) actif(s), `;
if (data.trash_active_count) html += `${data.trash_active_count} fichier(s) récent(s)`;
html += '</p>';
}
}
el.innerHTML = html;
if (totalStale === 0) {
document.getElementById('tmp-cleanup-confirm').disabled = true;
document.getElementById('tmp-cleanup-confirm').textContent = 'Rien à nettoyer';
}
} catch (e) {
el.textContent = 'Erreur lors du chargement des statistiques.';
}
}
async function executeTmpCleanup() {
const btn = document.getElementById('tmp-cleanup-confirm');
const result = document.getElementById('tmp-cleanup-result');
btn.disabled = true;
btn.textContent = 'Nettoyage…';
try {
const resp = await fetch('/admin/actions/cleanup-tmp.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'csrf_token=' + encodeURIComponent('<?= htmlspecialchars($_SESSION['csrf_token']) ?>')
});
const data = await resp.json();
result.style.display = 'block';
if (data.success) {
const total = data.filepond_removed + data.trash_removed;
if (total === 0) {
result.className = 'flash-success';
result.innerHTML = '✓ Aucun fichier obsolète trouvé.';
} else {
result.className = 'flash-success';
let msg = `✓ ${total} élément(s) supprimé(s) : `;
if (data.filepond_removed) msg += `${data.filepond_removed} téléversement(s) abandonné(s), `;
if (data.trash_removed) msg += `${data.trash_removed} fichier(s) orphelin(s)`;
result.innerHTML = msg;
if (data.details && data.details.length > 0) {
result.innerHTML += '<details style="margin-top:var(--space-xs);font-size:0.85em"><summary>Détails (' + data.details.length + ')</summary><ul style="margin:var(--space-xs) 0 0 var(--space-md)">' +
data.details.map(d => '<li>' + d + '</li>').join('') + '</ul></details>';
}
}
setTimeout(() => {
document.getElementById('tmp-cleanup-dialog').close();
window.location.reload();
}, 2000);
} else {
result.className = 'flash-error';
result.textContent = 'Erreur : ' + (data.error || 'inconnue');
btn.disabled = false;
btn.textContent = 'Réessayer';
}
} catch (e) {
result.style.display = 'block';
result.className = 'flash-error';
result.textContent = 'Erreur réseau.';
btn.disabled = false;
btn.textContent = 'Réessayer';
}
}
</script>
<?php if ($importMessage || !empty($importErrors)): ?>
<script>document.getElementById('import-dialog').showModal();</script>
<?php endif; ?>