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:
@@ -38,7 +38,15 @@ switch ($action) {
|
||||
$validObjet = ['tfe', 'thèse', 'frart'];
|
||||
$selected = is_array($objetRaw) ? array_intersect($objetRaw, $validObjet) : [];
|
||||
$objetRestriction = !empty($selected) ? implode(',', $selected) : 'tfe';
|
||||
$link = $shareLink->create(1, $expiresAt, $objetRestriction, $name);
|
||||
$lockedYearRaw = $_POST['locked_year'] ?? null;
|
||||
$lockedYear = null;
|
||||
if ($lockedYearRaw !== null && $lockedYearRaw !== '') {
|
||||
$lockedYear = filter_var($lockedYearRaw, FILTER_VALIDATE_INT);
|
||||
if ($lockedYear === false || $lockedYear < 2000 || $lockedYear > ((int)date('Y') + 3)) {
|
||||
$lockedYear = null;
|
||||
}
|
||||
}
|
||||
$link = $shareLink->create(1, $expiresAt, $objetRestriction, $name, $lockedYear);
|
||||
$logger->logLinkCreate(
|
||||
$link['slug'] ?? '',
|
||||
true, // Always has password
|
||||
@@ -86,7 +94,13 @@ switch ($action) {
|
||||
if ($id > 0) {
|
||||
$name = isset($_POST['name']) ? trim($_POST['name']) : null;
|
||||
$expiresRaw = isset($_POST['expires_at']) ? trim($_POST['expires_at']) : null;
|
||||
$shareLink->update($id, $name, $expiresRaw);
|
||||
// locked_year: null=not sent (keep), ""=clear, otherwise year string
|
||||
$lockedYearRaw = $_POST['locked_year'] ?? null;
|
||||
$lockedYear = null; // default: not sent → don't change
|
||||
if ($lockedYearRaw !== null) {
|
||||
$lockedYear = $lockedYearRaw; // pass through: "" for clear, "2026" for set
|
||||
}
|
||||
$shareLink->update($id, $name, $expiresRaw, $lockedYear);
|
||||
App::redirect('/admin/acces.php', success: 'Lien mis à jour.');
|
||||
} else {
|
||||
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
|
||||
|
||||
120
app/public/admin/actions/filepond/relink.php
Normal file
120
app/public/admin/actions/filepond/relink.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/**
|
||||
* Relink endpoint — attach an orphaned on-disk file to a thesis.
|
||||
*
|
||||
* POST /admin/actions/filepond/relink.php
|
||||
* Body: JSON { thesis_id: 123, file_path: "documents/2025/FOLDER/file.pdf",
|
||||
* file_name: "file.pdf", file_size: 12345, mime_type: "application/pdf",
|
||||
* queue_type: "tfe", file_type: "main" }
|
||||
*
|
||||
* Inserts a thesis_files row pointing to the existing on-disk file.
|
||||
* The file is NOT copied or moved — it stays where it is.
|
||||
*/
|
||||
require_once __DIR__ . '/../../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../../src/AdminAuth.php';
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
// CSRF via header
|
||||
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!isset($_SESSION['csrf_token'])
|
||||
|| !hash_equals($_SESSION['csrf_token'], $csrfHeader)) {
|
||||
http_response_code(403);
|
||||
die('Token CSRF invalide.');
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
die('Méthode non autorisée.');
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($body)) {
|
||||
http_response_code(400);
|
||||
die('JSON invalide.');
|
||||
}
|
||||
|
||||
$thesisId = filter_var($body['thesis_id'] ?? '', FILTER_VALIDATE_INT);
|
||||
$filePath = trim($body['file_path'] ?? '');
|
||||
$fileName = trim($body['file_name'] ?? basename($filePath));
|
||||
$fileSize = (int)($body['file_size'] ?? 0);
|
||||
$fileType = trim($body['file_type'] ?? '');
|
||||
$queueType = trim($body['queue_type'] ?? '');
|
||||
$mimeType = trim($body['mime_type'] ?? 'application/octet-stream');
|
||||
|
||||
if (!$thesisId || $filePath === '') {
|
||||
http_response_code(400);
|
||||
die('Paramètres invalides (thesis_id + file_path requis).');
|
||||
}
|
||||
|
||||
// Security: only allow paths under documents/ or theses/
|
||||
if (!preg_match('#^(documents|theses)/#', $filePath)) {
|
||||
http_response_code(403);
|
||||
die('Chemin de fichier non autorisé.');
|
||||
}
|
||||
|
||||
$absPath = STORAGE_ROOT . '/' . $filePath;
|
||||
$realPath = realpath($absPath);
|
||||
$realStorage = realpath(STORAGE_ROOT);
|
||||
|
||||
if ($realPath === false || !str_starts_with($realPath, $realStorage)) {
|
||||
http_response_code(404);
|
||||
die('Fichier introuvable ou chemin interdit.');
|
||||
}
|
||||
|
||||
if (!is_file($realPath)) {
|
||||
http_response_code(404);
|
||||
die('Le chemin ne pointe pas vers un fichier.');
|
||||
}
|
||||
|
||||
// Detect MIME if not provided
|
||||
if ($mimeType === 'application/octet-stream') {
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($realPath);
|
||||
}
|
||||
|
||||
// Map queue_type to file_type if not explicitly given
|
||||
if ($fileType === '') {
|
||||
$fileTypeMap = [
|
||||
'cover' => 'cover',
|
||||
'note_intention' => 'note_intention',
|
||||
'tfe' => 'main',
|
||||
'annexe' => 'annex',
|
||||
];
|
||||
$fileType = $fileTypeMap[$queueType] ?? 'main';
|
||||
}
|
||||
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Check if this file is already linked to this thesis (avoid duplicate)
|
||||
$pdo = $db->getConnection();
|
||||
$stmt = $pdo->prepare('SELECT id FROM thesis_files WHERE thesis_id = ? AND file_path = ?');
|
||||
$stmt->execute([$thesisId, $filePath]);
|
||||
if ($stmt->fetch()) {
|
||||
http_response_code(409);
|
||||
die('Ce fichier est déjà lié à ce TFE.');
|
||||
}
|
||||
|
||||
$db->insertThesisFile(
|
||||
$thesisId,
|
||||
$fileType,
|
||||
$filePath,
|
||||
$fileName,
|
||||
$fileSize,
|
||||
$mimeType,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
// Get the new file's ID
|
||||
$newId = $pdo->lastInsertId();
|
||||
|
||||
error_log("[relink] thesis_id=$thesisId file_path=$filePath file_type=$fileType new_id=$newId");
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'id' => (int)$newId,
|
||||
'message' => 'Fichier relié avec succès.',
|
||||
]);
|
||||
exit;
|
||||
@@ -55,7 +55,7 @@ function wasSelected($key, $value) {
|
||||
$isAdmin = true;
|
||||
$bodyClass = 'admin-body';
|
||||
$extraCss = ['/assets/css/form.css', '/assets/css/filepond.min.css', '/assets/css/filepond-plugin-image-preview.min.css'];
|
||||
$extraJs = ['/assets/js/vendor/filepond.min.js', '/assets/js/vendor/filepond-plugin-file-validate-type.min.js', '/assets/js/vendor/filepond-plugin-file-validate-size.min.js', '/assets/js/vendor/filepond-plugin-image-preview.min.js', '/assets/js/vendor/filepond-plugin-image-exif-orientation.min.js', '/assets/js/app/file-upload-filepond.js', '/assets/js/app/beforeunload-guard.js', '/assets/js/app/pill-search.js'];
|
||||
$extraJs = ['/assets/js/vendor/filepond.min.js', '/assets/js/vendor/filepond-plugin-file-validate-type.min.js', '/assets/js/vendor/filepond-plugin-file-validate-size.min.js', '/assets/js/vendor/filepond-plugin-image-preview.min.js', '/assets/js/vendor/filepond-plugin-image-exif-orientation.min.js', '/assets/js/app/file-upload-filepond.js', '/assets/js/app/beforeunload-guard.js', '/assets/js/app/pill-search.js', '/assets/js/app/jury-autocomplete.js'];
|
||||
require_once APP_ROOT . '/templates/head.php';
|
||||
include APP_ROOT . '/templates/header.php';
|
||||
include APP_ROOT . '/templates/admin/add.php';
|
||||
|
||||
@@ -40,7 +40,7 @@ try {
|
||||
|
||||
$isAdmin = true; $bodyClass = 'admin-body';
|
||||
$extraCss = ['/assets/css/form.css', '/assets/css/filepond.min.css', '/assets/css/filepond-plugin-image-preview.min.css'];
|
||||
$extraJs = ['/assets/js/vendor/filepond.min.js', '/assets/js/vendor/filepond-plugin-file-validate-type.min.js', '/assets/js/vendor/filepond-plugin-file-validate-size.min.js', '/assets/js/vendor/filepond-plugin-image-preview.min.js', '/assets/js/vendor/filepond-plugin-image-exif-orientation.min.js', '/assets/js/app/file-upload-filepond.js', '/assets/js/app/beforeunload-guard.js', '/assets/js/app/pill-search.js'];
|
||||
$extraJs = ['/assets/js/vendor/filepond.min.js', '/assets/js/vendor/filepond-plugin-file-validate-type.min.js', '/assets/js/vendor/filepond-plugin-file-validate-size.min.js', '/assets/js/vendor/filepond-plugin-image-preview.min.js', '/assets/js/vendor/filepond-plugin-image-exif-orientation.min.js', '/assets/js/app/file-upload-filepond.js', '/assets/js/app/beforeunload-guard.js', '/assets/js/app/pill-search.js', '/assets/js/app/jury-autocomplete.js'];
|
||||
require_once APP_ROOT . '/templates/head.php';
|
||||
include APP_ROOT . '/templates/header.php';
|
||||
include APP_ROOT . '/templates/admin/edit.php';
|
||||
|
||||
146
app/public/admin/fragments/file-browser.php
Normal file
146
app/public/admin/fragments/file-browser.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
/**
|
||||
* File browser fragment — returns a clickable directory tree of documents/ + theses/.
|
||||
*
|
||||
* GET /admin/fragments/file-browser.php?dir=documents/2025
|
||||
*
|
||||
* Used by the relink modal to let admins pick an orphaned file and reattach
|
||||
* it to a thesis entry.
|
||||
*/
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
$storageRoot = STORAGE_ROOT;
|
||||
|
||||
// Determine which directory to browse
|
||||
$relDir = trim($_GET['dir'] ?? '', '/');
|
||||
if ($relDir !== '' && !preg_match('#^(documents|theses)/#', $relDir)) {
|
||||
$relDir = '';
|
||||
}
|
||||
|
||||
$currentAbs = $storageRoot . '/' . ($relDir !== '' ? $relDir . '/' : '');
|
||||
// Security: prevent escaping STORAGE_ROOT
|
||||
$realCurrent = realpath($currentAbs);
|
||||
$realStorage = realpath($storageRoot);
|
||||
if ($realCurrent === false || !str_starts_with($realCurrent, $realStorage)) {
|
||||
http_response_code(403);
|
||||
die('Chemin interdit.');
|
||||
}
|
||||
|
||||
// Build breadcrumb
|
||||
$breadcrumb = [];
|
||||
if ($relDir !== '') {
|
||||
$parts = explode('/', $relDir);
|
||||
$accum = '';
|
||||
foreach ($parts as $p) {
|
||||
$accum = ltrim($accum . '/' . $p, '/');
|
||||
$breadcrumb[] = ['label' => $p, 'dir' => $accum];
|
||||
}
|
||||
}
|
||||
|
||||
// Gather entries
|
||||
$entries = [];
|
||||
if (is_dir($realCurrent)) {
|
||||
$dh = opendir($realCurrent);
|
||||
if ($dh) {
|
||||
while (($name = readdir($dh)) !== false) {
|
||||
if ($name === '.' || $name === '..') continue;
|
||||
$full = $realCurrent . '/' . $name;
|
||||
$isDir = is_dir($full);
|
||||
$entries[] = [
|
||||
'name' => $name,
|
||||
'is_dir' => $isDir,
|
||||
'size' => $isDir ? null : filesize($full),
|
||||
'ext' => $isDir ? null : strtolower(pathinfo($name, PATHINFO_EXTENSION)),
|
||||
];
|
||||
}
|
||||
closedir($dh);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: dirs first, then files, alphabetical
|
||||
usort($entries, function ($a, $b) {
|
||||
if ($a['is_dir'] !== $b['is_dir']) return $a['is_dir'] ? -1 : 1;
|
||||
return strnatcasecmp($a['name'], $b['name']);
|
||||
});
|
||||
|
||||
// Human-readable filesize
|
||||
function fmtSize(?int $bytes): string {
|
||||
if ($bytes === null) return '';
|
||||
if ($bytes >= 1_000_000_000) return round($bytes / 1_000_000_000, 1) . ' GB';
|
||||
if ($bytes >= 1_000_000) return round($bytes / 1_000_000, 1) . ' MB';
|
||||
if ($bytes >= 1_000) return round($bytes / 1_000, 1) . ' KB';
|
||||
return $bytes . ' B';
|
||||
}
|
||||
|
||||
// Determine parent dir
|
||||
$parentDir = '';
|
||||
if ($relDir !== '') {
|
||||
$parentParts = explode('/', $relDir);
|
||||
array_pop($parentParts);
|
||||
$parentDir = implode('/', $parentParts);
|
||||
}
|
||||
|
||||
$rootDirs = ['documents', 'theses'];
|
||||
?>
|
||||
<div id="file-browser-container" class="file-browser">
|
||||
<?php if ($relDir === ''): ?>
|
||||
<!-- Root: show top-level directories -->
|
||||
<p class="file-browser-hint">Sélectionnez un dossier pour parcourir les fichiers orphelins.</p>
|
||||
<ul class="file-browser-list">
|
||||
<?php foreach ($rootDirs as $rd): ?>
|
||||
<?php $rdAbs = $storageRoot . '/' . $rd; ?>
|
||||
<li class="file-browser-entry file-browser-dir">
|
||||
<a href="#" hx-get="/admin/fragments/file-browser.php?dir=<?= urlencode($rd) ?>"
|
||||
hx-target="#file-browser-container" hx-swap="outerHTML">
|
||||
<span class="file-browser-icon">📁</span>
|
||||
<span class="file-browser-name"><?= htmlspecialchars($rd) ?>/</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php else: ?>
|
||||
<!-- Subdirectory: breadcrumb + entries -->
|
||||
<nav class="file-browser-breadcrumb">
|
||||
<a href="#" hx-get="/admin/fragments/file-browser.php"
|
||||
hx-target="#file-browser-container" hx-swap="outerHTML">📂 racine</a>
|
||||
<?php foreach ($breadcrumb as $i => $bc): ?>
|
||||
<span class="file-browser-sep">/</span>
|
||||
<a href="#" hx-get="/admin/fragments/file-browser.php?dir=<?= urlencode($bc['dir']) ?>"
|
||||
hx-target="#file-browser-container" hx-swap="outerHTML"><?= htmlspecialchars($bc['label']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
|
||||
<?php if (empty($entries)): ?>
|
||||
<p class="file-browser-empty">Ce dossier est vide.</p>
|
||||
<?php else: ?>
|
||||
<ul class="file-browser-list">
|
||||
<?php foreach ($entries as $e): ?>
|
||||
<?php if ($e['is_dir']): ?>
|
||||
<li class="file-browser-entry file-browser-dir">
|
||||
<a href="#" hx-get="/admin/fragments/file-browser.php?dir=<?= urlencode($relDir . '/' . $e['name']) ?>"
|
||||
hx-target="#file-browser-container" hx-swap="outerHTML">
|
||||
<span class="file-browser-icon">📁</span>
|
||||
<span class="file-browser-name"><?= htmlspecialchars($e['name']) ?>/</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<li class="file-browser-entry file-browser-file"
|
||||
data-file-path="<?= htmlspecialchars($relDir . '/' . $e['name']) ?>"
|
||||
data-file-name="<?= htmlspecialchars($e['name']) ?>"
|
||||
data-file-ext="<?= htmlspecialchars($e['ext'] ?? '') ?>"
|
||||
data-file-size="<?= (int)($e['size'] ?? 0) ?>">
|
||||
<button type="button" class="file-browser-select-btn"
|
||||
onclick="XamxamRelinkFile(this)">
|
||||
<span class="file-browser-icon">📄</span>
|
||||
<span class="file-browser-name"><?= htmlspecialchars($e['name']) ?></span>
|
||||
<span class="file-browser-size"><?= htmlspecialchars(fmtSize($e['size'])) ?></span>
|
||||
</button>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
10
app/public/admin/fragments/pill-search.php
Normal file
10
app/public/admin/fragments/pill-search.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin fragment: generic pill-search (HTMX partial).
|
||||
*/
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
AdminAuth::requireLogin();
|
||||
App::boot();
|
||||
|
||||
require_once APP_ROOT . '/public/partage/pill-search-fragment.php';
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
8
app/public/partage/fragments/pill-search.php
Normal file
8
app/public/partage/fragments/pill-search.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
/**
|
||||
* Partagé fragment: generic pill-search (HTMX partial).
|
||||
*/
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
App::boot();
|
||||
|
||||
require_once APP_ROOT . '/public/partage/pill-search-fragment.php';
|
||||
@@ -79,6 +79,12 @@ if ($slug === 'validate-file-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST')
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($slug === 'pill-search-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
App::boot();
|
||||
require_once __DIR__ . '/fragments/pill-search.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Special route: /partage/recapitulatif?id=N
|
||||
if ($slug === 'recapitulatif' || $slug === 'recapitulatif.php') {
|
||||
App::boot();
|
||||
@@ -434,6 +440,7 @@ function renderShareLinkForm(string $slug, array $link): void
|
||||
<script src="<?= App::assetV('/assets/js/app/file-upload-filepond.js') ?>" defer></script>
|
||||
<script src="<?= App::assetV('/assets/js/app/beforeunload-guard.js') ?>" defer></script>
|
||||
<script src="<?= App::assetV('/assets/js/app/pill-search.js') ?>" defer></script>
|
||||
<script src="<?= App::assetV('/assets/js/app/jury-autocomplete.js') ?>" defer></script>
|
||||
<script src="<?= App::assetV('/assets/js/vendor/htmx.min.js') ?>" defer></script>
|
||||
</head>
|
||||
<body class="student-body">
|
||||
@@ -612,7 +619,7 @@ function handleShareLinkSubmission(string $slug): void
|
||||
header('Location: /partage/' . urlencode($slug));
|
||||
exit();
|
||||
|
||||
} catch (Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
$logger->logError('partage', $e->getMessage(), [
|
||||
'share_slug' => $slug,
|
||||
'author' => $authorName,
|
||||
|
||||
@@ -1,72 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* language-search-fragment.php
|
||||
*
|
||||
* Shared HTMX fragment: returns matching language suggestions for the
|
||||
* "Autre(s) langue(s)" interactive search input.
|
||||
* @deprecated Use /partage/fragments/pill-search.php with type=language instead.
|
||||
* Kept for backward compatibility with existing hx-post URLs.
|
||||
*/
|
||||
require_once __DIR__ . '/../../src/Database.php';
|
||||
|
||||
$query = trim(preg_replace('/\s+/', ' ', strtolower($_POST['language_search_q'] ?? '')));
|
||||
|
||||
$currentLanguages = isset($_POST['language_autre']) && is_array($_POST['language_autre'])
|
||||
? array_map(function($l) { return trim(preg_replace('/\s+/', ' ', strtolower($l))); }, $_POST['language_autre'])
|
||||
: [];
|
||||
|
||||
error_log("[lang-search] q=" . var_export($query, true) . " cur=" . json_encode($currentLanguages));
|
||||
|
||||
$db = Database::getInstance();
|
||||
$results = $db->searchLanguages($query);
|
||||
|
||||
error_log("[lang-search] raw results count=" . count($results) . " rows=" . json_encode(array_slice($results, 0, 5)));
|
||||
|
||||
// Deduplicate
|
||||
$seen = [];
|
||||
$results = array_values(array_filter($results, function($lang) use (&$seen) {
|
||||
$key = strtolower($lang['name']);
|
||||
if (isset($seen[$key])) return false;
|
||||
$seen[$key] = true;
|
||||
return true;
|
||||
}));
|
||||
|
||||
// Exclude the main languages (handled by the checkbox list above)
|
||||
$excludedLanguages = ['français', 'anglais', 'néerlandais'];
|
||||
|
||||
// Filter out already-selected and excluded main languages
|
||||
$results = array_values(array_filter($results, function($lang) use ($currentLanguages, $excludedLanguages) {
|
||||
$lower = strtolower($lang['name']);
|
||||
return !in_array($lower, $currentLanguages, true)
|
||||
&& !in_array($lower, $excludedLanguages, true);
|
||||
}));
|
||||
|
||||
// Exact match check
|
||||
$exactExists = false;
|
||||
foreach ($results as $lang) {
|
||||
if (strcasecmp($lang['name'], $query) === 0) {
|
||||
$exactExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$inCurrent = in_array($query, $currentLanguages, true);
|
||||
$isExcluded = in_array($query, $excludedLanguages, true);
|
||||
$canCreate = ($query !== '' && !$exactExists && !$inCurrent && !$isExcluded);
|
||||
|
||||
error_log("[lang-search] exactExists=" . var_export($exactExists, true) . " inCurrent=" . var_export($inCurrent, true) . " canCreate=" . var_export($canCreate, true));
|
||||
?>
|
||||
<?php if (empty($results) && !$canCreate): ?>
|
||||
<div class="tag-search-empty">Aucune langue trouvée.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($results as $lang): ?>
|
||||
<button type="button" class="tag-search-item" data-tag-id="<?= (int)$lang['id'] ?>" data-tag-name="<?= htmlspecialchars($lang['name']) ?>">
|
||||
<span class="tag-search-item-name"><?= htmlspecialchars($lang['name']) ?></span>
|
||||
<span class="tag-search-item-count">(<?= (int)$lang['thesis_count'] ?>)</span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($canCreate): ?>
|
||||
<button type="button" class="tag-search-item tag-search-item--create" data-tag-name="<?= htmlspecialchars($query) ?>">
|
||||
<span class="tag-search-item-name">Créer « <?= htmlspecialchars($query) ?> »</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
$_POST['type'] = 'language';
|
||||
$_POST['q'] = $_POST['language_search_q'] ?? '';
|
||||
$_POST['exclude'] = $_POST['language_autre'] ?? [];
|
||||
require_once __DIR__ . '/pill-search-fragment.php';
|
||||
|
||||
123
app/public/partage/pill-search-fragment.php
Normal file
123
app/public/partage/pill-search-fragment.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
/**
|
||||
* pill-search-fragment.php
|
||||
*
|
||||
* Generic HTMX fragment: returns matching suggestions for search inputs.
|
||||
*
|
||||
* Supports three entity types:
|
||||
* - tag → used by mot-clé pill-search component
|
||||
* - language → used by "Autre(s) langue(s)" pill-search component
|
||||
* - supervisor → used by jury member text inputs (inline autocomplete, no pills)
|
||||
*
|
||||
* Included by:
|
||||
* - /admin/fragments/pill-search.php (AdminAuth gated)
|
||||
* - /partage/fragments/pill-search.php (public, session already booted)
|
||||
*
|
||||
* Expected POST:
|
||||
* type — 'tag' | 'language' | 'supervisor'
|
||||
* q — search query string (partial name)
|
||||
* exclude[] — already-selected names to exclude from suggestions
|
||||
*/
|
||||
require_once __DIR__ . '/../../src/Database.php';
|
||||
|
||||
$db = Database::getInstance();
|
||||
$type = trim($_POST['pill_type'] ?? $_POST['type'] ?? 'tag');
|
||||
|
||||
// Accept both generic 'q' and type-specific field names (tag_search_q, language_search_q)
|
||||
// Use the type-specific field first to avoid collision when both are present (same <form>).
|
||||
$rawQ = $_POST['q']
|
||||
?? ($type === 'language' ? ($_POST['language_search_q'] ?? '') : ($_POST['tag_search_q'] ?? ''))
|
||||
?? '';
|
||||
$q = trim(preg_replace('/\s+/', ' ', strtolower((string)$rawQ)));
|
||||
|
||||
// Accept both generic 'exclude[]' and type-specific field names (tag[], language_autre[])
|
||||
$exclude = [];
|
||||
$rawExclude = $_POST['exclude']
|
||||
?? ($type === 'language' ? ($_POST['language_autre'] ?? null) : ($_POST['tag'] ?? null))
|
||||
?? null;
|
||||
if (isset($rawExclude) && is_array($rawExclude)) {
|
||||
$exclude = array_map(function($v) {
|
||||
return trim(preg_replace('/\s+/', ' ', strtolower($v)));
|
||||
}, $rawExclude);
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$emptyMessage = 'Aucun résultat.';
|
||||
$createLabel = 'Créer';
|
||||
|
||||
switch ($type) {
|
||||
case 'language':
|
||||
$results = $db->searchLanguages($q);
|
||||
$emptyMessage = 'Aucune langue trouvée.';
|
||||
$createLabel = 'Créer';
|
||||
break;
|
||||
case 'supervisor':
|
||||
$role = trim($_POST['role'] ?? '');
|
||||
$results = $db->searchSupervisors($q, $role);
|
||||
$emptyMessage = 'Aucun·e membre trouvé·e.';
|
||||
break;
|
||||
default: // tag
|
||||
$results = $db->searchTags($q);
|
||||
$emptyMessage = 'Aucun mot-clé trouvé.';
|
||||
$createLabel = 'Créer';
|
||||
break;
|
||||
}
|
||||
|
||||
// Deduplicate results by lowercase name
|
||||
$seen = [];
|
||||
$results = array_values(array_filter($results, function($item) use (&$seen) {
|
||||
$key = strtolower($item['name']);
|
||||
if (isset($seen[$key])) return false;
|
||||
$seen[$key] = true;
|
||||
return true;
|
||||
}));
|
||||
|
||||
// Exclude already-selected items and, for languages, the main three
|
||||
if ($type === 'language') {
|
||||
$excludedMain = ['français', 'anglais', 'néerlandais'];
|
||||
$results = array_values(array_filter($results, function($lang) use ($excludedMain, $exclude) {
|
||||
$lower = strtolower($lang['name']);
|
||||
return !in_array($lower, $excludedMain, true)
|
||||
&& !in_array($lower, $exclude, true);
|
||||
}));
|
||||
} elseif (!empty($exclude)) {
|
||||
$results = array_values(array_filter($results, function($item) use ($exclude) {
|
||||
return !in_array(strtolower($item['name']), $exclude, true);
|
||||
}));
|
||||
}
|
||||
|
||||
// "Créer" button logic
|
||||
$exactExists = false;
|
||||
foreach ($results as $item) {
|
||||
if (strcasecmp($item['name'], $q) === 0) {
|
||||
$exactExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$inExcluded = in_array($q, $exclude, true);
|
||||
$canCreate = ($q !== '' && !$exactExists && !$inExcluded);
|
||||
|
||||
if ($type === 'language') {
|
||||
$excludedMain = ['français', 'anglais', 'néerlandais'];
|
||||
if (in_array($q, $excludedMain, true)) {
|
||||
$canCreate = false;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<?php if (empty($results) && !$canCreate): ?>
|
||||
<div class="tag-search-empty"><?= htmlspecialchars($emptyMessage) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($results as $item): ?>
|
||||
<button type="button" class="tag-search-item" data-tag-name="<?= htmlspecialchars($item['name']) ?>">
|
||||
<span class="tag-search-item-name"><?= htmlspecialchars($item['name']) ?></span>
|
||||
<span class="tag-search-item-count">(<?= (int)$item['thesis_count'] ?>)</span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($canCreate): ?>
|
||||
<button type="button" class="tag-search-item tag-search-item--create" data-tag-name="<?= htmlspecialchars($q) ?>">
|
||||
<span class="tag-search-item-name"><?= htmlspecialchars($createLabel) ?> « <?= htmlspecialchars($q) ?> »</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
@@ -1,6 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* Partagé fragment: tag search (HTMX partial).
|
||||
* @deprecated Use /partage/fragments/tag-search.php instead.
|
||||
* tag-search-fragment.php
|
||||
* @deprecated Use /partage/fragments/pill-search.php with type=tag instead.
|
||||
* Kept for backward compatibility with existing hx-post URLs.
|
||||
*/
|
||||
require_once __DIR__ . '/fragments/tag-search.php';
|
||||
$_POST['type'] = 'tag';
|
||||
$_POST['q'] = $_POST['tag_search_q'] ?? '';
|
||||
$_POST['exclude'] = $_POST['tag'] ?? [];
|
||||
require_once __DIR__ . '/pill-search-fragment.php';
|
||||
|
||||
Reference in New Issue
Block a user