mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
Error tests, FK violations fix
- ErrorHandler tests: 77 assertions covering FK extraction, normalization, dedup, edge cases. Fix FK table map for child tables. - Fix FK violation: (int)null → 0 in createThesis for orientation/ap/finality/license FK columns. Add FK value logging to updateThesis. - Add CURRENT_ISSUES.md with summary of FK violation, dev debugging, and tag dedup status for next conversation
This commit is contained in:
@@ -18,6 +18,7 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/SmtpRelay.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
$db = Database::getInstance();
|
||||
$logger = AdminLogger::make();
|
||||
@@ -59,7 +60,7 @@ try {
|
||||
$logger->logAccessRequest($requestId, 'approve', $request['email'], $request['title']);
|
||||
App::flash('success', "Demande approuvée. Email envoyé à {$request['email']}.");
|
||||
} catch (SmtpSendException $e) {
|
||||
error_log('[access-request] Email delivery failed after approval: ' . $e->getMessage());
|
||||
ErrorHandler::log('access_request_smtp', $e, ['request_id' => $requestId]);
|
||||
$logger->logAccessRequest($requestId, 'approve', $request['email'], $request['title']);
|
||||
$smtpMsg = $e->isRecipientRejected()
|
||||
? "Demande approuvée, mais l'email n'a pas pu être délivré : adresse inconnue ({$request['email']})."
|
||||
@@ -78,7 +79,7 @@ try {
|
||||
exit;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('Access request action failed: ' . $e->getMessage());
|
||||
ErrorHandler::log('access_request', $e);
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => 'Erreur lors du traitement']);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ if (!in_array($aproposKey, $allowedKeys)) {
|
||||
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||
require_once __DIR__ . '/../../../src/ErrorHandler.php';
|
||||
|
||||
try {
|
||||
$db = new Database();
|
||||
@@ -54,8 +55,8 @@ try {
|
||||
AdminLogger::make()->logAproposEdit($aproposKey);
|
||||
App::flash('success', "Contenu « $aproposKey » mis à jour avec succès.");
|
||||
} catch (Exception $e) {
|
||||
error_log("Apropos save error: " . $e->getMessage());
|
||||
die("Erreur lors de la sauvegarde : " . htmlspecialchars($e->getMessage()));
|
||||
ErrorHandler::log('apropos', $e);
|
||||
die('Erreur lors de la sauvegarde : ' . htmlspecialchars(ErrorHandler::userMessage($e)));
|
||||
}
|
||||
|
||||
header('Location: /admin/contenus.php');
|
||||
|
||||
@@ -6,6 +6,7 @@ AdminAuth::requireLogin();
|
||||
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||
require_once __DIR__ . '/../../../src/ErrorHandler.php';
|
||||
|
||||
// CSRF validation
|
||||
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
@@ -57,8 +58,8 @@ try {
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('delete.php error: ' . $e->getMessage());
|
||||
App::flash('error', 'Erreur lors de la suppression : ' . $e->getMessage());
|
||||
ErrorHandler::log('thesis_delete', $e);
|
||||
App::flash('error', 'Erreur lors de la suppression : ' . ErrorHandler::userMessage($e));
|
||||
}
|
||||
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
@@ -26,6 +26,7 @@ if ($thesisId <= 0) {
|
||||
|
||||
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
try {
|
||||
$ctrl = ThesisEditController::create();
|
||||
@@ -41,9 +42,8 @@ try {
|
||||
exit();
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Edit action error: " . $e->getMessage());
|
||||
|
||||
App::flash('error', $e->getMessage());
|
||||
ErrorHandler::log('thesis_edit', $e, ['thesis_id' => $thesisId]);
|
||||
App::flash('error', ErrorHandler::userMessage($e));
|
||||
|
||||
// WCAG 3.3.1 — map error message to field name for autofocus on re-render.
|
||||
$autofocusField = ThesisEditController::autofocusFieldForError($e->getMessage());
|
||||
|
||||
@@ -15,6 +15,7 @@ AdminAuth::requireLogin();
|
||||
|
||||
require_once APP_ROOT . '/src/Controllers/ExportController.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
try {
|
||||
$controller = ExportController::create();
|
||||
@@ -40,9 +41,9 @@ try {
|
||||
// Clean up temp file
|
||||
@unlink($zipPath);
|
||||
} catch (Exception $e) {
|
||||
error_log('Files export error: ' . $e->getMessage());
|
||||
ErrorHandler::log('export_files', $e);
|
||||
http_response_code(500);
|
||||
exit('Erreur lors de la création de l\'archive : ' . htmlspecialchars($e->getMessage()));
|
||||
exit('Erreur lors de la création de l\'archive : ' . htmlspecialchars(ErrorHandler::userMessage($e)));
|
||||
}
|
||||
|
||||
exit;
|
||||
|
||||
@@ -15,6 +15,7 @@ AdminAuth::requireLogin();
|
||||
|
||||
require_once APP_ROOT . '/src/Controllers/ExportController.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
$doCsv = !empty($_GET['csv']);
|
||||
$doFiles = !empty($_GET['files']);
|
||||
@@ -69,9 +70,9 @@ if ($doFiles) {
|
||||
readfile($zipPath);
|
||||
@unlink($zipPath);
|
||||
} catch (Exception $e) {
|
||||
error_log('Files export error: ' . $e->getMessage());
|
||||
ErrorHandler::log('export', $e);
|
||||
http_response_code(500);
|
||||
exit('Erreur lors de la création de l\'archive : ' . htmlspecialchars($e->getMessage()));
|
||||
exit('Erreur lors de la création de l\'archive : ' . htmlspecialchars(ErrorHandler::userMessage($e)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ $content = $_POST['content'] ?? '';
|
||||
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
$db = new Database();
|
||||
|
||||
if (!in_array($key, Database::FORM_HELP_KEYS, true)) {
|
||||
@@ -32,8 +33,8 @@ try {
|
||||
AdminLogger::make()->logFormStructureEdit($key);
|
||||
App::flash('success', 'Bloc « ' . htmlspecialchars($key) . ' » mis à jour.');
|
||||
} catch (Exception $e) {
|
||||
error_log('form-help save error: ' . $e->getMessage());
|
||||
App::flash('error', 'Erreur lors de la sauvegarde : ' . htmlspecialchars($e->getMessage()));
|
||||
ErrorHandler::log('form_help', $e);
|
||||
App::flash('error', 'Erreur lors de la sauvegarde : ' . ErrorHandler::userMessage($e));
|
||||
}
|
||||
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
@@ -26,6 +26,7 @@ require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
|
||||
require_once APP_ROOT . '/src/AppLogger.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/DuplicateThesisException.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
$logger = new AppLogger();
|
||||
$adminLogger = AdminLogger::make();
|
||||
@@ -47,8 +48,7 @@ try {
|
||||
|
||||
} catch (DuplicateThesisException $e) {
|
||||
$logger->logDuplicate('admin', $authorName, $e->existingThesisId, $e->existingIdentifier);
|
||||
|
||||
error_log('ThesisCreateController duplicate: ' . $e->getMessage());
|
||||
ErrorHandler::log('thesis_create_duplicate', $e, ['author' => $authorName]);
|
||||
|
||||
// Build a warning with a clickable link to the existing thesis.
|
||||
$existingUrl = htmlspecialchars('/admin/edit.php?id=' . $e->existingThesisId);
|
||||
@@ -68,10 +68,8 @@ try {
|
||||
'author' => $authorName,
|
||||
'post_keys' => array_keys($_POST),
|
||||
]);
|
||||
|
||||
error_log('ThesisCreateController error: ' . $e->getMessage());
|
||||
|
||||
App::flash('error', $e->getMessage());
|
||||
ErrorHandler::log('thesis_create', $e, ['author' => $authorName]);
|
||||
App::flash('error', ErrorHandler::userMessage($e));
|
||||
$_SESSION['form_data'] = $_POST;
|
||||
|
||||
$redirect = '../add.php';
|
||||
|
||||
@@ -25,6 +25,7 @@ if (!in_array($slug, $allowedSlugs, true)) {
|
||||
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
$db = new Database();
|
||||
|
||||
try {
|
||||
@@ -32,8 +33,8 @@ try {
|
||||
AdminLogger::make()->logPageEdit($slug);
|
||||
App::flash('success', 'Page « ' . htmlspecialchars($slug) . ' » mise à jour.');
|
||||
} catch (Exception $e) {
|
||||
error_log('page save error: ' . $e->getMessage());
|
||||
App::flash('error', 'Erreur lors de la sauvegarde : ' . htmlspecialchars($e->getMessage()));
|
||||
ErrorHandler::log('page', $e);
|
||||
App::flash('error', 'Erreur lors de la sauvegarde : ' . ErrorHandler::userMessage($e));
|
||||
}
|
||||
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
@@ -6,6 +6,7 @@ AdminAuth::requireLogin();
|
||||
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||
require_once __DIR__ . '/../../../src/ErrorHandler.php';
|
||||
|
||||
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
||||
@@ -61,8 +62,8 @@ try {
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('publish.php error: ' . $e->getMessage());
|
||||
App::flash('error', 'Erreur lors de la modification : ' . $e->getMessage());
|
||||
ErrorHandler::log('thesis_publish', $e);
|
||||
App::flash('error', 'Erreur lors de la modification : ' . ErrorHandler::userMessage($e));
|
||||
}
|
||||
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
@@ -12,6 +12,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|
||||
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||
require_once __DIR__ . '/../../../src/ErrorHandler.php';
|
||||
|
||||
try {
|
||||
$db = new Database();
|
||||
@@ -48,7 +49,8 @@ try {
|
||||
|
||||
App::flash('success', "Opération effectuée.");
|
||||
} catch (Exception $e) {
|
||||
App::flash('error', $e->getMessage());
|
||||
ErrorHandler::log('tag', $e);
|
||||
App::flash('error', ErrorHandler::userMessage($e));
|
||||
}
|
||||
|
||||
header('Location: /admin/tags.php');
|
||||
|
||||
@@ -12,6 +12,7 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||
require_once __DIR__ . '/../../../src/ErrorHandler.php';
|
||||
|
||||
$action = $_POST['action'] ?? ''; // 'set_visibility'
|
||||
$accessTypeId = filter_var($_POST['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
|
||||
@@ -51,8 +52,8 @@ try {
|
||||
App::flash('success', "Visibilité mise à jour.");
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("visibility.php error: " . $e->getMessage());
|
||||
App::flash('error', "Erreur : " . $e->getMessage());
|
||||
ErrorHandler::log('visibility', $e);
|
||||
App::flash('error', 'Erreur : ' . ErrorHandler::userMessage($e));
|
||||
}
|
||||
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
@@ -39,7 +39,9 @@ function withAutofocus(string $fieldName, array $attrs = []): array {
|
||||
|
||||
function old($key, $default = "") {
|
||||
global $formData;
|
||||
return isset($formData[$key]) ? htmlspecialchars($formData[$key]) : $default;
|
||||
if (!isset($formData[$key])) return $default;
|
||||
if (is_array($formData[$key])) return $formData[$key]; // Return raw array for callers that handle it
|
||||
return htmlspecialchars($formData[$key]);
|
||||
}
|
||||
|
||||
function wasSelected($key, $value) {
|
||||
|
||||
@@ -23,7 +23,9 @@ $helpFn = fn(string $key) => empty($helpBlocks[$key]['enabled']) ? '' : ($helpBl
|
||||
|
||||
function old($key, $default = "") {
|
||||
global $formData;
|
||||
return isset($formData[$key]) ? htmlspecialchars($formData[$key]) : $default;
|
||||
if (!isset($formData[$key])) return $default;
|
||||
if (is_array($formData[$key])) return $formData[$key]; // Return raw array for callers that handle it
|
||||
return htmlspecialchars($formData[$key]);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -330,13 +330,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
}
|
||||
}
|
||||
if (!empty($keywordsRaw)) {
|
||||
foreach (array_slice(array_map('trim', explode(',', $keywordsRaw)), 0, 10) as $kw) {
|
||||
if ($kw) {
|
||||
$tId = $importDb->findOrCreateTag($kw);
|
||||
if ($tId) {
|
||||
$s = $importPdo->prepare("INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)");
|
||||
$s->execute([$thesisId, $tId]);
|
||||
}
|
||||
$normalizeTag = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
|
||||
$tags = array_values(array_unique(array_map($normalizeTag, explode(',', $keywordsRaw))));
|
||||
$tags = array_filter($tags, fn($t) => $t !== '');
|
||||
foreach (array_slice($tags, 0, 10) as $kw) {
|
||||
$tId = $importDb->findOrCreateTag($kw);
|
||||
if ($tId) {
|
||||
$s = $importPdo->prepare("INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)");
|
||||
$s->execute([$thesisId, $tId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
app/public/admin/tag-search-fragment.php
Normal file
14
app/public/admin/tag-search-fragment.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
/**
|
||||
* tag-search-fragment.php (admin)
|
||||
*
|
||||
* HTMX fragment: returns matching tag suggestions for the mot-clé
|
||||
* interactive search input. Admin-auth gated wrapper.
|
||||
*/
|
||||
require_once __DIR__ . '/../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../src/AdminAuth.php';
|
||||
|
||||
AdminAuth::requireLogin();
|
||||
App::boot();
|
||||
|
||||
require_once APP_ROOT . '/public/partage/tag-search-fragment.php';
|
||||
@@ -1006,3 +1006,216 @@ a.recap-file-name:hover {
|
||||
.btn-secondary {
|
||||
/* deprecated alias for .btn--secondary; kept for backward-compat */
|
||||
}
|
||||
|
||||
/* ── Tag search (mots-clés interactive component) ──────────────────────── */
|
||||
|
||||
/* The outer container may be placed inside a fieldset or as a direct child
|
||||
of .admin-form. Use a generic vertical layout that works in both contexts. */
|
||||
[id$="-search-container"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
|
||||
[id$="-search-container"] > .admin-row-label {
|
||||
font-size: var(--step--1);
|
||||
padding-top: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tag-search-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xs);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Hint text above input */
|
||||
.tag-search-hint {
|
||||
display: block;
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-3xs);
|
||||
}
|
||||
|
||||
/* Counter (e.g. "3/10") */
|
||||
.tag-search-counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs);
|
||||
font-size: var(--step--2);
|
||||
}
|
||||
|
||||
.tag-search-count {
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
background: var(--accent-muted, rgba(149, 87, 181, 0.1));
|
||||
padding: 1px var(--space-2xs);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.tag-search-max-msg {
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Pill row — spacing around selected tags area */
|
||||
.tag-search-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2xs);
|
||||
min-height: 0;
|
||||
padding: var(--space-2xs) 0;
|
||||
}
|
||||
|
||||
.tag-search-pills:empty {
|
||||
padding: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Individual pill */
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3xs);
|
||||
padding: 2px 2px 2px var(--space-2xs);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 999px;
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.tag-pill:hover {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tag-pill-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Round remove button */
|
||||
.tag-pill-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.tag-pill-remove:hover {
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tag-pill-remove svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Search input */
|
||||
.tag-search-input-wrap {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tag-search-input {
|
||||
width: 100%;
|
||||
font-size: var(--step--1);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Suggestions dropdown — absolute positioned to hover above content */
|
||||
.tag-search-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tag-search-suggestions:not(:empty) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tag-search-empty {
|
||||
padding: var(--space-2xs) var(--space-xs);
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Individual suggestion item */
|
||||
.tag-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs);
|
||||
width: 100%;
|
||||
padding: var(--space-2xs) var(--space-xs);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-family: inherit;
|
||||
font-size: var(--step--1);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.tag-search-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tag-search-item:hover,
|
||||
.tag-search-item:focus,
|
||||
.tag-search-item--highlight {
|
||||
background: var(--bg-secondary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tag-search-item--highlight {
|
||||
background: var(--accent-muted, rgba(149, 87, 181, 0.1));
|
||||
}
|
||||
|
||||
.tag-search-item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tag-search-item-count {
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag-search-item--create {
|
||||
color: var(--accent-primary);
|
||||
font-weight: 500;
|
||||
border-bottom-style: dashed;
|
||||
}
|
||||
|
||||
.tag-search-item--create:hover,
|
||||
.tag-search-item--create.tag-search-item--highlight {
|
||||
background: var(--accent-muted, rgba(149, 87, 181, 0.08));
|
||||
}
|
||||
|
||||
@@ -49,6 +49,13 @@ if ($slug === 'licence-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Special route: /partage/tag-search-fragment (HTMX fragment — interactive tag search)
|
||||
if ($slug === 'tag-search-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
App::boot();
|
||||
require_once __DIR__ . '/tag-search-fragment.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Special route: /partage/recapitulatif?id=N
|
||||
if ($slug === 'recapitulatif' || $slug === 'recapitulatif.php') {
|
||||
App::boot();
|
||||
@@ -440,6 +447,7 @@ function handleShareLinkSubmission(string $slug): void
|
||||
require_once APP_ROOT . '/src/StudentEmail.php';
|
||||
require_once APP_ROOT . '/src/AppLogger.php';
|
||||
require_once APP_ROOT . '/src/DuplicateThesisException.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
$logger = new AppLogger();
|
||||
$authorName = $_POST['auteurice'] ?? 'unknown';
|
||||
@@ -467,6 +475,7 @@ function handleShareLinkSubmission(string $slug): void
|
||||
$emailSent = StudentEmail::sendConfirmation(Database::getInstance(), $thesisId, $_POST);
|
||||
$_SESSION['share_email_sent'] = $emailSent;
|
||||
} catch (SmtpSendException $e) {
|
||||
ErrorHandler::log('partage_smtp', $e, ['slug' => $slug, 'thesis_id' => $thesisId]);
|
||||
if ($e->isRecipientRejected()) {
|
||||
$_SESSION['share_email_retry_thesis'] = $thesisId;
|
||||
$_SESSION['share_email_retry_error'] = $e->smtpResponse;
|
||||
@@ -485,8 +494,7 @@ function handleShareLinkSubmission(string $slug): void
|
||||
$logger->logDuplicate('partage', $authorName, $e->existingThesisId, $e->existingIdentifier, [
|
||||
'share_slug' => $slug,
|
||||
]);
|
||||
|
||||
error_log('Share link duplicate submission: ' . $e->getMessage());
|
||||
ErrorHandler::log('partage_duplicate', $e, ['slug' => $slug, 'author' => $authorName]);
|
||||
|
||||
// Repopulate the form and surface a clear warning to the student.
|
||||
// Store as plain text — htmlspecialchars() is applied at render time.
|
||||
@@ -505,10 +513,9 @@ function handleShareLinkSubmission(string $slug): void
|
||||
'author' => $authorName,
|
||||
'post_keys' => array_keys($_POST),
|
||||
]);
|
||||
ErrorHandler::log('partage_submit', $e, ['slug' => $slug, 'author' => $authorName]);
|
||||
|
||||
error_log('Share link submission error: ' . $e->getMessage());
|
||||
|
||||
$_SESSION['_flash_error'] = $e->getMessage();
|
||||
$_SESSION['_flash_error'] = ErrorHandler::userMessage($e);
|
||||
$_SESSION['form_data_share_' . $slug] = $_POST;
|
||||
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token
|
||||
|
||||
|
||||
67
app/public/partage/tag-search-fragment.php
Normal file
67
app/public/partage/tag-search-fragment.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/**
|
||||
* tag-search-fragment.php
|
||||
*
|
||||
* Shared HTMX fragment: returns matching tag suggestions for the mot-clé
|
||||
* interactive search input.
|
||||
*
|
||||
* Included by:
|
||||
* - /admin/tag-search-fragment.php (AdminAuth gated)
|
||||
* - partage/index.php special route (public, session already booted)
|
||||
*
|
||||
* Expected POST:
|
||||
* q — search query string (partial tag name)
|
||||
* tag[] — already selected tag names (sent via hx-include, to exclude from suggestions)
|
||||
*/
|
||||
require_once __DIR__ . '/../../src/Database.php';
|
||||
|
||||
$query = trim(preg_replace('/\s+/', ' ', strtolower($_POST['q'] ?? '')));
|
||||
$currentTags = isset($_POST['tag']) && is_array($_POST['tag'])
|
||||
? array_map(function($t) { return trim(preg_replace('/\s+/', ' ', strtolower($t))); }, $_POST['tag'])
|
||||
: [];
|
||||
|
||||
$db = Database::getInstance();
|
||||
$results = $db->searchTags($query);
|
||||
|
||||
// Deduplicate results by lowercase name
|
||||
$seen = [];
|
||||
$results = array_values(array_filter($results, function($tag) use (&$seen) {
|
||||
$key = strtolower($tag['name']);
|
||||
if (isset($seen[$key])) return false;
|
||||
$seen[$key] = true;
|
||||
return true;
|
||||
}));
|
||||
|
||||
// Filter out already-selected tags (case-insensitive)
|
||||
$results = array_values(array_filter($results, function($tag) use ($currentTags) {
|
||||
return !in_array(strtolower($tag['name']), $currentTags, true);
|
||||
}));
|
||||
|
||||
// Check if query exactly matches an existing tag (case-insensitive)
|
||||
$exactExists = false;
|
||||
foreach ($results as $tag) {
|
||||
if (strcasecmp($tag['name'], $query) === 0) {
|
||||
$exactExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match and query non-empty, suggest creation
|
||||
$canCreate = ($query !== '' && !$exactExists && !in_array($query, $currentTags, true));
|
||||
?>
|
||||
<?php if (empty($results) && !$canCreate): ?>
|
||||
<div class="tag-search-empty">Aucun mot-clé trouvé.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($results as $tag): ?>
|
||||
<button type="button" class="tag-search-item" data-tag-id="<?= (int)$tag['id'] ?>" data-tag-name="<?= htmlspecialchars($tag['name']) ?>">
|
||||
<span class="tag-search-item-name"><?= htmlspecialchars($tag['name']) ?></span>
|
||||
<span class="tag-search-item-count">(<?= (int)$tag['thesis_count'] ?>)</span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($canCreate): ?>
|
||||
<button type="button" class="tag-search-item tag-search-item--create" data-tag-name="<?= htmlspecialchars($query) ?>">
|
||||
<span class="tag-search-item-name">Créer « <?= htmlspecialchars($query) ?> »</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
Reference in New Issue
Block a user