mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
Add SQLite indexes for contenus page language/tag queries + WIP: Peertube orphans, dialogs, contact decoupling, context note, finality types
This commit is contained in:
15
TODO.md
15
TODO.md
@@ -1,9 +1,14 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
> Last updated: 2026-06-20
|
> Last updated: 2026-06-21
|
||||||
> Context: Fix sticky TOC going out of view + heading anchor links not working on charte/licence/apropos pages
|
> Context: Add SQLite indexes for contenus page language/tag query performance + fix soft-deleted thesis count filtering
|
||||||
|
|
||||||
## In Progress
|
## In Progress
|
||||||
|
- [x] #contenus-indexes Add index on thesis_languages(language_id) + tags(deleted_at, name); fix count queries to exclude soft-deleted theses `(Database.php, DatabaseMigrations.php, schema.sql, migrations/applied/041_thesis_languages_index.sql)` ✓
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- [x] #contenus-indexes Add index on thesis_languages(language_id) + tags(deleted_at, name); fix count queries to exclude soft-deleted theses `(Database.php, DatabaseMigrations.php, schema.sql, migrations/applied/041_thesis_languages_index.sql)` ✓
|
||||||
|
- [x] #peertube-orphans-check Add Peertube orphan video check + relink in admin — listChannelVideos (PeerTubeService), peertube-orphans.php endpoint, UI in nettoyage dialog, relink support on edit page (peertube-relink.php, peertube-browser.php, fichiers-fragment.php, file-upload-filepond.js) ✓
|
||||||
|
|
||||||
## Pending
|
## Pending
|
||||||
- [ ] #overtype-analysis Analyse and fix OverType editor reliability on contenus-edit.php
|
- [ ] #overtype-analysis Analyse and fix OverType editor reliability on contenus-edit.php
|
||||||
@@ -17,12 +22,18 @@
|
|||||||
- [x] #apropos-toc-style Fix TOC "Parties" label: Ductus font + lowercase, remove border-left from links, match global link style; rename .apropos-content → section.content, .apropos-section → .content-section, remove .prose wrapper `(apropos.css, about.php, charte.php, licence.php)` ✓
|
- [x] #apropos-toc-style Fix TOC "Parties" label: Ductus font + lowercase, remove border-left from links, match global link style; rename .apropos-content → section.content, .apropos-section → .content-section, remove .prose wrapper `(apropos.css, about.php, charte.php, licence.php)` ✓
|
||||||
|
|
||||||
- [x] #apropos-toc-confirm Fixed sticky TOC: removed `flex: 1; min-height: 0` on main for apropos-body so the sticky container is full content height; added `max-height` + `overflow-y: auto` to TOC for long lists `(apropos.css)` ✓
|
- [x] #apropos-toc-confirm Fixed sticky TOC: removed `flex: 1; min-height: 0` on main for apropos-body so the sticky container is full content height; added `max-height` + `overflow-y: auto` to TOC for long lists `(apropos.css)` ✓
|
||||||
|
- [ ] #contact-test-manual Test contact decoupling end-to-end: student submission → admin edit → public TFE display
|
||||||
- [ ] #aria-test-manual Test WCAG changes with VoiceOver and NVDA on full add/edit/partage form flows
|
- [ ] #aria-test-manual Test WCAG changes with VoiceOver and NVDA on full add/edit/partage form flows
|
||||||
- [ ] #nojs-upload-test Test end-to-end: submit partage form with JS disabled, verify files arrive via `$_FILES`
|
- [ ] #nojs-upload-test Test end-to-end: submit partage form with JS disabled, verify files arrive via `$_FILES`
|
||||||
- [ ] #csp-media-iframe-deploy Deploy nginx config fix to server, test PDF iframe on /tfe?id=221
|
- [ ] #csp-media-iframe-deploy Deploy nginx config fix to server, test PDF iframe on /tfe?id=221
|
||||||
|
|
||||||
|
- [x] #fix-finality-types Create standalone script + just command to rename finality types (Approfondi→Approfondie, Enseignement→Didactique, Spécialisé→Spécialisée) `(scripts/fix-finality-types.php, justfile)` ✓
|
||||||
|
- [x] #context-note-synopsis Display contextual note above synopsis (italic) instead of in meta column on TFE page `(tfe.php, tfe.css)` ✓
|
||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
|
|
||||||
|
- [x] #decouple-contacts Decouple contact_visible (public) & contact_interne (private email): backend already decoupled; made contact_public checkbox functional in admin add/edit forms; contact_public now controls TFE page visibility `(FormBootstrap.php, ThesisCreateController.php, ThesisEditController.php, tfe.php, form.php)` ✓
|
||||||
|
|
||||||
- [x] #csrf-rotation-race Stop CSRF token rotation in draft.php + remove hx-post from <form> — both broke FilePond uploads and form submission `(admin/actions/draft.php, partage/fragments/draft.php, FormBootstrap.php, pill-search.js)` ✓
|
- [x] #csrf-rotation-race Stop CSRF token rotation in draft.php + remove hx-post from <form> — both broke FilePond uploads and form submission `(admin/actions/draft.php, partage/fragments/draft.php, FormBootstrap.php, pill-search.js)` ✓
|
||||||
- [x] ~~#filepond-csrf-stale~~ (superseded by #csrf-rotation-race)
|
- [x] ~~#filepond-csrf-stale~~ (superseded by #csrf-rotation-race)
|
||||||
- [x] #adminold-return-type Fix adminOld closure return type from `:string` to `:string|array` `(FormBootstrap.php)` ✓
|
- [x] #adminold-return-type Fix adminOld closure return type from `:string` to `:string|array` `(FormBootstrap.php)` ✓
|
||||||
|
|||||||
16
app/migrations/applied/041_thesis_languages_index.sql
Normal file
16
app/migrations/applied/041_thesis_languages_index.sql
Normal 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);
|
||||||
91
app/public/admin/actions/cleanup-stats-fragment.php
Normal file
91
app/public/admin/actions/cleanup-stats-fragment.php
Normal 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; ?>
|
||||||
@@ -43,6 +43,55 @@ $details = [];
|
|||||||
$db = new Database();
|
$db = new Database();
|
||||||
$pdo = $db->getPDO();
|
$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 ──────────────────────────────────────
|
// ── Determine PHP session save path ──────────────────────────────────────
|
||||||
$sessionSavePath = session_save_path();
|
$sessionSavePath = session_save_path();
|
||||||
if (!$sessionSavePath || $sessionSavePath === '') {
|
if (!$sessionSavePath || $sessionSavePath === '') {
|
||||||
|
|||||||
72
app/public/admin/actions/peertube-delete.php
Normal file
72
app/public/admin/actions/peertube-delete.php
Normal 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;
|
||||||
102
app/public/admin/actions/peertube-orphans-fragment.php
Normal file
102
app/public/admin/actions/peertube-orphans-fragment.php
Normal 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>
|
||||||
121
app/public/admin/actions/peertube-orphans.php
Normal file
121
app/public/admin/actions/peertube-orphans.php
Normal 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,
|
||||||
|
]);
|
||||||
126
app/public/admin/actions/peertube-relink.php
Normal file
126
app/public/admin/actions/peertube-relink.php
Normal 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;
|
||||||
@@ -24,6 +24,12 @@ try {
|
|||||||
$formData = $_SESSION['form_data'] ?? [];
|
$formData = $_SESSION['form_data'] ?? [];
|
||||||
unset($_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();
|
$siteSettings = Database::getInstance()->getAllSettings();
|
||||||
$helpBlocks = Database::getInstance()->getAllFormHelpBlocks();
|
$helpBlocks = Database::getInstance()->getAllFormHelpBlocks();
|
||||||
|
|
||||||
|
|||||||
112
app/public/admin/fragments/peertube-browser.php
Normal file
112
app/public/admin/fragments/peertube-browser.php
Normal 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>
|
||||||
@@ -948,7 +948,7 @@ th.admin-ap-col {
|
|||||||
border-bottom: 1px solid var(--border-primary);
|
border-bottom: 1px solid var(--border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog__header h2 {
|
.admin-dialog__header h3 {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1014,6 +1014,9 @@ th.admin-ap-col {
|
|||||||
padding: var(--space-m) var(--space-l);
|
padding: var(--space-m) var(--space-l);
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog__body > *:first-child {
|
.admin-dialog__body > *:first-child {
|
||||||
@@ -1045,6 +1048,116 @@ th.admin-ap-col {
|
|||||||
padding: 0 var(--space-l) var(--space-m);
|
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 ─────────────────────────────────────────────── */
|
/* ── Import results log ─────────────────────────────────────────────── */
|
||||||
.admin-import-log {
|
.admin-import-log {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
/* Keep the base flex layout (no html/body overrides) —
|
/* Keep the base flex layout (no html/body overrides) —
|
||||||
main scrolls internally so the body gradient stays at viewport bottom. */
|
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 {
|
.page-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -30,7 +35,7 @@
|
|||||||
|
|
||||||
.apropos-toc-label {
|
.apropos-toc-label {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: var(--step--2);
|
font-size: var(--step-1);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0 0 var(--space-2xs) 0;
|
margin: 0 0 var(--space-2xs) 0;
|
||||||
@@ -49,8 +54,9 @@
|
|||||||
|
|
||||||
.apropos-toc ul a {
|
.apropos-toc ul a {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: var(--step--1);
|
font-size: var(--step-0);
|
||||||
color: var(--text-secondary);
|
font-weight: 300;
|
||||||
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: block;
|
display: block;
|
||||||
padding: var(--space-3xs) 0;
|
padding: var(--space-3xs) 0;
|
||||||
@@ -112,6 +118,10 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
.content p,
|
.content p,
|
||||||
.content-section p {
|
.content-section p {
|
||||||
margin: 0 0 1em 0;
|
margin: 0 0 1em 0;
|
||||||
@@ -127,6 +137,11 @@
|
|||||||
margin: 1.5em 0 0.5em 0;
|
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 a,
|
||||||
.content-section a {
|
.content-section a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|||||||
@@ -62,9 +62,15 @@ main * {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Global heading scale — used by admin + public pages */
|
/* 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) {
|
:where(h1, h2, h3, h4, h5, h6) {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: var(--step-2);
|
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
margin: 0 0 var(--space-l) 0;
|
margin: 0 0 var(--space-l) 0;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
|
|||||||
@@ -8,12 +8,11 @@
|
|||||||
header {
|
header {
|
||||||
vertical-align: center;
|
vertical-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: #9557B5;
|
background: #c05de1;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
0deg,
|
0deg,
|
||||||
rgba(149, 87, 181, 1) 0%,
|
rgba(192, 93, 225, 1) 0%,
|
||||||
rgba(192, 93, 225, 1) 25%,
|
rgba(51, 191, 135, 1) 66%,
|
||||||
rgba(51, 191, 135, 1) 75%,
|
|
||||||
rgba(60, 133, 108, 1) 100%
|
rgba(60, 133, 108, 1) 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,9 +55,6 @@ header nav ul a:hover {
|
|||||||
|
|
||||||
header nav ul a[aria-current="page"] {
|
header nav ul a[aria-current="page"] {
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
text-shadow:
|
|
||||||
0 0 4px white,
|
|
||||||
0 0 8px white;
|
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-bottom: 2px solid currentColor;
|
border-bottom: 2px solid currentColor;
|
||||||
padding-bottom: 1px;
|
padding-bottom: 1px;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
.header-search-wrap {
|
.header-search-wrap {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
flex-shrink: 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%; }
|
.header-search-form { width: 100%; }
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 398;
|
font-weight: 398;
|
||||||
|
line-height: 23px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--space-xs) 0 var(--space-3xs) 0;
|
padding: var(--space-xs) 0 var(--space-3xs) 0;
|
||||||
border-bottom: 1px solid var(--text-primary);
|
border-bottom: 1px solid var(--text-primary);
|
||||||
@@ -150,7 +151,7 @@
|
|||||||
/* Years column — big numbers, semi-bold (BBBDMSans Medium weight) */
|
/* Years column — big numbers, semi-bold (BBBDMSans Medium weight) */
|
||||||
.repertoire-col[data-col="years"] .rep-entry {
|
.repertoire-col[data-col="years"] .rep-entry {
|
||||||
font-size: var(--step-3);
|
font-size: var(--step-3);
|
||||||
font-weight: 498;
|
font-weight: 300;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
padding: var(--space-3xs) 0;
|
padding: var(--space-3xs) 0;
|
||||||
|
|||||||
@@ -76,6 +76,25 @@
|
|||||||
font-style: italic;
|
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 {
|
.tfe-synopsis-empty {
|
||||||
/* placeholder to maintain grid column */
|
/* placeholder to maintain grid column */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -948,4 +948,110 @@
|
|||||||
bodyEl.innerHTML = '<p class="file-browser-error">Erreur réseau.</p>';
|
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>';
|
||||||
|
});
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -274,6 +274,9 @@ class ExportController
|
|||||||
'Licence',
|
'Licence',
|
||||||
'Points sur 20',
|
'Points sur 20',
|
||||||
'Lien BAIU',
|
'Lien BAIU',
|
||||||
|
'CC2r',
|
||||||
|
'Exemplaire BAIU',
|
||||||
|
'Exemplaire ERG',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -390,6 +393,9 @@ class ExportController
|
|||||||
$t['license_name'] ?? '',
|
$t['license_name'] ?? '',
|
||||||
isset($t['jury_points']) ? (string) $t['jury_points'] : '',
|
isset($t['jury_points']) ? (string) $t['jury_points'] : '',
|
||||||
$t['baiu_link'] ?? '',
|
$t['baiu_link'] ?? '',
|
||||||
|
!empty($t['cc2r']) ? 'Oui' : 'Non',
|
||||||
|
!empty($t['exemplaire_baiu']) ? 'Oui' : 'Non',
|
||||||
|
!empty($t['exemplaire_erg']) ? 'Oui' : 'Non',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -339,8 +339,13 @@ class ThesisCreateController
|
|||||||
if ($contactVisible === '') {
|
if ($contactVisible === '') {
|
||||||
$contactVisible = trim($post['mail'] ?? '');
|
$contactVisible = trim($post['mail'] ?? '');
|
||||||
}
|
}
|
||||||
// showContact: whether to show the contact publicly
|
// showContact: whether to show the contact publicly on the TFE page.
|
||||||
if (array_key_exists('contact_public', $post)) {
|
// 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']);
|
$showContact = !empty($post['contact_public']);
|
||||||
} else {
|
} else {
|
||||||
$showContact = $contactVisible !== '';
|
$showContact = $contactVisible !== '';
|
||||||
|
|||||||
@@ -249,6 +249,8 @@ class ThesisEditController
|
|||||||
// contact_interne = private email of the first author (backoffice field)
|
// contact_interne = private email of the first author (backoffice field)
|
||||||
$contactInterne = trim($post['contact_interne'] ?? '');
|
$contactInterne = trim($post['contact_interne'] ?? '');
|
||||||
$firstAuthorEmail = $contactInterne !== '' ? $contactInterne : null;
|
$firstAuthorEmail = $contactInterne !== '' ? $contactInterne : null;
|
||||||
|
// contact_public: whether to show the public contact on the TFE page
|
||||||
|
$showContact = !empty($post['contact_public']);
|
||||||
$authorNames = [];
|
$authorNames = [];
|
||||||
if ($authorsRaw !== '') {
|
if ($authorsRaw !== '') {
|
||||||
$authorNames = array_values(array_filter(array_map('trim', explode(',', $authorsRaw)), fn ($n) => $n !== ''));
|
$authorNames = array_values(array_filter(array_map('trim', explode(',', $authorsRaw)), fn ($n) => $n !== ''));
|
||||||
@@ -259,7 +261,7 @@ class ThesisEditController
|
|||||||
$authorEntries[] = [
|
$authorEntries[] = [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'email' => $i === 0 ? $firstAuthorEmail : null,
|
'email' => $i === 0 ? $firstAuthorEmail : null,
|
||||||
'show_contact' => $i === 0,
|
'show_contact' => $i === 0 && $showContact,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
$this->db->setThesisAuthors($thesisId, $authorEntries);
|
$this->db->setThesisAuthors($thesisId, $authorEntries);
|
||||||
|
|||||||
@@ -1297,9 +1297,10 @@ class Database
|
|||||||
if ($query === '') {
|
if ($query === '') {
|
||||||
$stmt = $this->pdo->query('
|
$stmt = $this->pdo->query('
|
||||||
SELECT tg.id, tg.name,
|
SELECT tg.id, tg.name,
|
||||||
COUNT(DISTINCT tt.thesis_id) as thesis_count
|
COUNT(DISTINCT t.id) as thesis_count
|
||||||
FROM tags tg
|
FROM tags tg
|
||||||
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
|
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
|
WHERE tg.deleted_at IS NULL
|
||||||
GROUP BY tg.id
|
GROUP BY tg.id
|
||||||
ORDER BY thesis_count DESC, tg.name COLLATE NOCASE
|
ORDER BY thesis_count DESC, tg.name COLLATE NOCASE
|
||||||
@@ -1308,9 +1309,10 @@ class Database
|
|||||||
} else {
|
} else {
|
||||||
$stmt = $this->pdo->prepare('
|
$stmt = $this->pdo->prepare('
|
||||||
SELECT tg.id, tg.name,
|
SELECT tg.id, tg.name,
|
||||||
COUNT(DISTINCT tt.thesis_id) as thesis_count
|
COUNT(DISTINCT t.id) as thesis_count
|
||||||
FROM tags tg
|
FROM tags tg
|
||||||
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
|
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
|
WHERE tg.name LIKE ? AND tg.deleted_at IS NULL
|
||||||
GROUP BY tg.id
|
GROUP BY tg.id
|
||||||
ORDER BY tg.name = ? DESC, thesis_count DESC, tg.name COLLATE NOCASE
|
ORDER BY tg.name = ? DESC, thesis_count DESC, tg.name COLLATE NOCASE
|
||||||
@@ -1328,9 +1330,10 @@ class Database
|
|||||||
{
|
{
|
||||||
$stmt = $this->pdo->query('
|
$stmt = $this->pdo->query('
|
||||||
SELECT tg.id, tg.name,
|
SELECT tg.id, tg.name,
|
||||||
COUNT(DISTINCT tt.thesis_id) as thesis_count
|
COUNT(DISTINCT t.id) as thesis_count
|
||||||
FROM tags tg
|
FROM tags tg
|
||||||
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
|
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
|
WHERE tg.deleted_at IS NULL
|
||||||
GROUP BY tg.id
|
GROUP BY tg.id
|
||||||
ORDER BY tg.name COLLATE NOCASE
|
ORDER BY tg.name COLLATE NOCASE
|
||||||
@@ -1416,9 +1419,10 @@ class Database
|
|||||||
$stmt = $this->pdo->query('
|
$stmt = $this->pdo->query('
|
||||||
SELECT MIN(l.id) as id,
|
SELECT MIN(l.id) as id,
|
||||||
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
|
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
|
FROM languages l
|
||||||
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
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
|
WHERE l.deleted_at IS NULL
|
||||||
GROUP BY LOWER(l.name)
|
GROUP BY LOWER(l.name)
|
||||||
ORDER BY thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE
|
ORDER BY thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE
|
||||||
@@ -1428,9 +1432,10 @@ class Database
|
|||||||
$stmt = $this->pdo->prepare('
|
$stmt = $this->pdo->prepare('
|
||||||
SELECT MIN(l.id) as id,
|
SELECT MIN(l.id) as id,
|
||||||
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
|
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
|
FROM languages l
|
||||||
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
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
|
WHERE LOWER(l.name) LIKE LOWER(?) AND l.deleted_at IS NULL
|
||||||
GROUP BY LOWER(l.name)
|
GROUP BY LOWER(l.name)
|
||||||
ORDER BY LOWER(MIN(l.name)) = LOWER(?) DESC, thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE
|
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('
|
$stmt = $this->pdo->query('
|
||||||
SELECT MIN(l.id) as id,
|
SELECT MIN(l.id) as id,
|
||||||
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
|
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
|
FROM languages l
|
||||||
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
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
|
WHERE l.deleted_at IS NULL
|
||||||
GROUP BY LOWER(l.name)
|
GROUP BY LOWER(l.name)
|
||||||
ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE
|
ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE
|
||||||
@@ -2683,7 +2689,10 @@ class Database
|
|||||||
t.context_note,
|
t.context_note,
|
||||||
t.remarks,
|
t.remarks,
|
||||||
t.jury_points,
|
t.jury_points,
|
||||||
t.baiu_link
|
t.baiu_link,
|
||||||
|
t.exemplaire_baiu,
|
||||||
|
t.exemplaire_erg,
|
||||||
|
t.cc2r
|
||||||
FROM theses t
|
FROM theses t
|
||||||
LEFT JOIN orientations o ON t.orientation_id = o.id
|
LEFT JOIN orientations o ON t.orientation_id = o.id
|
||||||
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
|
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
|
||||||
|
|||||||
@@ -24,6 +24,48 @@ class DatabaseMigrations
|
|||||||
$this->migrateRenameFinalityTypes();
|
$this->migrateRenameFinalityTypes();
|
||||||
$this->migrateShareLinksNameColumn();
|
$this->migrateShareLinksNameColumn();
|
||||||
$this->migrateShareLinksLockedYearColumn();
|
$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
|
* Spécialisé → Spécialisée
|
||||||
* Approfondi → Approfondie
|
* Approfondi → Approfondie
|
||||||
* Enseignement → Didactique
|
* Didactique → Enseignement
|
||||||
*/
|
*/
|
||||||
private function migrateRenameFinalityTypes(): void
|
private function migrateRenameFinalityTypes(): void
|
||||||
{
|
{
|
||||||
@@ -39,7 +81,7 @@ class DatabaseMigrations
|
|||||||
$renames = [
|
$renames = [
|
||||||
'Spécialisé' => 'Spécialisée',
|
'Spécialisé' => 'Spécialisée',
|
||||||
'Approfondi' => 'Approfondie',
|
'Approfondi' => 'Approfondie',
|
||||||
'Enseignement' => 'Didactique',
|
'Didactique' => 'Enseignement',
|
||||||
];
|
];
|
||||||
foreach ($renames as $old => $new) {
|
foreach ($renames as $old => $new) {
|
||||||
// Skip if only canonical row already exists
|
// Skip if only canonical row already exists
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class FormBootstrap
|
|||||||
$autofocusField = App::consumeAutofocus();
|
$autofocusField = App::consumeAutofocus();
|
||||||
|
|
||||||
// Controls
|
// Controls
|
||||||
$showContact = false;
|
$showContact = ($mode === 'add' || $mode === 'edit');
|
||||||
$showBackoffice = ($mode === 'add' || $mode === 'edit');
|
$showBackoffice = ($mode === 'add' || $mode === 'edit');
|
||||||
|
|
||||||
// Licence / access toggles: admin always enables all three
|
// Licence / access toggles: admin always enables all three
|
||||||
@@ -208,7 +208,7 @@ class FormBootstrap
|
|||||||
// Backoffice (empty for add, populated for edit by caller)
|
// Backoffice (empty for add, populated for edit by caller)
|
||||||
'currentRaw' => [],
|
'currentRaw' => [],
|
||||||
'contactInterne' => null,
|
'contactInterne' => null,
|
||||||
'contactPublic' => false,
|
'contactPublic' => null,
|
||||||
'currentContextNote' => null,
|
'currentContextNote' => null,
|
||||||
'currentContactVisible' => null,
|
'currentContactVisible' => null,
|
||||||
'currentDurationValue' => null,
|
'currentDurationValue' => null,
|
||||||
|
|||||||
@@ -265,6 +265,74 @@ class PeerTubeService
|
|||||||
return rtrim($s['instance_url'], '/') . '/videos/watch/' . $uuid;
|
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
|
// Delete
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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_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_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_thesis_tags_thesis ON thesis_tags(thesis_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tags_deleted_name ON tags(deleted_at, name);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- VIEWS
|
-- 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 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 ('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 finality_types (name) VALUES ('Spécialisée');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO languages (name) VALUES ('français');
|
INSERT OR IGNORE INTO languages (name) VALUES ('français');
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($tmpTotalCount > 0): ?>
|
<?php if ($tmpTotalCount > 0): ?>
|
||||||
<button type="button" class="btn btn--sm btn--secondary" id="tmp-cleanup-btn"
|
<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 ?>)
|
Nettoyer (<?= $tmpTotalCount ?>)
|
||||||
</button>
|
</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -97,289 +97,14 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
|
|||||||
<?php include APP_ROOT . '/templates/admin/index-table.php'; ?>
|
<?php include APP_ROOT . '/templates/admin/index-table.php'; ?>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════════════════
|
<?php include APP_ROOT . '/templates/admin/partials/dialogs/no-selection.php'; ?>
|
||||||
CONFIRM DIALOGS (replacing browser alert/confirm)
|
<?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 -->
|
<?php include APP_ROOT . '/templates/admin/partials/dialogs/import.php'; ?>
|
||||||
<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()">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="admin-dialog__alert">
|
|
||||||
<p>Sélectionnez au moins un TFE avant d'effectuer une action groupée.</p>
|
|
||||||
</div>
|
|
||||||
<div class="admin-dialog__footer">
|
|
||||||
<button type="button" class="btn btn--primary" onclick="this.closest('dialog').close()">OK</button>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<!-- Bulk publish/unpublish confirm -->
|
<?php include APP_ROOT . '/templates/admin/partials/dialogs/tmp-cleanup.php'; ?>
|
||||||
<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()">✕</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()">✕</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()">✕</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()">✕</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()">✕</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 if ($importMessage || !empty($importErrors)): ?>
|
<?php if ($importMessage || !empty($importErrors)): ?>
|
||||||
<script>document.getElementById('import-dialog').showModal();</script>
|
<script>document.getElementById('import-dialog').showModal();</script>
|
||||||
|
|||||||
14
app/templates/admin/partials/dialogs/bulk-confirm.php
Normal file
14
app/templates/admin/partials/dialogs/bulk-confirm.php
Normal 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()">✕</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>
|
||||||
14
app/templates/admin/partials/dialogs/bulk-delete.php
Normal file
14
app/templates/admin/partials/dialogs/bulk-delete.php
Normal 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()">✕</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>
|
||||||
14
app/templates/admin/partials/dialogs/delete-thesis.php
Normal file
14
app/templates/admin/partials/dialogs/delete-thesis.php
Normal 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()">✕</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>
|
||||||
78
app/templates/admin/partials/dialogs/import.php
Normal file
78
app/templates/admin/partials/dialogs/import.php
Normal 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()">✕</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>
|
||||||
13
app/templates/admin/partials/dialogs/no-selection.php
Normal file
13
app/templates/admin/partials/dialogs/no-selection.php
Normal 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()">✕</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>
|
||||||
29
app/templates/admin/partials/dialogs/tmp-cleanup.php
Normal file
29
app/templates/admin/partials/dialogs/tmp-cleanup.php
Normal 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()">✕</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>
|
||||||
@@ -100,7 +100,7 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
|
|||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
onclick="document.getElementById('relink-modal').showModal(); window.__xamxamRelinkCtx = { queueType: 'cover', thesisId: '<?= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>' };">
|
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>
|
</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +128,7 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
|
|||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
onclick="document.getElementById('relink-modal').showModal(); window.__xamxamRelinkCtx = { queueType: 'note_intention', thesisId: '<?= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>' };">
|
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>
|
</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,8 +166,20 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
|
|||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
onclick="document.getElementById('relink-modal').showModal(); window.__xamxamRelinkCtx = { queueType: 'tfe', thesisId: '<?= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>' };">
|
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>
|
</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; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,7 +207,7 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
|
|||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
onclick="document.getElementById('relink-modal').showModal(); window.__xamxamRelinkCtx = { queueType: 'annexe', thesisId: '<?= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>' };">
|
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>
|
</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,4 +231,22 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
|
|||||||
<!-- ═══════════════════ File Browser Modal (edit mode only) ═══════════════════ -->
|
<!-- ═══════════════════ File Browser Modal (edit mode only) ═══════════════════ -->
|
||||||
<?php if ($editMode): ?>
|
<?php if ($editMode): ?>
|
||||||
<?php include APP_ROOT . '/templates/partials/form/file-browser-fragment.php'; ?>
|
<?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; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
* bool $showContact — Contact checkbox fieldset
|
* bool $showContact — Contact checkbox fieldset
|
||||||
* bool $showCoverPreview — cover image preview + remove checkbox
|
* bool $showCoverPreview — cover image preview + remove checkbox
|
||||||
* bool $showExistingFiles — existing thesis files list (deletable)
|
* 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
|
* bool $showEmailConfirmation — E-mail de confirmation fieldset
|
||||||
|
|
||||||
* string $helpFn — fn(string $key): string (for help blocks)
|
* string $helpFn — fn(string $key): string (for help blocks)
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
* array $currentFiles — existing thesis files for edit mode
|
* array $currentFiles — existing thesis files for edit mode
|
||||||
* ?string $currentContextNote — existing context note for edit mode
|
* ?string $currentContextNote — existing context note for edit mode
|
||||||
* array $currentRaw — raw thesis row 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
|
* ?string $contactInterne — contact email for edit mode
|
||||||
*
|
*
|
||||||
* Autosave:
|
* Autosave:
|
||||||
@@ -455,7 +455,7 @@ if ($filesMode === 'add'): ?>
|
|||||||
</div>
|
</div>
|
||||||
<!-- Integer input for pages / Mo -->
|
<!-- Integer input for pages / Mo -->
|
||||||
<div id="duration-value-integer"<?= $_durUnit === 'durée' ? ' style="display:none"' : '' ?>>
|
<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"
|
<input type="number" id="duration_value_int"
|
||||||
value="<?= htmlspecialchars($_durUnit !== 'durée' ? (string)($_durFloat ?? '') : '') ?>"
|
value="<?= htmlspecialchars($_durUnit !== 'durée' ? (string)($_durFloat ?? '') : '') ?>"
|
||||||
step="1" min="0" placeholder="0"
|
step="1" min="0" placeholder="0"
|
||||||
@@ -648,10 +648,13 @@ if ($filesMode === 'add'): ?>
|
|||||||
var hidden = document.getElementById('duration_value');
|
var hidden = document.getElementById('duration_value');
|
||||||
var intWrap = document.getElementById('duration-value-integer');
|
var intWrap = document.getElementById('duration-value-integer');
|
||||||
var intInput = document.getElementById('duration_value_int');
|
var intInput = document.getElementById('duration_value_int');
|
||||||
|
var intLabel = document.getElementById('duration-value-label');
|
||||||
var timeWrap = document.getElementById('duration-value-time');
|
var timeWrap = document.getElementById('duration-value-time');
|
||||||
var hInput = document.getElementById('duration_h');
|
var hInput = document.getElementById('duration_h');
|
||||||
var mInput = document.getElementById('duration_m');
|
var mInput = document.getElementById('duration_m');
|
||||||
var sInput = document.getElementById('duration_s');
|
var sInput = document.getElementById('duration_s');
|
||||||
|
|
||||||
|
var LABELS = { pages: 'Nombre :', mo: 'Taille :', 'durée': 'Durée :' };
|
||||||
if (!unit || !hidden) return;
|
if (!unit || !hidden) return;
|
||||||
|
|
||||||
function updateHidden() {
|
function updateHidden() {
|
||||||
@@ -673,6 +676,7 @@ if ($filesMode === 'add'): ?>
|
|||||||
} else {
|
} else {
|
||||||
timeWrap.style.display = 'none';
|
timeWrap.style.display = 'none';
|
||||||
intWrap.style.display = '';
|
intWrap.style.display = '';
|
||||||
|
if (intLabel) intLabel.textContent = LABELS[unit.value] || 'Valeur :';
|
||||||
}
|
}
|
||||||
updateHidden();
|
updateHidden();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,14 +207,8 @@
|
|||||||
</p>
|
</p>
|
||||||
<?php endif; ?>
|
<?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">
|
<p class="tfe-meta-item">
|
||||||
<span class="tfe-meta-label">Contact :</span>
|
<span class="tfe-meta-label">Contact :</span>
|
||||||
<?php
|
<?php
|
||||||
@@ -271,13 +265,19 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!empty($data["synopsis"])): ?>
|
<div class="tfe-synopsis-column">
|
||||||
<div class="tfe-synopsis-text">
|
<?php if (!empty($data["context_note"])): ?>
|
||||||
<?= nl2br(htmlspecialchars($data["synopsis"])) ?>
|
<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>
|
</div>
|
||||||
<?php else: ?>
|
|
||||||
<div class="tfe-synopsis-text tfe-synopsis-empty"></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ROW 3: All files — flex container -->
|
<!-- ROW 3: All files — flex container -->
|
||||||
|
|||||||
8
justfile
8
justfile
@@ -76,6 +76,8 @@ deploy-code:
|
|||||||
--exclude 'storage/backups/' \
|
--exclude 'storage/backups/' \
|
||||||
--exclude 'storage/logs/' \
|
--exclude 'storage/logs/' \
|
||||||
--exclude 'var/' \
|
--exclude 'var/' \
|
||||||
|
--exclude 'composer.json' \
|
||||||
|
--exclude 'composer.lock' \
|
||||||
app/ xamxam:/var/www/xamxam/
|
app/ xamxam:/var/www/xamxam/
|
||||||
# Deploy nginx config + fix permissions + reload (single server-side run)
|
# Deploy nginx config + fix permissions + reload (single server-side run)
|
||||||
rsync -v nginx/xamxam.conf xamxam:/tmp/xamxam.conf
|
rsync -v nginx/xamxam.conf xamxam:/tmp/xamxam.conf
|
||||||
@@ -442,6 +444,12 @@ query:
|
|||||||
backup:
|
backup:
|
||||||
@sqlite3 app/storage/xamxam.db .dump > app/storage/backup_$(date +%Y%m%d_%H%M%S).sql
|
@sqlite3 app/storage/xamxam.db .dump > app/storage/backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
|
||||||
|
[group('database')]
|
||||||
|
fix-finality-types:
|
||||||
|
# Rename finality types from old forms to canonical names
|
||||||
|
# Approfondi → Approfondie, Didactique → Enseignement, Spécialisé → Spécialisée
|
||||||
|
@php scripts/fix-finality-types.php
|
||||||
|
|
||||||
[group('database')]
|
[group('database')]
|
||||||
backup-snapshot:
|
backup-snapshot:
|
||||||
# Hot backup using SQLite's .backup API (WAL-safe), then gzip.
|
# Hot backup using SQLite's .backup API (WAL-safe), then gzip.
|
||||||
|
|||||||
71
scripts/fix-finality-types.php
Executable file
71
scripts/fix-finality-types.php
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fix-finality-types.php — Rename finality types from old masculine/spelling to
|
||||||
|
* canonical feminine forms.
|
||||||
|
*
|
||||||
|
* Approfondi → Approfondie
|
||||||
|
* Didactique → Enseignement
|
||||||
|
* Spécialisé → Spécialisée
|
||||||
|
*
|
||||||
|
* Idempotent — safe to run multiple times.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php scripts/fix-finality-types.php [DB_PATH]
|
||||||
|
* Default: storage/xamxam.db
|
||||||
|
*/
|
||||||
|
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
|
||||||
|
// Detect layout: local dev has app/storage/, server has storage/ at root
|
||||||
|
if ($argc > 1) {
|
||||||
|
$dbPath = $argv[1];
|
||||||
|
} elseif (file_exists($root . '/app/storage/xamxam.db')) {
|
||||||
|
$dbPath = $root . '/app/storage/xamxam.db';
|
||||||
|
} elseif (file_exists($root . '/storage/xamxam.db')) {
|
||||||
|
$dbPath = $root . '/storage/xamxam.db';
|
||||||
|
} else {
|
||||||
|
die("Database not found. Pass path as argument.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file_exists($dbPath)) {
|
||||||
|
die("Database not found: $dbPath\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = new PDO('sqlite:' . $dbPath);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
$renames = [
|
||||||
|
'Approfondi' => 'Approfondie',
|
||||||
|
'Didactique' => 'Enseignement',
|
||||||
|
'Spécialisé' => 'Spécialisée',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($renames as $old => $new) {
|
||||||
|
// Check if old name exists
|
||||||
|
$oldId = $pdo->query("SELECT id FROM finality_types WHERE name = '$old'")->fetchColumn();
|
||||||
|
if (!$oldId) {
|
||||||
|
echo " [skip] '$old' not found\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create canonical row
|
||||||
|
$newId = $pdo->query("SELECT id FROM finality_types WHERE name = '$new'")->fetchColumn();
|
||||||
|
if (!$newId) {
|
||||||
|
$pdo->exec("INSERT INTO finality_types (name) VALUES ('$new')");
|
||||||
|
$newId = $pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relink theses from old to new
|
||||||
|
$updated = $pdo->exec("
|
||||||
|
UPDATE theses SET finality_id = $newId
|
||||||
|
WHERE finality_id = $oldId
|
||||||
|
");
|
||||||
|
echo " Relinked $updated thesis(es) from '$old' → '$new'\n";
|
||||||
|
|
||||||
|
// Delete old row
|
||||||
|
$pdo->exec("DELETE FROM finality_types WHERE id = $oldId");
|
||||||
|
echo " Deleted '$old' (id=$oldId)\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Done.\n";
|
||||||
Reference in New Issue
Block a user