72cb7acae4
- ArticleManager : invalider le cache si index.md est plus récent que meta.json - migration_001 : touch(meta.json) après maj index.md pour forcer l'invalidation - post_view.php : masquer le H1 initial du contenu (déjà affiché par le template) - step1.php : en-tête "Modifier" sans le titre de l'article - wizard.js : retirer scrollToCursor (erroné sur auto-resize) ; Ctrl+Home/End via scrollIntoView Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
357 lines
16 KiB
PHP
357 lines
16 KiB
PHP
<?php
|
||
require_once __DIR__ . '/../src/Parsedown.php';
|
||
$Parsedown = new Parsedown();
|
||
|
||
$_accentMap = [
|
||
'à' => 'a','â' => 'a','ä' => 'a','á' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e',
|
||
'î' => 'i','ï' => 'i','í' => 'i','ô' => 'o','ö' => 'o','ó' => 'o','ù' => 'u','û' => 'u',
|
||
'ü' => 'u','ú' => 'u','ç' => 'c','ñ' => 'n','æ' => 'ae','œ' => 'oe',
|
||
];
|
||
$_tocItems = [];
|
||
$_tocSeen = [];
|
||
// Le titre H1 est déjà affiché par le template ; on le retire du rendu.
|
||
$_rawForRender = preg_replace('/^\s*# [^\n]*\n*/u', '', $rawContent);
|
||
$_renderedContent = preg_replace_callback(
|
||
'/<(h[23])>(.+?)<\/h[23]>/i',
|
||
function ($m) use (&$_tocItems, &$_tocSeen, $_accentMap) {
|
||
$tag = $m[1];
|
||
$inner = $m[2];
|
||
$level = (int) substr($tag, 1);
|
||
$plain = strip_tags($inner);
|
||
$slug = trim(preg_replace(
|
||
'/[^a-z0-9]+/',
|
||
'-',
|
||
mb_strtolower(strtr($plain, $_accentMap), 'UTF-8')
|
||
), '-') ?: 'section';
|
||
if (isset($_tocSeen[$slug])) {
|
||
$_tocSeen[$slug]++;
|
||
$id = $slug . '-' . $_tocSeen[$slug];
|
||
} else {
|
||
$_tocSeen[$slug] = 0;
|
||
$id = $slug;
|
||
}
|
||
$_tocItems[] = ['level' => $level, 'text' => $plain, 'id' => $id];
|
||
return "<{$tag} id=\"" . htmlspecialchars($id) . "\">{$inner}</{$tag}>";
|
||
},
|
||
$Parsedown->text($_rawForRender)
|
||
);
|
||
|
||
ob_start();
|
||
|
||
$coverFile = $article['cover'] ?? '';
|
||
$ogImage = $coverFile !== ''
|
||
? url('file?uuid=' . rawurlencode($article['uuid']) . '&name=' . rawurlencode($coverFile))
|
||
: null;
|
||
|
||
$category = trim((string)($article['category'] ?? ''));
|
||
$gradient = coverGradient($category !== '' ? $category : $article['uuid'], $allCats ?? []);
|
||
|
||
// Pièces jointes (hors fichiers intégrés, thumbs et cover)
|
||
$attachments = [];
|
||
if ($files) {
|
||
$referenced = [];
|
||
preg_match_all('/\(\/file\?uuid=[^&]+&name=([^)]+)\)/', $rawContent, $m);
|
||
foreach ($m[1] as $encodedName) {
|
||
$referenced[rawurldecode($encodedName)] = true;
|
||
}
|
||
$attachments = array_values(array_filter(
|
||
$files,
|
||
static fn ($f) =>
|
||
!isset($referenced[$f['name']])
|
||
&& !str_starts_with($f['name'], '_thumb_')
|
||
&& $f['name'] !== $coverFile
|
||
));
|
||
}
|
||
|
||
$externalLinks = $article['external_links'] ?? [];
|
||
?>
|
||
<div class="row g-4 align-items-start flex-lg-nowrap">
|
||
|
||
<!-- Colonne principale -->
|
||
<div class="col">
|
||
|
||
<div class="card mb-4">
|
||
<?php if (!$article['published']): ?>
|
||
<div class="draft-ribbon">Brouillon</div>
|
||
<?php elseif ($isPrivateCat ?? false): ?>
|
||
<div class="private-ribbon">Privé</div>
|
||
<?php endif; ?>
|
||
<?php
|
||
$authorEmail = $article['author'] ?? '';
|
||
$authorName = ($authorEmail !== '' && function_exists('authorDisplayName')) ? authorDisplayName($authorEmail) : '';
|
||
$authorProfileUrl = ($authorEmail !== '' && function_exists('authorProfileUrl')) ? authorProfileUrl($authorEmail) : '';
|
||
$authorSlugVal = ($authorEmail !== '' && function_exists('authorSlug')) ? authorSlug($authorEmail) : '';
|
||
$pubDate = htmlspecialchars(date('d/m/Y', strtotime((string)($article['published_at'] ?? $article['created_at'] ?? ''))));
|
||
$hasCover = $coverFile !== '';
|
||
$heroExtraClass = $hasCover ? '' : ' article-cover--gradient';
|
||
$heroStyle = $hasCover ? '' : ' style="background:' . htmlspecialchars($gradient) . '"';
|
||
$hasSources = (!empty($externalLinks) || !empty($files))
|
||
&& function_exists('canDoOnArticle') && canDoOnArticle('view_sources', $article);
|
||
?>
|
||
<div class="article-cover article-cover--hero<?= $heroExtraClass ?>"<?= $heroStyle ?>>
|
||
<?php if ($hasCover): ?>
|
||
<img src="/file?uuid=<?= rawurlencode($article['uuid']) ?>&name=<?= rawurlencode($coverFile) ?>"
|
||
alt="<?= htmlspecialchars($article['title']) ?>">
|
||
<?php endif; ?>
|
||
<div class="article-hero-text">
|
||
|
||
<!-- Haut : retour + actions admin -->
|
||
<div class="article-hero-top">
|
||
<a href="/" class="hero-btn">← Retour</a>
|
||
<?php if (function_exists('isAdmin') && isAdmin()): ?>
|
||
<a href="/edit/<?= rawurlencode($article['uuid']) ?>" class="hero-btn ms-auto">✎ Modifier</a>
|
||
<a href="/delete/<?= rawurlencode($article['uuid']) ?>"
|
||
class="hero-btn hero-btn--danger"
|
||
data-confirm="Supprimer cet article définitivement ?">🗑 Supprimer</a>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<!-- Bas : titre + actions secondaires -->
|
||
<div class="article-hero-bottom">
|
||
<div class="article-hero-left">
|
||
<?php if ($category !== ''): ?>
|
||
<span class="cover-category"><?= htmlspecialchars($category) ?></span>
|
||
<?php endif; ?>
|
||
<h1 class="article-title"><?= htmlspecialchars($article['title']) ?></h1>
|
||
<p class="article-hero-meta">
|
||
<?php if ($authorName !== ''): ?>
|
||
<?php if ($authorSlugVal !== ''): ?>
|
||
<a href="/profil/<?= rawurlencode($authorSlugVal) ?>" class="text-reset"><?= htmlspecialchars($authorName) ?></a>
|
||
<?php else: ?>
|
||
<span><?= htmlspecialchars($authorName) ?></span>
|
||
<?php endif; ?>
|
||
<span class="mx-1 opacity-50">·</span>
|
||
<?php endif; ?>
|
||
<?= $pubDate ?>
|
||
</p>
|
||
</div>
|
||
<div class="article-hero-right">
|
||
<?php if ($hasSources): ?>
|
||
<a href="/sources/<?= rawurlencode($article['uuid']) ?>" class="hero-btn">ℹ Sources</a>
|
||
<?php endif; ?>
|
||
<?php
|
||
$_heroReactionDefs = [
|
||
'useful' => ['👍', 'Utile'],
|
||
'important' => ['🔥', 'Important'],
|
||
'interesting' => ['🤔', 'À creuser'],
|
||
];
|
||
?>
|
||
<div class="hero-reactions" id="reactions">
|
||
<?php foreach ($_heroReactionDefs as $type => [$icon, $label]): ?>
|
||
<?php $active = in_array($type, $visitorReactions ?? [], true); ?>
|
||
<form method="post" action="/react" class="reaction-form d-inline">
|
||
<input type="hidden" name="uuid" value="<?= htmlspecialchars($article['uuid']) ?>">
|
||
<input type="hidden" name="type" value="<?= htmlspecialchars($type) ?>">
|
||
<input type="hidden" name="_back" value="/post/<?= rawurlencode($article['slug'] ?? '') ?>#reactions">
|
||
<button type="submit"
|
||
class="hero-reaction-btn<?= $active ? ' hero-reaction-btn--active' : '' ?> reaction-btn"
|
||
data-type="<?= htmlspecialchars($type) ?>"
|
||
data-uuid="<?= htmlspecialchars($article['uuid']) ?>"
|
||
title="<?= htmlspecialchars($label) ?>">
|
||
<span><?= $icon ?></span>
|
||
<span class="reaction-count" data-type="<?= htmlspecialchars($type) ?>"><?= (int)($reactionStats[$type] ?? 0) ?></span>
|
||
</button>
|
||
</form>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="card-text post-content">
|
||
<?= $_renderedContent ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<?php include __DIR__ . '/comments_section.php'; ?>
|
||
|
||
</div><!-- /col principale -->
|
||
|
||
<div class="post-sidebar-col order-3">
|
||
<aside class="related-sidebar">
|
||
|
||
<?php if (count($_tocItems) >= 3): ?>
|
||
<h6 class="related-sidebar-title">Table des matières</h6>
|
||
<ul class="toc-list">
|
||
<?php foreach ($_tocItems as $_ti): ?>
|
||
<li class="toc-h<?= (int) $_ti['level'] ?>">
|
||
<a href="#<?= htmlspecialchars($_ti['id']) ?>"><?= htmlspecialchars($_ti['text']) ?></a>
|
||
</li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
<?php endif; ?>
|
||
|
||
<div class="toc-nav">
|
||
<button class="toc-nav-btn" id="toc-go-top">↑ Haut</button>
|
||
<button class="toc-nav-btn" id="toc-go-bottom">↓ Bas</button>
|
||
</div>
|
||
<ul class="toc-list mt-2">
|
||
<?php if (!empty($alsoReadArticles ?? [])): ?>
|
||
<li><a href="#also-read">À lire aussi</a></li>
|
||
<?php endif; ?>
|
||
<li><a href="#reactions">Réactions</a></li>
|
||
<li><a href="#comments">Commentaires</a></li>
|
||
</ul>
|
||
|
||
<?php if (!empty($backlinks ?? [])): ?>
|
||
<h6 class="related-sidebar-title">Rétroliens</h6>
|
||
<?php foreach ($backlinks as $_bl):
|
||
$_blCover = $_bl['cover'] ?? '';
|
||
$_blCat = trim($_bl['category'] ?? '');
|
||
$_blGradient = coverGradient($_blCat !== '' ? $_blCat : $_bl['uuid'], $allCats ?? []);
|
||
$_blDate = date('d/m/Y', strtotime((string)($_bl['published_at'] ?? $_bl['created_at'] ?? '')));
|
||
?>
|
||
<a href="/post/<?= rawurlencode($_bl['slug'] ?? '') ?>" class="related-card">
|
||
<div class="related-card-thumb" style="<?= $_blCover !== ''
|
||
? 'background-image:url(/file?uuid=' . rawurlencode($_bl['uuid']) . '&name=' . rawurlencode($_blCover) . ');background-size:cover;background-position:center'
|
||
: 'background:' . htmlspecialchars($_blGradient) ?>">
|
||
</div>
|
||
<div class="related-card-body">
|
||
<div class="related-card-title"><?= htmlspecialchars($_bl['title']) ?></div>
|
||
<div class="related-card-date"><?= $_blDate ?></div>
|
||
</div>
|
||
</a>
|
||
<?php endforeach; ?>
|
||
<?php endif; ?>
|
||
|
||
<?php if (!empty($attachments)): ?>
|
||
<?php $filesMeta = $article['files_meta'] ?? []; ?>
|
||
<h6 class="related-sidebar-title">Pièces jointes</h6>
|
||
<div class="d-flex flex-column gap-2 mb-4">
|
||
<?php foreach ($attachments as $file):
|
||
$fileUrl = '/file?uuid=' . rawurlencode($article['uuid']) . '&name=' . rawurlencode($file['name']);
|
||
$fmeta = $filesMeta[$file['name']] ?? [];
|
||
$fAuthor = trim($fmeta['author'] ?? '');
|
||
$fSource = trim($fmeta['source_url'] ?? '');
|
||
?>
|
||
<a href="<?= htmlspecialchars($fileUrl) ?>" target="_blank" rel="noopener" class="source-card">
|
||
<?php if ($file['is_image']): ?>
|
||
<div class="source-card-thumb" style="background-image:url(<?= htmlspecialchars($fileUrl) ?>);background-size:cover;background-position:center"></div>
|
||
<?php elseif ($file['is_video']): ?>
|
||
<div class="source-card-thumb source-card-thumb--link">▶</div>
|
||
<?php elseif ($file['is_audio']): ?>
|
||
<div class="source-card-thumb source-card-thumb--link">♪</div>
|
||
<?php else: ?>
|
||
<div class="source-card-thumb source-card-thumb--pdf">📎</div>
|
||
<?php endif; ?>
|
||
<div class="source-card-body">
|
||
<div class="source-card-title"><?= htmlspecialchars($file['name']) ?></div>
|
||
<div class="source-card-meta">
|
||
<?= htmlspecialchars(number_format($file['size'] / 1024, 1)) ?> Ko
|
||
<?php if ($fAuthor !== ''): ?>
|
||
· <?= htmlspecialchars($fAuthor) ?>
|
||
<?php endif; ?>
|
||
<?php if ($fSource !== ''): ?>
|
||
· <?= htmlspecialchars(parse_url($fSource, PHP_URL_HOST) ?: $fSource) ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if (!empty($externalLinks)): ?>
|
||
<h6 class="related-sidebar-title">Liens & sources</h6>
|
||
<div class="d-flex flex-column gap-2 mb-4">
|
||
<?php foreach ($externalLinks as $lnk):
|
||
$lMeta = $lnk['meta'] ?? [];
|
||
$lTitle = $lnk['name'] ?? '';
|
||
$lUrl = $lnk['url'] ?? '';
|
||
$lHost = parse_url($lUrl, PHP_URL_HOST) ?? $lUrl;
|
||
$lDate = $lMeta['date'] ?? '';
|
||
$lSite = $lMeta['site_name'] ?? $lHost;
|
||
$lImage = $lMeta['og_image'] ?? '';
|
||
$lMime = $lMeta['mime'] ?? 'text/html';
|
||
$lPages = $lMeta['pages'] ?? null;
|
||
$lFormat = $lMeta['page_size'] ?? '';
|
||
$isPdf = ($lMime === 'application/pdf');
|
||
?>
|
||
<a href="<?= htmlspecialchars($lUrl) ?>" target="_blank" rel="noopener" class="source-card">
|
||
<?php if ($lImage && str_starts_with($lImage, '/')): ?>
|
||
<div class="source-card-thumb" style="background-image:url(<?= htmlspecialchars($lImage) ?>);background-size:cover;background-position:center"></div>
|
||
<?php elseif ($isPdf): ?>
|
||
<div class="source-card-thumb source-card-thumb--pdf">📑</div>
|
||
<?php else: ?>
|
||
<div class="source-card-thumb source-card-thumb--link">↗</div>
|
||
<?php endif; ?>
|
||
<div class="source-card-body">
|
||
<div class="source-card-title"><?= htmlspecialchars($lTitle) ?></div>
|
||
<div class="source-card-meta">
|
||
<?= htmlspecialchars($lSite) ?>
|
||
<?php if ($lDate): ?> · <?= htmlspecialchars(substr($lDate, 0, 10)) ?><?php endif; ?>
|
||
<?php if ($isPdf && $lPages): ?> · PDF <?= $lPages ?>p.<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
|
||
</aside>
|
||
</div>
|
||
|
||
</div><!-- /row -->
|
||
|
||
<script src="/assets/js/toc.js" defer></script>
|
||
|
||
<?php
|
||
$content = ob_get_clean();
|
||
$title = htmlspecialchars($article['title']);
|
||
$seoTitle = ($article['seo_title'] ?? '') ?: $article['title'];
|
||
$ogType = 'article';
|
||
$ogUrl = url('post/' . rawurlencode($article['slug'] ?? ''));
|
||
$canonical = $ogUrl;
|
||
$articlePublishedAt = $article['published_at'] ?? '';
|
||
$mainClass = 'container-fluid';
|
||
|
||
// Auto-description depuis le contenu si le champ SEO est vide
|
||
$seoDescription = $article['seo_description'] ?? '';
|
||
if ($seoDescription === '') {
|
||
$plain = strip_tags($Parsedown->text($article['content']));
|
||
$plain = preg_replace('/\s+/', ' ', $plain);
|
||
$seoDescription = mb_strimwidth(trim((string)$plain), 0, 155, '…');
|
||
}
|
||
|
||
// og:image : cover puis fallback og_image du meta
|
||
if ($ogImage === null || $ogImage === '') {
|
||
$ogImage = $article['og_image'] ?? '';
|
||
}
|
||
|
||
// Auteur : nom et URL de profil résolus depuis le champ author du JSON de l'article
|
||
$metaAuthor = $authorName;
|
||
$metaAuthorUrl = $authorProfileUrl;
|
||
|
||
// JSON-LD Article
|
||
$jsonLdData = [
|
||
'@context' => 'https://schema.org',
|
||
'@type' => 'BlogPosting',
|
||
'headline' => $seoTitle,
|
||
'description' => $seoDescription,
|
||
'url' => $canonical,
|
||
'datePublished' => date('c', strtotime((string)$articlePublishedAt)),
|
||
'dateModified' => date('c', strtotime((string)($article['updated_at'] ?? $articlePublishedAt))),
|
||
'author' => array_filter([
|
||
'@type' => 'Person',
|
||
'name' => $metaAuthor !== '' ? $metaAuthor : siteTitle(),
|
||
'url' => $metaAuthorUrl !== '' ? $metaAuthorUrl : null,
|
||
]),
|
||
'publisher' => [
|
||
'@type' => 'Blog',
|
||
'name' => siteTitle(),
|
||
'url' => rtrim(APP_URL, '/'),
|
||
],
|
||
'inLanguage' => siteLang(),
|
||
];
|
||
if (!empty($ogImage)) {
|
||
$jsonLdData['image'] = $ogImage;
|
||
}
|
||
$jsonLd = json_encode($jsonLdData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||
|
||
include __DIR__ . '/layout.php';
|