Files
folio/templates/post_view.php
T
cedricAbonnel 996ab3e508 fix : suppression article — permissions répertoire et gestion d'erreur (v1.6.10)
- mkArticleDir() crée les répertoires avec chmod 0775 explicite (bypass umask)
- delete() retourne bool et détecte l'échec sans reconstruire les index
- removeDir() supprime les warnings PHP (@unlink, @rmdir, @scandir)
- post_view.php affiche un message d'erreur si delete_failed=1
- index.php redirige vers l'article avec ?delete_failed=1 si échec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:27:24 +02:00

411 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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">
<?php if (!empty($bookContext)): ?>
<div class="book-article-banner mb-3">
<a href="/book/<?= rawurlencode($bookContext['book']['slug']) ?>" class="book-article-banner-link">
<span class="book-article-banner-icon">📖</span>
<span class="book-article-banner-text">
Chapitre <?= $bookContext['position'] ?>/<?= $bookContext['total'] ?> —
<strong><?= htmlspecialchars($bookContext['book']['title']) ?></strong>
</span>
<span class="book-article-banner-cta">Voir le sommaire →</span>
</a>
</div>
<?php endif; ?>
<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">
<?php if (($_GET['delete_failed'] ?? '') === '1' && function_exists('isAdmin') && isAdmin()): ?>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
Suppression impossible — droits insuffisants sur le répertoire de données.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<div class="card-text post-content">
<?= $_renderedContent ?>
</div>
<?php if (!empty($bookContext)): ?>
<nav class="book-chapter-nav">
<div class="book-chapter-nav-inner">
<?php if (!empty($bookContext['prev_article'])): ?>
<a href="/post/<?= rawurlencode($bookContext['prev_article']['slug'] ?? '') ?>"
class="book-nav-btn book-nav-btn--prev">
<span class="book-nav-dir">← Précédent</span>
<span class="book-nav-title"><?= htmlspecialchars($bookContext['prev_article']['title'] ?? '') ?></span>
</a>
<?php else: ?>
<span class="book-nav-btn book-nav-btn--prev book-nav-btn--disabled">
<span class="book-nav-dir">Premier chapitre</span>
</span>
<?php endif; ?>
<a href="/book/<?= rawurlencode($bookContext['book']['slug']) ?>"
class="book-nav-toc" title="Sommaire du livre">
</a>
<?php if (!empty($bookContext['next_article'])): ?>
<a href="/post/<?= rawurlencode($bookContext['next_article']['slug'] ?? '') ?>"
class="book-nav-btn book-nav-btn--next">
<span class="book-nav-dir">Suivant →</span>
<span class="book-nav-title"><?= htmlspecialchars($bookContext['next_article']['title'] ?? '') ?></span>
</a>
<?php else: ?>
<span class="book-nav-btn book-nav-btn--next book-nav-btn--disabled">
<span class="book-nav-dir">Dernier chapitre</span>
</span>
<?php endif; ?>
</div>
</nav>
<?php endif; ?>
</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 &amp; 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';