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