Add SQLite indexes for contenus page language/tag queries + WIP: Peertube orphans, dialogs, contact decoupling, context note, finality types

This commit is contained in:
Pontoporeia
2026-06-21 13:33:55 +02:00
parent 0d5e9dac19
commit 03c9c3566f
38 changed files with 1432 additions and 333 deletions

View File

@@ -0,0 +1,16 @@
-- 041: Add index on thesis_languages(language_id) for contenus page performance.
--
-- The contenus page queries getAllLanguagesWithCount() and searchLanguages() join
-- languages → thesis_languages on language_id. SQLite's PRIMARY KEY on
-- (thesis_id, language_id) cannot optimize a lookup by language_id alone, so
-- it builds an AUTOMATIC COVERING INDEX per query. This persisted index
-- removes that overhead.
--
-- Also adds a covering index on tags(deleted_at, name) to accelerate
-- getAllTagsWithCount() and searchTags() ORDER BY clauses (avoiding temp B-tree).
CREATE INDEX IF NOT EXISTS idx_thesis_languages_language
ON thesis_languages(language_id);
CREATE INDEX IF NOT EXISTS idx_tags_deleted_name
ON tags(deleted_at, name);

View File

@@ -0,0 +1,91 @@
<?php
/**
* Temp file statistics as an HTML fragment (admin).
*
* GET /admin/actions/cleanup-stats-fragment.php
*
* Returns an HTML fragment ready for HTMX swap into #tmp-cleanup-stats.
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
// Re-use the existing stats endpoint internally
ob_start();
require __DIR__ . '/cleanup-stats.php';
$json = ob_get_clean();
$d = json_decode($json, true);
$hasFilePond = ($d['filepond_stale_count'] ?? 0) > 0 || ($d['filepond_active_count'] ?? 0) > 0;
$hasTrash = ($d['trash_stale_count'] ?? 0) > 0 || ($d['trash_active_count'] ?? 0) > 0;
if (!$hasFilePond && !$hasTrash): ?>
<p style="margin:0;color:var(--accent-green)">✓ Aucun fichier temporaire.</p>
<?php return; endif; ?>
<?php if ($d['filepond_stale_count'] > 0): ?>
<p class="n-heading">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256" aria-hidden="true"><path d="M230.64,25.36a32,32,0,0,0-45.26,0q-.21.21-.42.45L131.55,88.22,121,77.64a24,24,0,0,0-33.95,0l-76.69,76.7a8,8,0,0,0,0,11.31l80,80a8,8,0,0,0,11.31,0L178.36,169a24,24,0,0,0,0-33.95l-10.58-10.57L230.19,71c.15-.14.31-.28.45-.43A32,32,0,0,0,230.64,25.36ZM96,228.69,79.32,212l22.34-22.35a8,8,0,0,0-11.31-11.31L68,200.68,55.32,188l22.34-22.35a8,8,0,0,0-11.31-11.31L44,176.68,27.31,160,72,115.31,140.69,184ZM219.52,59.1l-68.71,58.81a8,8,0,0,0-.46,11.74L167,146.34a8,8,0,0,1,0,11.31l-15,15L83.32,104l15-15a8,8,0,0,1,11.31,0l16.69,16.69a8,8,0,0,0,11.74-.46L196.9,36.48A16,16,0,0,1,219.52,59.1Z"></path></svg>
Téléversements abandonnés <span class="n-meta"><?= $d['filepond_stale_count'] ?> dossier(s) · <?= htmlspecialchars($d['filepond_stale_human']) ?></span>
</p>
<table class="n-table">
<thead><tr><th>Nom</th><th>Taille</th><th>Âge</th><th width="1%"></th></tr></thead>
<tbody>
<?php foreach ($d['filepond_stale_files'] as $f): ?>
<tr>
<td><strong><?= htmlspecialchars($f['name']) ?></strong></td>
<td style="white-space:nowrap"><?= htmlspecialchars($f['human']) ?></td>
<td style="white-space:nowrap">~<?= (int)$f['age_minutes'] ?> min</td>
<td style="white-space:nowrap">
<button type="button" class="btn btn--sm btn--danger" style="font-size:0.85em;padding:2px var(--space-xs)"
hx-post="/admin/actions/cleanup-tmp.php"
hx-vals='{"csrf_token":"<?= htmlspecialchars($_SESSION['csrf_token']) ?>","filepond_dir":"<?= htmlspecialchars($f['name']) ?>"}'
hx-target="#tmp-cleanup-stats"
hx-swap="innerHTML"
hx-indicator="#tmp-cleanup-stats">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256" aria-hidden="true"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"></path></svg>
Supprimer
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($d['trash_stale_count'] > 0): ?>
<p class="n-heading">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256" aria-hidden="true"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"></path></svg>
Corbeille <span class="n-meta"><?= $d['trash_stale_count'] ?> fichier(s) · <?= htmlspecialchars($d['trash_stale_human']) ?></span>
</p>
<table class="n-table">
<thead><tr><th>Nom</th><th>Taille</th><th>Âge</th><th width="1%"></th></tr></thead>
<tbody>
<?php foreach ($d['trash_stale_files'] as $f): ?>
<tr>
<td><strong><?= htmlspecialchars($f['name']) ?></strong></td>
<td style="white-space:nowrap"><?= htmlspecialchars($f['human']) ?></td>
<td style="white-space:nowrap">~<?= (int)$f['age_days'] ?> j</td>
<td style="white-space:nowrap">
<button type="button" class="btn btn--sm btn--danger" style="font-size:0.85em;padding:2px var(--space-xs)"
hx-post="/admin/actions/cleanup-tmp.php"
hx-vals='{"csrf_token":"<?= htmlspecialchars($_SESSION['csrf_token']) ?>","trash_file":"<?= htmlspecialchars($f['name']) ?>"}'
hx-target="#tmp-cleanup-stats"
hx-swap="innerHTML"
hx-indicator="#tmp-cleanup-stats">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256" aria-hidden="true"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"></path></svg>
Supprimer
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if (($d['filepond_active_count'] ?? 0) > 0 || ($d['trash_active_count'] ?? 0) > 0): ?>
<p style="margin:var(--space-sm) 0 0 0;font-size:0.85em;color:var(--text-secondary)">Conservés :
<?php if ($d['filepond_active_count']) echo $d['filepond_active_count'] . ' téléversement(s) actif(s) (' . htmlspecialchars($d['filepond_active_human']) . '), '; ?>
<?php if ($d['trash_active_count']) echo $d['trash_active_count'] . ' fichier(s) récent(s) (' . htmlspecialchars($d['trash_active_human']) . ')'; ?>
</p>
<?php endif; ?>

View File

@@ -43,6 +43,55 @@ $details = [];
$db = new Database();
$pdo = $db->getPDO();
// ── Individual deletion mode ────────────────────────────────────────────
$individualFilepond = trim($_POST['filepond_dir'] ?? '');
$individualTrash = trim($_POST['trash_file'] ?? '');
if ($individualFilepond !== '' || $individualTrash !== '') {
if ($individualFilepond !== '') {
$dirPath = $filepondDir . '/' . basename($individualFilepond);
if (is_dir($dirPath) && str_starts_with(realpath($dirPath), realpath($filepondDir))) {
rmdirRecursive($dirPath);
$filepondRemoved = 1;
$details[] = "filepond/$individualFilepond: suppression manuelle";
} else {
$errors[] = 'Dossier introuvable : ' . htmlspecialchars($individualFilepond);
}
}
if ($individualTrash !== '') {
$filePath = $trashDir . '/' . basename($individualTrash);
if (is_file($filePath) && str_starts_with(realpath($filePath), realpath($trashDir))) {
if (@unlink($filePath)) {
$trashRemoved = 1;
$details[] = "_trash/$individualTrash: suppression manuelle";
} else {
$errors[] = 'Impossible de supprimer : ' . htmlspecialchars($individualTrash);
}
} else {
$errors[] = 'Fichier introuvable : ' . htmlspecialchars($individualTrash);
}
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// HTMX request: re-render the fragment
if (isset($_SERVER['HTTP_HX_REQUEST'])) {
header('HX-Trigger: refreshStats');
require __DIR__ . '/cleanup-stats-fragment.php';
exit;
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => true,
'filepond_removed' => $filepondRemoved,
'trash_removed' => $trashRemoved,
'errors' => $errors,
'details' => $details,
]);
exit;
}
// ── Determine PHP session save path ──────────────────────────────────────
$sessionSavePath = session_save_path();
if (!$sessionSavePath || $sessionSavePath === '') {

View File

@@ -0,0 +1,72 @@
<?php
/**
* PeerTube video deletion endpoint (admin).
*
* POST /admin/actions/peertube-delete.php
* Body: csrf_token + uuid
*
* Deletes a video from the PeerTube channel entirely.
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'error' => 'CSRF invalide.']);
exit;
}
$uuid = trim($_POST['uuid'] ?? '');
if ($uuid === '' || !preg_match('/^[a-zA-Z0-9\-_]+$/', $uuid)) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'error' => 'UUID invalide.']);
exit;
}
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/PeerTubeService.php';
$db = new Database();
if (!PeerTubeService::isConfigured($db)) {
http_response_code(503);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'error' => 'PeerTube non configuré.']);
exit;
}
// Also remove any stale DB references to this UUID
$pdo = $db->getConnection();
$stmt = $pdo->prepare(
"SELECT id FROM thesis_files WHERE file_path = ?"
);
$stmt->execute(['peertube_ids:' . $uuid]);
$dbRefs = $stmt->fetchAll(PDO::FETCH_COLUMN);
$dbCleaned = count($dbRefs);
foreach ($dbRefs as $id) {
$pdo->prepare("DELETE FROM thesis_files WHERE id = ?")->execute([$id]);
}
$deleted = PeerTubeService::deleteVideo($db, $uuid);
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
if ($deleted) {
error_log("[peertube-delete] uuid=$uuid deleted" . ($dbCleaned > 0 ? " + $dbCleaned DB ref(s) cleaned" : ""));
if (isset($_SERVER['HTTP_HX_REQUEST'])) {
require __DIR__ . '/peertube-orphans-fragment.php';
exit;
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => true]);
} else {
error_log("[peertube-delete] uuid=$uuid delete failed" . ($dbCleaned > 0 ? " (cleaned $dbCleaned DB refs though)" : ""));
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'error' => 'Échec de la suppression sur PeerTube (vérifiez les logs).']);
}
exit;

View File

@@ -0,0 +1,102 @@
<?php
/**
* PeerTube orphan check as HTML fragment (admin).
*
* GET /admin/actions/peertube-orphans-fragment.php
*
* Returns an HTML fragment ready for HTMX swap into #peertube-orphans-wrapper.
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
// Re-use the existing JSON endpoint internally
ob_start();
require __DIR__ . '/peertube-orphans.php';
$json = ob_get_clean();
$d = json_decode($json, true);
if (!($d['configured'] ?? false)): ?>
<div id="peertube-orphans-wrapper">
<details id="peertube-orphans-col" class="n-section" open>
<summary>Vidéos PeerTube orphelines</summary>
<div id="peertube-orphans-stats">
<p style="margin:0;color:var(--color-warning)">⚠️ PeerTube non configuré.</p>
</div>
</details>
</div>
<?php return; endif; ?>
<?php if (!empty($d['error'])): ?>
<div id="peertube-orphans-wrapper">
<details id="peertube-orphans-col" class="n-section" open>
<summary>Vidéos PeerTube orphelines</summary>
<div id="peertube-orphans-stats">
<p style="margin:0;color:var(--color-error)">✗ <?= htmlspecialchars($d['error']) ?></p>
</div>
</details>
</div>
<?php return; endif; ?>
<div id="peertube-orphans-wrapper">
<details id="peertube-orphans-col" class="n-section" open>
<summary>Vidéos PeerTube orphelines <span class="n-meta"><?= (int)($d['total_on_channel'] ?? 0) ?> vidéos · <?= (int)($d['total_linked'] ?? 0) ?> liées</span></summary>
<div id="peertube-orphans-stats">
<?php if (($d['orphan_count'] ?? 0) > 0): ?>
<table class="n-table">
<thead><tr><th>Nom</th><th>Date</th><th width="1%"></th></tr></thead>
<tbody>
<?php foreach ($d['orphans'] as $v): ?>
<tr>
<td>
<strong><?= htmlspecialchars($v['name']) ?></strong>
<span class="n-table__info" style="display:block"><?= htmlspecialchars($v['uuid']) ?></span>
</td>
<td style="white-space:nowrap"><?= htmlspecialchars(substr($v['createdAt'] ?? '', 0, 10)) ?></td>
<td style="white-space:nowrap">
<button type="button" class="btn btn--sm btn--danger" style="font-size:0.85em;padding:2px var(--space-xs)"
hx-post="/admin/actions/peertube-delete.php"
hx-vals='{"csrf_token":"<?= htmlspecialchars($_SESSION['csrf_token']) ?>","uuid":"<?= htmlspecialchars($v['uuid']) ?>"}'
hx-target="#peertube-orphans-wrapper"
hx-swap="outerHTML"
hx-trigger="click"
hx-indicator="#peertube-orphans-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256" aria-hidden="true"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"></path></svg>
Supprimer
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p style="margin:0;color:var(--accent-green)">✓ Aucune vidéo orpheline.</p>
<?php endif; ?>
</div>
</details>
<?php if (($d['stale_count'] ?? 0) > 0): ?>
<details id="peertube-stale-section" class="n-section" open>
<summary>Références DB obsolètes <span class="n-meta"><?= $d['stale_count'] ?></span></summary>
<p style="margin:0 0 var(--space-sm) 0;font-size:0.85em;color:var(--text-secondary)">Ces UUID sont référencés en base de données mais n'existent plus sur la chaîne PeerTube. Les TFE liés affichent des liens morts.</p>
<table class="n-table">
<thead><tr><th>UUID</th><th>TFE(s)</th></tr></thead>
<tbody>
<?php foreach ($d['stale_entries'] as $s): ?>
<tr>
<td style="word-break:break-all;color:var(--text-secondary)"><?= htmlspecialchars($s['uuid']) ?></td>
<td>
<?php if (!empty($s['theses'])): ?>
<?= implode(', ', array_map(function($t) {
$label = $t['identifier'] ?: '#' . $t['thesis_id'];
return '<a href="/admin/contenus-edit.php?id=' . (int)$t['thesis_id'] . '" target="_blank">' . htmlspecialchars($label) . '</a>';
}, $s['theses'])) ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</details>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,121 @@
<?php
/**
* PeerTube orphan video check endpoint (admin).
*
* GET /admin/actions/peertube-orphans.php
*
* Returns JSON with a list of PeerTube channel videos that are NOT linked to
* any TFE in the database.
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
exit;
}
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/PeerTubeService.php';
$db = new Database();
if (!PeerTubeService::isConfigured($db)) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'configured' => false,
'error' => 'PeerTube non configuré.',
]);
exit;
}
// ── Collect all Peertube UUIDs linked in the DB ──────────────────────────
$pdo = $db->getPDO();
$dbUuids = [];
$linkedMap = []; // uuid → [thesis_id, thesis_title, thesis_identifier]
$stmt = $pdo->query(
"SELECT tf.file_path, tf.file_name, t.id AS thesis_id, t.title, t.identifier
FROM thesis_files tf
JOIN theses t ON t.id = tf.thesis_id
WHERE tf.file_path LIKE 'peertube_ids:%'
AND t.deleted_at IS NULL"
);
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
$uuid = substr($row['file_path'], strlen('peertube_ids:'));
$dbUuids[$uuid] = true;
$linkedMap[$uuid][] = [
'thesis_id' => (int)$row['thesis_id'],
'title' => $row['title'],
'identifier' => $row['identifier'] ?? '',
];
}
// ── List all channel videos ──────────────────────────────────────────────
try {
$channelVideos = PeerTubeService::listChannelVideos($db);
} catch (\Throwable $e) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'configured' => true,
'error' => 'Erreur lors du listage des vidéos : ' . $e->getMessage(),
]);
exit;
}
// ── Find orphans: on channel but not in DB ───────────────────────────────
$orphans = [];
$linked = [];
foreach ($channelVideos as $v) {
$uuid = $v['shortUUID'] ?: $v['uuid'];
if ($uuid === '') {
continue;
}
if (isset($dbUuids[$uuid])) {
$linked[] = [
'uuid' => $uuid,
'name' => $v['name'],
'theses' => $linkedMap[$uuid] ?? [],
];
} else {
$orphans[] = [
'uuid' => $uuid,
'name' => $v['name'],
'createdAt' => $v['createdAt'],
];
}
}
// ── Find stale DB entries: in DB but not on channel ──────────────────────
$stale = [];
foreach ($dbUuids as $uuid => $_) {
$found = false;
foreach ($channelVideos as $v) {
if (($v['shortUUID'] ?: $v['uuid']) === $uuid) {
$found = true;
break;
}
}
if (!$found) {
$stale[] = [
'uuid' => $uuid,
'theses' => $linkedMap[$uuid] ?? [],
];
}
}
$totalOnChannel = count($channelVideos);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'configured' => true,
'channel_name' => PeerTubeService::getSettings($db)['channel_name'],
'total_on_channel' => $totalOnChannel,
'total_linked' => count($linked),
'orphan_count' => count($orphans),
'orphans' => $orphans,
'stale_count' => count($stale),
'stale_entries' => $stale,
]);

View File

@@ -0,0 +1,126 @@
<?php
/**
* PeerTube video relink endpoint (admin).
*
* POST /admin/actions/peertube-relink.php
* Body: JSON { thesis_id: 123, uuid: "bmpQZTUPv4ou8ufiwajV63" }
*
* Links an existing PeerTube video to a thesis by inserting a thesis_files row
* with file_path = 'peertube_ids:{uuid}'. Only videos that are on the channel
* but NOT linked to any TFE can be relinked.
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
function peertubeRelinkError(int $code, string $message): never {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => $message]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
peertubeRelinkError(405, 'Méthode non autorisée.');
}
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!isset($_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $csrfHeader)) {
peertubeRelinkError(403, 'Token CSRF invalide.');
}
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body)) {
peertubeRelinkError(400, 'JSON invalide.');
}
$thesisId = filter_var($body['thesis_id'] ?? '', FILTER_VALIDATE_INT);
$uuid = trim($body['uuid'] ?? '');
if (!$thesisId || $uuid === '') {
peertubeRelinkError(400, 'Paramètres invalides (thesis_id + uuid requis).');
}
// Validate UUID format (shortUUID or full UUID)
if (!preg_match('/^[a-zA-Z0-9\-_]+$/', $uuid)) {
peertubeRelinkError(400, 'UUID invalide.');
}
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/PeerTubeService.php';
$db = new Database();
if (!PeerTubeService::isConfigured($db)) {
peertubeRelinkError(503, 'PeerTube non configuré.');
}
// Check thesis exists
$thesis = $db->getThesis($thesisId);
if (!$thesis) {
peertubeRelinkError(404, 'TFE introuvable.');
}
// Check this UUID is not already linked to this thesis
$pdo = $db->getConnection();
$stmt = $pdo->prepare(
"SELECT id FROM thesis_files
WHERE thesis_id = ? AND file_path = ?"
);
$stmt->execute([$thesisId, 'peertube_ids:' . $uuid]);
if ($stmt->fetch()) {
peertubeRelinkError(409, 'Cette vidéo est déjà liée à ce TFE.');
}
// Verify the video exists on the channel
$info = PeerTubeService::fetchVideoInfo($db, $uuid);
if ($info === null) {
peertubeRelinkError(404, 'Vidéo introuvable sur PeerTube.');
}
// Verify it's not already linked to another TFE
$stmt = $pdo->prepare(
"SELECT t.identifier FROM thesis_files tf
JOIN theses t ON t.id = tf.thesis_id
WHERE tf.file_path = ? AND t.deleted_at IS NULL"
);
$stmt->execute(['peertube_ids:' . $uuid]);
$existing = $stmt->fetch();
if ($existing) {
peertubeRelinkError(409,
'Cette vidéo est déjà liée au TFE ' . htmlspecialchars($existing['identifier'] ?? '?')
. '. Dé-liez-la d\'abord avant de la relier à un autre.'
);
}
// Determine file type from PeerTube info
$videoName = $info['name'] ?? $uuid;
$fileType = 'video'; // default
$catId = (int)($info['category']['id'] ?? 0);
if ($catId === 16) {
$fileType = 'audio';
}
$db->insertThesisFile(
$thesisId,
$fileType,
'peertube_ids:' . $uuid,
$videoName,
0, // size unknown (not on disk)
'video/mp4', // PeerTube streams HLS, mime is nominal
null,
null
);
$newId = $pdo->lastInsertId();
error_log("[peertube-relink] thesis_id=$thesisId uuid=$uuid file_type=$fileType new_id=$newId");
header('Content-Type: application/json');
echo json_encode([
'ok' => true,
'id' => (int)$newId,
'message' => 'Vidéo PeerTube reliée avec succès.',
]);
exit;

View File

@@ -24,6 +24,12 @@ try {
$formData = $_SESSION['form_data'] ?? [];
unset($_SESSION['form_data']);
// Default values for new TFE creation (no prior form data)
if (empty($formData)) {
$formData['exemplaire_baiu'] = true;
$formData['exemplaire_erg'] = true;
}
$siteSettings = Database::getInstance()->getAllSettings();
$helpBlocks = Database::getInstance()->getAllFormHelpBlocks();

View File

@@ -0,0 +1,112 @@
<?php
/**
* PeerTube video browser fragment — returns a list of orphan videos.
*
* GET /admin/fragments/peertube-browser.php
*
* Lists videos on the configured PeerTube channel that are NOT linked to any
* TFE in the database. Each entry is clickable to relink via the JS handler.
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/PeerTubeService.php';
$db = new Database();
if (!PeerTubeService::isConfigured($db)) {
echo '<p class="file-browser-empty">PeerTube non configuré.</p>';
exit;
}
$thesisId = isset($_GET['thesis_id']) ? (int)$_GET['thesis_id'] : 0;
// ── Collect already-linked UUIDs for the current thesis (to exclude them) ─
$pdo = $db->getConnection();
$stmt = $pdo->prepare(
"SELECT file_path FROM thesis_files
WHERE thesis_id = ? AND file_path LIKE 'peertube_ids:%'"
);
$stmt->execute([$thesisId]);
$linkedToThis = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$linkedToThis[] = substr($row['file_path'], strlen('peertube_ids:'));
}
// ── Collect all DB-linked UUIDs (any thesis that isn't soft-deleted) ─────
$stmt = $pdo->query(
"SELECT tf.file_path, t.identifier
FROM thesis_files tf
JOIN theses t ON t.id = tf.thesis_id
WHERE tf.file_path LIKE 'peertube_ids:%'
AND t.deleted_at IS NULL"
);
$dbLinked = []; // uuid → identifier
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$uuid = substr($row['file_path'], strlen('peertube_ids:'));
$dbLinked[$uuid] = $row['identifier'];
}
// ── List channel videos ─────────────────────────────────────────────────
try {
$channelVideos = PeerTubeService::listChannelVideos($db);
} catch (\Throwable $e) {
echo '<p class="file-browser-error">Erreur : ' . htmlspecialchars($e->getMessage()) . '</p>';
exit;
}
// ── Build orphan list ────────────────────────────────────────────────────
$orphans = [];
foreach ($channelVideos as $v) {
$uuid = $v['shortUUID'] ?: $v['uuid'];
if ($uuid === '') {
continue;
}
// Skip if already linked to THIS thesis
if (in_array($uuid, $linkedToThis, true)) {
continue;
}
// Mark as linked-to-other if already in DB (different thesis)
$linkedTo = $dbLinked[$uuid] ?? null;
$orphans[] = [
'uuid' => $uuid,
'name' => $v['name'],
'createdAt' => $v['createdAt'],
'linkedTo' => $linkedTo,
];
}
if (empty($orphans)) {
echo '<p class="file-browser-empty">Aucune vidéo orpheline trouvée. Toutes les vidéos de la chaîne sont déjà liées.</p>';
exit;
}
?>
<div id="peertube-browser" class="file-browser">
<p class="file-browser-hint">
<?= count($orphans) ?> vidéo(s) orpheline(s) sur la chaîne.
Cliquez pour relier à ce TFE.
</p>
<ul class="file-browser-list">
<?php foreach ($orphans as $v): ?>
<li class="file-browser-entry file-browser-file"
data-pt-uuid="<?= htmlspecialchars($v['uuid']) ?>"
data-pt-name="<?= htmlspecialchars($v['name']) ?>">
<button type="button" class="file-browser-select-btn"
onclick="XamxamRelinkPeerTube(this)"
<?= $v['linkedTo'] !== null ? 'disabled title="Déjà liée au TFE ' . htmlspecialchars($v['linkedTo']) . '"' : '' ?>
>
<span class="file-browser-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm40-88a8,8,0,0,1-8,8H107.31l18.35,18.34a8,8,0,0,1-11.32,11.32l-32-32a8,8,0,0,1,0-11.32l32-32a8,8,0,0,1,11.32,11.32L107.31,120H160A8,8,0,0,1,168,128Z"></path></svg>
</span>
<span class="file-browser-name"><?= htmlspecialchars($v['name']) ?></span>
<span class="file-browser-size"><?= !empty($v['createdAt']) ? substr($v['createdAt'], 0, 10) : '' ?></span>
<?php if ($v['linkedTo'] !== null): ?>
<span class="file-browser-badge" style="color:var(--text-tertiary);font-size:0.85em">(<?= htmlspecialchars($v['linkedTo']) ?>)</span>
<?php endif; ?>
</button>
</li>
<?php endforeach; ?>
</ul>
</div>

View File

@@ -948,7 +948,7 @@ th.admin-ap-col {
border-bottom: 1px solid var(--border-primary);
}
.admin-dialog__header h2 {
.admin-dialog__header h3 {
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -1014,6 +1014,9 @@ th.admin-ap-col {
padding: var(--space-m) var(--space-l);
font-size: var(--step--1);
line-height: 1.6;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.admin-dialog__body > *:first-child {
@@ -1045,6 +1048,116 @@ th.admin-ap-col {
padding: 0 var(--space-l) var(--space-m);
}
/* Side-panel variant: pinned to right, full height */
.admin-dialog--sheet {
max-width: 720px;
max-height: 100vh;
height: 100vh;
margin: 0 0 0 auto;
border-radius: 16px 0 0 16px;
animation: adminSheetIn 0.3s ease-out;
overflow: hidden;
}
.admin-dialog--sheet[open] {
display: flex;
flex-direction: column;
}
@keyframes adminSheetIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.admin-dialog--sheet::backdrop {
background: rgba(0,0,0,0.5);
}
/* Dialog table: replaces grey background box with flat table + heading */
.n-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85em;
margin: var(--space-xs) 0 var(--space-md) 0;
}
.n-table thead th {
text-align: left;
padding: var(--space-3xs) var(--space-xs);
border-bottom: 1px solid var(--border-primary);
font-weight: 600;
color: var(--text-secondary);
font-size: 0.9em;
}
.n-table tbody td {
padding: var(--space-3xs) var(--space-xs);
border-bottom: 1px solid var(--border-secondary);
vertical-align: top;
}
.n-table .n-table__info {
color: var(--text-tertiary);
font-size: 0.9em;
}
.n-heading {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
margin: 0 0 var(--space-xs) 0;
font-size: var(--step--1);
font-weight: 600;
color: var(--text-primary);
line-height: 1.4;
}
.n-meta {
font-weight: 400;
font-size: 0.9em;
color: var(--text-secondary);
margin-left: var(--space-2xs);
}
.n-grid {
display: block;
}
.n-section,
.n-grid > section,
.n-grid > details {
margin: 2ch 0;
}
.n-section:first-child,
.n-grid > section:first-child,
.n-grid > details:first-child {
margin-top: 0;
}
.n-section:last-child,
.n-grid > section:last-child,
.n-grid > details:last-child {
margin-bottom: 0;
}
.n-grid .n-heading {
margin-top: 0;
}
/* <details>/<summary> inside cleanup sections */
.n-grid > details > summary {
cursor: pointer;
font-weight: 600;
font-size: var(--step--1);
color: var(--text-primary);
padding: var(--space-xs) 0;
list-style: none;
}
.n-grid > details > summary::-webkit-details-marker {
display: none;
}
.n-grid > details > summary::before {
content: '▸ ';
display: inline-block;
transition: transform 0.15s;
}
.n-grid > details[open] > summary::before {
transform: rotate(90deg);
}
.n-grid > details > :not(summary) {
padding-left: calc(1ch + var(--space-xs));
}
/* ── Import results log ─────────────────────────────────────────────── */
.admin-import-log {
list-style: none;

View File

@@ -6,6 +6,11 @@
/* Keep the base flex layout (no html/body overrides) —
main scrolls internally so the body gradient stays at viewport bottom. */
/* Override inherited padding on main-content (apropós, licence, charte) */
#main-content {
padding-top: 0;
}
.page-content {
flex: 1;
min-height: 0;
@@ -30,7 +35,7 @@
.apropos-toc-label {
font-family: var(--font-display);
font-size: var(--step--2);
font-size: var(--step-1);
font-weight: 400;
color: var(--text-primary);
margin: 0 0 var(--space-2xs) 0;
@@ -49,8 +54,9 @@
.apropos-toc ul a {
font-family: var(--font-body);
font-size: var(--step--1);
color: var(--text-secondary);
font-size: var(--step-0);
font-weight: 300;
color: var(--text-primary);
text-decoration: none;
display: block;
padding: var(--space-3xs) 0;
@@ -112,6 +118,10 @@
word-break: break-word;
}
.content {
padding-bottom: var(--space-xl);
}
.content p,
.content-section p {
margin: 0 0 1em 0;
@@ -127,6 +137,11 @@
margin: 1.5em 0 0.5em 0;
}
.content :where(h1, h2, h3):first-child,
.content-section :where(h1, h2, h3):first-child {
margin-top: 2.2rem;
}
.content a,
.content-section a {
color: inherit;

View File

@@ -62,9 +62,15 @@ main * {
}
/* Global heading scale — used by admin + public pages */
h1 { font-size: var(--step-4); }
h2 { font-size: var(--step-3); }
h3 { font-size: var(--step-2); }
h4 { font-size: var(--step-1); }
h5 { font-size: var(--step-0); }
h6 { font-size: var(--step--1); }
:where(h1, h2, h3, h4, h5, h6) {
font-family: var(--font-display);
font-size: var(--step-2);
font-weight: 400;
margin: 0 0 var(--space-l) 0;
line-height: 1.15;

View File

@@ -8,12 +8,11 @@
header {
vertical-align: center;
flex-shrink: 0;
background: #9557B5;
background: #c05de1;
background: linear-gradient(
0deg,
rgba(149, 87, 181, 1) 0%,
rgba(192, 93, 225, 1) 25%,
rgba(51, 191, 135, 1) 75%,
rgba(192, 93, 225, 1) 0%,
rgba(51, 191, 135, 1) 66%,
rgba(60, 133, 108, 1) 100%
);
}
@@ -56,9 +55,6 @@ header nav ul a:hover {
header nav ul a[aria-current="page"] {
color: var(--accent-primary);
text-shadow:
0 0 4px white,
0 0 8px white;
border-radius: 0;
border-bottom: 2px solid currentColor;
padding-bottom: 1px;

View File

@@ -6,7 +6,8 @@
.header-search-wrap {
padding: 0;
flex-shrink: 0;
background: linear-gradient(180deg, #9557B5 0%, #ffffffee 100%);
background: #C05DE1;
background: linear-gradient(180deg, rgba(192, 93, 225, 1) 0%, rgba(255, 255, 255, 1) 100%);
}
.header-search-form { width: 100%; }

View File

@@ -83,6 +83,7 @@
text-transform: uppercase;
color: var(--text-primary);
font-weight: 398;
line-height: 23px;
margin: 0;
padding: var(--space-xs) 0 var(--space-3xs) 0;
border-bottom: 1px solid var(--text-primary);
@@ -150,7 +151,7 @@
/* Years column — big numbers, semi-bold (BBBDMSans Medium weight) */
.repertoire-col[data-col="years"] .rep-entry {
font-size: var(--step-3);
font-weight: 498;
font-weight: 300;
line-height: 1.1;
letter-spacing: -0.02em;
padding: var(--space-3xs) 0;

View File

@@ -76,6 +76,25 @@
font-style: italic;
}
/* Synopsis column wrapper (context note + synopsis) */
.tfe-synopsis-column {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
/* Contextual note above synopsis */
.tfe-context-note {
font-style: italic;
font-size: var(--step--1);
line-height: 1.6;
color: var(--text-secondary);
margin: 0;
padding: var(--space-xs) var(--space-s);
background: color-mix(in srgb, var(--accent-primary) 10%, var(--bg-primary));
border-radius: var(--radius);
}
.tfe-synopsis-empty {
/* placeholder to maintain grid column */
}

View File

@@ -948,4 +948,110 @@
bodyEl.innerHTML = '<p class="file-browser-error">Erreur réseau.</p>';
});
};
// PeerTube video relink (edit page — binds a channel-orphan video to the thesis)
window.XamxamRelinkPeerTube = (el) => {
var li = el.closest(".file-browser-entry");
if (!li) return;
var uuid = li.dataset.ptUuid;
var name = li.dataset.ptName;
if (!uuid) return;
var ctx = window.__xamxamPeertubeRelinkCtx || {};
var thesisId = ctx.thesisId;
if (!thesisId) return;
var bodyEl = document.getElementById("peertube-relink-modal-body");
if (bodyEl)
bodyEl.innerHTML =
'<p class="file-browser-loading">Reliage en cours…</p>';
var csrfToken =
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content") || "";
fetch("/admin/actions/peertube-relink.php", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify({
thesis_id: parseInt(thesisId, 10),
uuid: uuid,
}),
})
.then((r) =>
r.json().then((data) => ({ ok: r.ok, status: r.status, data })),
)
.then(({ ok, status, data }) => {
if (!ok || (data && data.ok === false)) {
var msg = data?.error
? data.error
: typeof data === "string"
? data
: "Erreur " + status;
if (bodyEl)
bodyEl.innerHTML = '<p class="file-browser-error">Erreur : ' + msg + '</p>';
return;
}
console.log("[pt-relink] success | new_id=" + data.id);
var input = document.querySelector(
'.tfe-file-picker[data-queue-type="tfe"]',
);
var closeAndRefresh = () => {
var modal = document.getElementById("peertube-relink-modal");
if (modal) modal.close();
var block = document.getElementById("format-fichiers-block");
if (block && window.htmx) {
var url = "/admin/fragments/fichiers.php";
if (thesisId)
url += "?_thesis_id=" + encodeURIComponent(thesisId);
htmx.ajax("GET", url, {
target: "#format-fichiers-block",
swap: "outerHTML",
});
}
};
if (input) {
var pond = FilePond.find(input);
if (pond) {
pond
.addFile(String(data.id), {
type: "limbo",
file: {
name: name,
size: 0,
type: "video/mp4",
},
})
.then(() => {
console.log("[pt-relink] addFile resolved");
closeAndRefresh();
})
.catch((err) => {
console.error("[pt-relink] addFile rejected", err);
closeAndRefresh();
});
} else {
console.error("[pt-relink] FilePond.find returned null");
closeAndRefresh();
}
} else {
console.warn("[pt-relink] input not found");
closeAndRefresh();
}
window.__xamxamDirty = true;
})
.catch((err) => {
console.error("[pt-relink] fetch error", err);
if (bodyEl)
bodyEl.innerHTML =
'<p class="file-browser-error">Erreur réseau.</p>';
});
};
})();

View File

@@ -274,6 +274,9 @@ class ExportController
'Licence',
'Points sur 20',
'Lien BAIU',
'CC2r',
'Exemplaire BAIU',
'Exemplaire ERG',
];
/**
@@ -390,6 +393,9 @@ class ExportController
$t['license_name'] ?? '',
isset($t['jury_points']) ? (string) $t['jury_points'] : '',
$t['baiu_link'] ?? '',
!empty($t['cc2r']) ? 'Oui' : 'Non',
!empty($t['exemplaire_baiu']) ? 'Oui' : 'Non',
!empty($t['exemplaire_erg']) ? 'Oui' : 'Non',
];
}

View File

@@ -339,8 +339,13 @@ class ThesisCreateController
if ($contactVisible === '') {
$contactVisible = trim($post['mail'] ?? '');
}
// showContact: whether to show the contact publicly
if (array_key_exists('contact_public', $post)) {
// showContact: whether to show the contact publicly on the TFE page.
// In admin mode, the checkbox always renders — unchecked = not sent.
// In student mode (partage), the checkbox is not shown, so we
// default to true when contact_visible is filled.
if ($adminMode) {
$showContact = !empty($post['contact_public']);
} elseif (array_key_exists('contact_public', $post)) {
$showContact = !empty($post['contact_public']);
} else {
$showContact = $contactVisible !== '';

View File

@@ -249,6 +249,8 @@ class ThesisEditController
// contact_interne = private email of the first author (backoffice field)
$contactInterne = trim($post['contact_interne'] ?? '');
$firstAuthorEmail = $contactInterne !== '' ? $contactInterne : null;
// contact_public: whether to show the public contact on the TFE page
$showContact = !empty($post['contact_public']);
$authorNames = [];
if ($authorsRaw !== '') {
$authorNames = array_values(array_filter(array_map('trim', explode(',', $authorsRaw)), fn ($n) => $n !== ''));
@@ -259,7 +261,7 @@ class ThesisEditController
$authorEntries[] = [
'name' => $name,
'email' => $i === 0 ? $firstAuthorEmail : null,
'show_contact' => $i === 0,
'show_contact' => $i === 0 && $showContact,
];
}
$this->db->setThesisAuthors($thesisId, $authorEntries);

View File

@@ -1297,9 +1297,10 @@ class Database
if ($query === '') {
$stmt = $this->pdo->query('
SELECT tg.id, tg.name,
COUNT(DISTINCT tt.thesis_id) as thesis_count
COUNT(DISTINCT t.id) as thesis_count
FROM tags tg
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
LEFT JOIN theses t ON tt.thesis_id = t.id AND t.deleted_at IS NULL
WHERE tg.deleted_at IS NULL
GROUP BY tg.id
ORDER BY thesis_count DESC, tg.name COLLATE NOCASE
@@ -1308,9 +1309,10 @@ class Database
} else {
$stmt = $this->pdo->prepare('
SELECT tg.id, tg.name,
COUNT(DISTINCT tt.thesis_id) as thesis_count
COUNT(DISTINCT t.id) as thesis_count
FROM tags tg
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
LEFT JOIN theses t ON tt.thesis_id = t.id AND t.deleted_at IS NULL
WHERE tg.name LIKE ? AND tg.deleted_at IS NULL
GROUP BY tg.id
ORDER BY tg.name = ? DESC, thesis_count DESC, tg.name COLLATE NOCASE
@@ -1328,9 +1330,10 @@ class Database
{
$stmt = $this->pdo->query('
SELECT tg.id, tg.name,
COUNT(DISTINCT tt.thesis_id) as thesis_count
COUNT(DISTINCT t.id) as thesis_count
FROM tags tg
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
LEFT JOIN theses t ON tt.thesis_id = t.id AND t.deleted_at IS NULL
WHERE tg.deleted_at IS NULL
GROUP BY tg.id
ORDER BY tg.name COLLATE NOCASE
@@ -1416,9 +1419,10 @@ class Database
$stmt = $this->pdo->query('
SELECT MIN(l.id) as id,
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
COUNT(DISTINCT tl.thesis_id) as thesis_count
COUNT(DISTINCT t.id) as thesis_count
FROM languages l
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
LEFT JOIN theses t ON tl.thesis_id = t.id AND t.deleted_at IS NULL
WHERE l.deleted_at IS NULL
GROUP BY LOWER(l.name)
ORDER BY thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE
@@ -1428,9 +1432,10 @@ class Database
$stmt = $this->pdo->prepare('
SELECT MIN(l.id) as id,
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
COUNT(DISTINCT tl.thesis_id) as thesis_count
COUNT(DISTINCT t.id) as thesis_count
FROM languages l
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
LEFT JOIN theses t ON tl.thesis_id = t.id AND t.deleted_at IS NULL
WHERE LOWER(l.name) LIKE LOWER(?) AND l.deleted_at IS NULL
GROUP BY LOWER(l.name)
ORDER BY LOWER(MIN(l.name)) = LOWER(?) DESC, thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE
@@ -1450,9 +1455,10 @@ class Database
$stmt = $this->pdo->query('
SELECT MIN(l.id) as id,
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
COUNT(DISTINCT tl.thesis_id) as thesis_count
COUNT(DISTINCT t.id) as thesis_count
FROM languages l
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
LEFT JOIN theses t ON tl.thesis_id = t.id AND t.deleted_at IS NULL
WHERE l.deleted_at IS NULL
GROUP BY LOWER(l.name)
ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE
@@ -2683,7 +2689,10 @@ class Database
t.context_note,
t.remarks,
t.jury_points,
t.baiu_link
t.baiu_link,
t.exemplaire_baiu,
t.exemplaire_erg,
t.cc2r
FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id

View File

@@ -24,6 +24,48 @@ class DatabaseMigrations
$this->migrateRenameFinalityTypes();
$this->migrateShareLinksNameColumn();
$this->migrateShareLinksLockedYearColumn();
$this->migrateThesisLanguagesIndex();
$this->migrateTagsDeletedNameIndex();
}
/**
* 2026-06-21 — Add index on thesis_languages(language_id).
*
* The contenus page queries join languages → thesis_languages on
* language_id. SQLite's PRIMARY KEY on (thesis_id, language_id)
* can't optimize lookups by language_id alone, so it builds an
* AUTOMATIC COVERING INDEX per query. This persisted index
* removes that overhead.
*/
private function migrateThesisLanguagesIndex(): void
{
try {
$this->pdo->exec(
'CREATE INDEX IF NOT EXISTS idx_thesis_languages_language
ON thesis_languages(language_id)'
);
} catch (\PDOException $e) {
// Table may not exist yet on fresh install — ignore
}
}
/**
* 2026-06-21 — Add covering index on tags(deleted_at, name).
*
* getAllTagsWithCount() filters on deleted_at IS NULL and orders
* by name. This covering index avoids a SCAN of the tags table
* and a temp B-tree for sorting.
*/
private function migrateTagsDeletedNameIndex(): void
{
try {
$this->pdo->exec(
'CREATE INDEX IF NOT EXISTS idx_tags_deleted_name
ON tags(deleted_at, name)'
);
} catch (\PDOException $e) {
// Table may not exist yet on fresh install — ignore
}
}
/**
@@ -31,7 +73,7 @@ class DatabaseMigrations
*
* Spécialisé → Spécialisée
* Approfondi → Approfondie
* Enseignement → Didactique
* Didactique → Enseignement
*/
private function migrateRenameFinalityTypes(): void
{
@@ -39,7 +81,7 @@ class DatabaseMigrations
$renames = [
'Spécialisé' => 'Spécialisée',
'Approfondi' => 'Approfondie',
'Enseignement' => 'Didactique',
'Didactique' => 'Enseignement',
];
foreach ($renames as $old => $new) {
// Skip if only canonical row already exists

View File

@@ -108,7 +108,7 @@ class FormBootstrap
$autofocusField = App::consumeAutofocus();
// Controls
$showContact = false;
$showContact = ($mode === 'add' || $mode === 'edit');
$showBackoffice = ($mode === 'add' || $mode === 'edit');
// Licence / access toggles: admin always enables all three
@@ -208,7 +208,7 @@ class FormBootstrap
// Backoffice (empty for add, populated for edit by caller)
'currentRaw' => [],
'contactInterne' => null,
'contactPublic' => false,
'contactPublic' => null,
'currentContextNote' => null,
'currentContactVisible' => null,
'currentDurationValue' => null,

View File

@@ -265,6 +265,74 @@ class PeerTubeService
return rtrim($s['instance_url'], '/') . '/videos/watch/' . $uuid;
}
// -------------------------------------------------------------------------
// List channel videos
// -------------------------------------------------------------------------
/**
* List all videos uploaded by the authenticated user account.
*
* Uses GET /api/v1/users/me/videos which reliably returns only the
* authenticated user's own videos (all on the configured channel).
* The channel-based endpoints proved unreliable on this instance.
*
* @return array<int, array{uuid:string, shortUUID:string, name:string, createdAt:string}>
*/
public static function listChannelVideos(Database $db): array
{
$s = self::getSettings($db);
if ($s['instance_url'] === '') {
return [];
}
$videos = [];
$count = 100;
$start = 0;
try {
$token = self::obtainToken($s);
$baseUrl = rtrim($s['instance_url'], '/');
while (true) {
$url = $baseUrl . '/api/v1/users/me/videos?count=' . $count
. '&start=' . $start . '&sort=-createdAt';
$resp = self::httpRequest($url, 'GET', [
'headers' => ['Authorization' => 'Bearer ' . $token],
'timeout' => 30,
]);
if ($resp['status'] !== 200) {
error_log('PeerTubeService::listChannelVideos failed: status=' . $resp['status']);
break;
}
$json = json_decode($resp['body'], true);
if (!is_array($json) || !isset($json['data'])) {
break;
}
foreach ($json['data'] as $v) {
$videos[] = [
'uuid' => $v['uuid'] ?? '',
'shortUUID' => $v['shortUUID'] ?? '',
'name' => $v['name'] ?? '',
'createdAt' => $v['createdAt'] ?? '',
];
}
$total = (int)($json['total'] ?? 0);
if ($start + $count >= $total) {
break;
}
$start += $count;
}
} catch (\Throwable $e) {
error_log('PeerTubeService::listChannelVideos exception: ' . $e->getMessage());
}
return $videos;
}
// -------------------------------------------------------------------------
// Delete
// -------------------------------------------------------------------------

View File

@@ -386,10 +386,14 @@ CREATE INDEX IF NOT EXISTS idx_thesis_authors_author ON thesis_authors(author_id
CREATE INDEX IF NOT EXISTS idx_thesis_authors_thesis ON thesis_authors(thesis_id);
CREATE INDEX IF NOT EXISTS idx_thesis_languages_language ON thesis_languages(language_id);
CREATE INDEX IF NOT EXISTS idx_thesis_tags_tag ON thesis_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_thesis_tags_thesis ON thesis_tags(thesis_id);
CREATE INDEX IF NOT EXISTS idx_tags_deleted_name ON tags(deleted_at, name);
-- ============================================================================
-- VIEWS
-- ============================================================================
@@ -531,7 +535,7 @@ INSERT OR IGNORE INTO ap_programs (name, code) VALUES ('Lieux, Interdisciplinari
INSERT OR IGNORE INTO ap_programs (name, code) VALUES ('Pratique de l''art - outils critiques, arts et contexte simultanés', 'PACS');
INSERT OR IGNORE INTO finality_types (name) VALUES ('Approfondie');
INSERT OR IGNORE INTO finality_types (name) VALUES ('Didactique');
INSERT OR IGNORE INTO finality_types (name) VALUES ('Enseignement');
INSERT OR IGNORE INTO finality_types (name) VALUES ('Spécialisée');
INSERT OR IGNORE INTO languages (name) VALUES ('français');

View File

@@ -41,7 +41,7 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
<?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()">
onclick="document.getElementById('tmp-cleanup-dialog').showModal(); htmx.trigger('#tmp-cleanup-stats','loadStats'); htmx.trigger('#peertube-orphans-wrapper','loadPeertube')">
Nettoyer (<?= $tmpTotalCount ?>)
</button>
<?php endif; ?>
@@ -97,289 +97,14 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
<?php include APP_ROOT . '/templates/admin/index-table.php'; ?>
</main>
<!-- ══════════════════════════════════════════════════════════════
CONFIRM DIALOGS (replacing browser alert/confirm)
══════════════════════════════════════════════════════════════ -->
<?php include APP_ROOT . '/templates/admin/partials/dialogs/no-selection.php'; ?>
<?php include APP_ROOT . '/templates/admin/partials/dialogs/bulk-confirm.php'; ?>
<?php include APP_ROOT . '/templates/admin/partials/dialogs/bulk-delete.php'; ?>
<?php include APP_ROOT . '/templates/admin/partials/dialogs/delete-thesis.php'; ?>
<!-- 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>
<?php include APP_ROOT . '/templates/admin/partials/dialogs/import.php'; ?>
<!-- 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"
class="tfe-file-picker"
data-queue-type="csv_import"
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 += `<details style="margin:0 0 var(--space-xs) 0;font-size:0.9em" open><summary>📁 <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></summary>`;
if (data.filepond_stale_files) {
html += '<ul style="margin:var(--space-xs) 0 0 var(--space-md);max-height:10em;overflow-y:auto">';
data.filepond_stale_files.forEach(f => { html += `<li>${f.name} <span style="color:var(--text-secondary)">(${f.human}, ~${Math.round(f.age_minutes)}min)</span></li>`; });
html += '</ul>';
}
html += '</details>';
}
if (data.trash_stale_count) {
html += `<details style="margin:0 0 var(--space-xs) 0;font-size:0.9em" open><summary>🗑️ <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></summary>`;
if (data.trash_stale_files) {
html += '<ul style="margin:var(--space-xs) 0 0 var(--space-md);max-height:10em;overflow-y:auto">';
data.trash_stale_files.forEach(f => { html += `<li>${f.name} <span style="color:var(--text-secondary)">(${f.human}, ~${Math.round(f.age_days)}j)</span></li>`; });
html += '</ul>';
}
html += '</details>';
}
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 include APP_ROOT . '/templates/admin/partials/dialogs/tmp-cleanup.php'; ?>
<?php if ($importMessage || !empty($importErrors)): ?>
<script>document.getElementById('import-dialog').showModal();</script>

View File

@@ -0,0 +1,14 @@
<dialog id="bulk-confirm-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="bulk-confirm-title">
<div class="admin-dialog__header">
<h3 id="bulk-confirm-title">Confirmation</h3>
<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)&nbsp;?</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>

View File

@@ -0,0 +1,14 @@
<dialog id="bulk-delete-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="bulk-delete-title">
<div class="admin-dialog__header">
<h3 id="bulk-delete-title">Supprimer des TFE</h3>
<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>&nbsp;? 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>

View File

@@ -0,0 +1,14 @@
<dialog id="delete-thesis-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="delete-thesis-title-label">
<div class="admin-dialog__header">
<h3 id="delete-thesis-title-label">Supprimer ce TFE</h3>
<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 &laquo;&nbsp;<strong id="delete-thesis-title"></strong>&nbsp;&raquo;&nbsp;? 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>

View File

@@ -0,0 +1,78 @@
<dialog id="import-dialog" class="admin-dialog" aria-labelledby="import-dialog-title">
<div class="admin-dialog__header">
<h3 id="import-dialog-title">Importer une liste de TFE</h3>
<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"
class="tfe-file-picker"
data-queue-type="csv_import"
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>

View File

@@ -0,0 +1,13 @@
<dialog id="no-selection-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="no-sel-title">
<div class="admin-dialog__header">
<h3 id="no-sel-title">Aucune sélection</h3>
<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>

View File

@@ -0,0 +1,29 @@
<dialog id="tmp-cleanup-dialog" class="admin-dialog admin-dialog--sheet" aria-labelledby="tmp-cleanup-title">
<div class="admin-dialog__header">
<h3 id="tmp-cleanup-title">Nettoyer les fichiers temporaires</h3>
<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">
<div id="tmp-cleanup-result" style="display:none;margin-bottom:var(--space-sm)"></div>
<div class="n-grid" id="cleanup-grid-parent">
<!-- ═══════ FilePond / Trash ═══════ -->
<details id="tmp-cleanup-stats" class="n-section" open>
<summary>Fichiers temporaires</summary>
<div hx-get="/admin/actions/cleanup-stats-fragment.php"
hx-trigger="loadStats"
hx-swap="innerHTML">
<p style="margin:0;color:var(--text-secondary)">Chargement…</p>
</div>
</details>
<!-- ═══════ PeerTube ═══════ -->
<div id="peertube-orphans-wrapper"
hx-get="/admin/actions/peertube-orphans-fragment.php"
hx-trigger="loadPeertube"
hx-swap="outerHTML"
hx-indicator="#peertube-orphans-wrapper">
<p style="margin:0;color:var(--text-secondary)">Chargement…</p>
</div>
</div>
</div>
</dialog>

View File

@@ -100,7 +100,7 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
hx-swap="innerHTML"
hx-trigger="click"
onclick="document.getElementById('relink-modal').showModal(); window.__xamxamRelinkCtx = { queueType: 'cover', thesisId: '<?= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>' };">
📂 Relier un fichier existant
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256" style="vertical-align:-2px;margin-right:var(--space-3xs)"><path d="M198.63,57.37a32,32,0,0,0-45.19-.06L141.79,69.52a8,8,0,0,1-11.58-11l11.72-12.29a1.59,1.59,0,0,1,.13-.13,48,48,0,0,1,67.88,67.88,1.59,1.59,0,0,1-.13.13l-12.29,11.72a8,8,0,0,1-11-11.58l12.21-11.65A32,32,0,0,0,198.63,57.37ZM114.21,186.48l-11.65,12.21a32,32,0,0,1-45.25-45.25l12.21-11.65a8,8,0,0,0-11-11.58L46.19,141.93a1.59,1.59,0,0,0-.13.13,48,48,0,0,0,67.88,67.88,1.59,1.59,0,0,0,.13-.13l11.72-12.29a8,8,0,1,0-11.58-11ZM216,152H192a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16ZM40,104H64a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16Zm120,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V192A8,8,0,0,0,160,184ZM96,72a8,8,0,0,0,8-8V40a8,8,0,0,0-16,0V64A8,8,0,0,0,96,72Z"></path></svg> Relier un fichier existant
</button>
<?php endif; ?>
</div>
@@ -128,7 +128,7 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
hx-swap="innerHTML"
hx-trigger="click"
onclick="document.getElementById('relink-modal').showModal(); window.__xamxamRelinkCtx = { queueType: 'note_intention', thesisId: '<?= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>' };">
📂 Relier un fichier existant
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256" style="vertical-align:-2px;margin-right:var(--space-3xs)"><path d="M198.63,57.37a32,32,0,0,0-45.19-.06L141.79,69.52a8,8,0,0,1-11.58-11l11.72-12.29a1.59,1.59,0,0,1,.13-.13,48,48,0,0,1,67.88,67.88,1.59,1.59,0,0,1-.13.13l-12.29,11.72a8,8,0,0,1-11-11.58l12.21-11.65A32,32,0,0,0,198.63,57.37ZM114.21,186.48l-11.65,12.21a32,32,0,0,1-45.25-45.25l12.21-11.65a8,8,0,0,0-11-11.58L46.19,141.93a1.59,1.59,0,0,0-.13.13,48,48,0,0,0,67.88,67.88,1.59,1.59,0,0,0,.13-.13l11.72-12.29a8,8,0,1,0-11.58-11ZM216,152H192a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16ZM40,104H64a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16Zm120,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V192A8,8,0,0,0,160,184ZM96,72a8,8,0,0,0,8-8V40a8,8,0,0,0-16,0V64A8,8,0,0,0,96,72Z"></path></svg> Relier un fichier existant
</button>
<?php endif; ?>
</div>
@@ -166,8 +166,20 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
hx-swap="innerHTML"
hx-trigger="click"
onclick="document.getElementById('relink-modal').showModal(); window.__xamxamRelinkCtx = { queueType: 'tfe', thesisId: '<?= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>' };">
📂 Relier un fichier existant
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256" style="vertical-align:-2px;margin-right:var(--space-3xs)"><path d="M198.63,57.37a32,32,0,0,0-45.19-.06L141.79,69.52a8,8,0,0,1-11.58-11l11.72-12.29a1.59,1.59,0,0,1,.13-.13,48,48,0,0,1,67.88,67.88,1.59,1.59,0,0,1-.13.13l-12.29,11.72a8,8,0,0,1-11-11.58l12.21-11.65A32,32,0,0,0,198.63,57.37ZM114.21,186.48l-11.65,12.21a32,32,0,0,1-45.25-45.25l12.21-11.65a8,8,0,0,0-11-11.58L46.19,141.93a1.59,1.59,0,0,0-.13.13,48,48,0,0,0,67.88,67.88,1.59,1.59,0,0,0,.13-.13l11.72-12.29a8,8,0,1,0-11.58-11ZM216,152H192a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16ZM40,104H64a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16Zm120,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V192A8,8,0,0,0,160,184ZM96,72a8,8,0,0,0,8-8V40a8,8,0,0,0-16,0V64A8,8,0,0,0,96,72Z"></path></svg> Relier un fichier existant
</button>
<?php if ($peerTubeEnabled): ?>
<button type="button" class="btn btn--sm btn--ghost peertube-browser-trigger"
data-thesis-id="<?= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>"
hx-get="/admin/fragments/peertube-browser.php"
hx-target="#peertube-relink-modal-body"
hx-swap="innerHTML"
hx-trigger="click"
onclick="document.getElementById('peertube-relink-modal').showModal(); window.__xamxamPeertubeRelinkCtx = { thesisId: '<?= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>' };"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256" style="vertical-align:-2px;margin-right:var(--space-3xs)"><path d="M198.63,57.37a32,32,0,0,0-45.19-.06L141.79,69.52a8,8,0,0,1-11.58-11l11.72-12.29a1.59,1.59,0,0,1,.13-.13,48,48,0,0,1,67.88,67.88,1.59,1.59,0,0,1-.13.13l-12.29,11.72a8,8,0,0,1-11-11.58l12.21-11.65A32,32,0,0,0,198.63,57.37ZM114.21,186.48l-11.65,12.21a32,32,0,0,1-45.25-45.25l12.21-11.65a8,8,0,0,0-11-11.58L46.19,141.93a1.59,1.59,0,0,0-.13.13,48,48,0,0,0,67.88,67.88,1.59,1.59,0,0,0,.13-.13l11.72-12.29a8,8,0,1,0-11.58-11ZM216,152H192a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16ZM40,104H64a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16Zm120,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V192A8,8,0,0,0,160,184ZM96,72a8,8,0,0,0,8-8V40a8,8,0,0,0-16,0V64A8,8,0,0,0,96,72Z"></path></svg> Relier une vidéo PeerTube
</button>
<?php endif; ?>
<?php endif; ?>
</div>
@@ -195,7 +207,7 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
hx-swap="innerHTML"
hx-trigger="click"
onclick="document.getElementById('relink-modal').showModal(); window.__xamxamRelinkCtx = { queueType: 'annexe', thesisId: '<?= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>' };">
📂 Relier un fichier existant
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256" style="vertical-align:-2px;margin-right:var(--space-3xs)"><path d="M198.63,57.37a32,32,0,0,0-45.19-.06L141.79,69.52a8,8,0,0,1-11.58-11l11.72-12.29a1.59,1.59,0,0,1,.13-.13,48,48,0,0,1,67.88,67.88,1.59,1.59,0,0,1-.13.13l-12.29,11.72a8,8,0,0,1-11-11.58l12.21-11.65A32,32,0,0,0,198.63,57.37ZM114.21,186.48l-11.65,12.21a32,32,0,0,1-45.25-45.25l12.21-11.65a8,8,0,0,0-11-11.58L46.19,141.93a1.59,1.59,0,0,0-.13.13,48,48,0,0,0,67.88,67.88,1.59,1.59,0,0,0,.13-.13l11.72-12.29a8,8,0,1,0-11.58-11ZM216,152H192a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16ZM40,104H64a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16Zm120,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V192A8,8,0,0,0,160,184ZM96,72a8,8,0,0,0,8-8V40a8,8,0,0,0-16,0V64A8,8,0,0,0,96,72Z"></path></svg> Relier un fichier existant
</button>
<?php endif; ?>
</div>
@@ -219,4 +231,22 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
<!-- ═══════════════════ File Browser Modal (edit mode only) ═══════════════════ -->
<?php if ($editMode): ?>
<?php include APP_ROOT . '/templates/partials/form/file-browser-fragment.php'; ?>
<!-- PeerTube relink modal (edit mode, PeerTube enabled) -->
<?php if ($peerTubeEnabled): ?>
<dialog id="peertube-relink-modal" class="relink-modal">
<div class="relink-modal-header">
<h3>Relier une vidéo PeerTube</h3>
<button type="button" class="btn btn--sm btn--ghost"
onclick="document.getElementById('peertube-relink-modal').close()"
aria-label="Fermer">✕</button>
</div>
<div id="peertube-relink-modal-body">
<p class="file-browser-loading">Chargement des vidéos orphelines…</p>
</div>
<div class="relink-modal-footer">
<small>Seules les vidéos présentes sur la chaîne mais non liées à un TFE sont listées.</small>
</div>
</dialog>
<?php endif; ?>
<?php endif; ?>

View File

@@ -32,7 +32,7 @@
* bool $showContact — Contact checkbox fieldset
* bool $showCoverPreview — cover image preview + remove checkbox
* bool $showExistingFiles — existing thesis files list (deletable)
* bool $showBackoffice — Backoffice fieldset (context_note, jury_points, remarks, baiu_link, exemplaires, contact_visible, contact_interne, is_published)
* bool $showBackoffice — Backoffice fieldset (context_note, jury_points, remarks, baiu_link, exemplaires, contact_interne, is_published)
* bool $showEmailConfirmation — E-mail de confirmation fieldset
* string $helpFn — fn(string $key): string (for help blocks)
@@ -45,7 +45,7 @@
* array $currentFiles — existing thesis files for edit mode
* ?string $currentContextNote — existing context note for edit mode
* array $currentRaw — raw thesis row for edit mode
* ?string $contactPublic — contact visibility flag for edit mode
* ?bool $contactPublic — contact visibility flag for edit mode
* ?string $contactInterne — contact email for edit mode
*
* Autosave:
@@ -455,7 +455,7 @@ if ($filesMode === 'add'): ?>
</div>
<!-- Integer input for pages / Mo -->
<div id="duration-value-integer"<?= $_durUnit === 'durée' ? ' style="display:none"' : '' ?>>
<label for="duration_value_int">Valeur :</label>
<label for="duration_value_int" id="duration-value-label">Valeur :</label>
<input type="number" id="duration_value_int"
value="<?= htmlspecialchars($_durUnit !== 'durée' ? (string)($_durFloat ?? '') : '') ?>"
step="1" min="0" placeholder="0"
@@ -648,10 +648,13 @@ if ($filesMode === 'add'): ?>
var hidden = document.getElementById('duration_value');
var intWrap = document.getElementById('duration-value-integer');
var intInput = document.getElementById('duration_value_int');
var intLabel = document.getElementById('duration-value-label');
var timeWrap = document.getElementById('duration-value-time');
var hInput = document.getElementById('duration_h');
var mInput = document.getElementById('duration_m');
var sInput = document.getElementById('duration_s');
var LABELS = { pages: 'Nombre :', mo: 'Taille :', 'durée': 'Durée :' };
if (!unit || !hidden) return;
function updateHidden() {
@@ -673,6 +676,7 @@ if ($filesMode === 'add'): ?>
} else {
timeWrap.style.display = 'none';
intWrap.style.display = '';
if (intLabel) intLabel.textContent = LABELS[unit.value] || 'Valeur :';
}
updateHidden();
}

View File

@@ -207,14 +207,8 @@
</p>
<?php endif; ?>
<?php if (!empty($data["context_note"])): ?>
<p class="tfe-meta-item tfe-meta-note">
<span class="tfe-meta-label">Note contextuelle relative à soutenance :</span>
<span class="tfe-note-value"><?= nl2br(htmlspecialchars($data["context_note"])) ?></span>
</p>
<?php endif; ?>
<?php if (!empty($data["contact_visible"])): ?>
<?php if (!empty($data["contact_visible"]) && !empty($data["contact_public"])): ?>
<p class="tfe-meta-item">
<span class="tfe-meta-label">Contact :</span>
<?php
@@ -271,13 +265,19 @@
<?php endif; ?>
</div>
<?php if (!empty($data["synopsis"])): ?>
<div class="tfe-synopsis-text">
<?= nl2br(htmlspecialchars($data["synopsis"])) ?>
<div class="tfe-synopsis-column">
<?php if (!empty($data["context_note"])): ?>
<p class="tfe-context-note"><em>Note Contextuelle :</em><br><?= nl2br(htmlspecialchars($data["context_note"])) ?></p>
<?php endif; ?>
<?php if (!empty($data["synopsis"])): ?>
<div class="tfe-synopsis-text">
<?= nl2br(htmlspecialchars($data["synopsis"])) ?>
</div>
<?php else: ?>
<div class="tfe-synopsis-text tfe-synopsis-empty"></div>
<?php endif; ?>
</div>
<?php else: ?>
<div class="tfe-synopsis-text tfe-synopsis-empty"></div>
<?php endif; ?>
</div>
<!-- ROW 3: All files — flex container -->