fix #29 : envoyer le lien magique par email (envoyer_mail_smtp)

This commit is contained in:
Cedric Abonnel
2026-05-13 23:41:58 +02:00
commit 8a85c15372
129 changed files with 22818 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
<?php
ob_start();
?>
<div class="posts-list site-page">
<?= $siteContent ?>
</div>
<?php
$content = ob_get_clean();
$title = ($siteMeta['seo_title'] ?? $siteMeta['title'] ?? 'À propos') . ' — ' . siteTitle();
include __DIR__ . '/layout.php';
+128
View File
@@ -0,0 +1,128 @@
<?php
ob_start();
$existingFiles = $articles->getFiles($addFilesArticle['uuid']);
$articleUuid = $addFilesArticle['uuid'];
$articleTitle = $addFilesArticle['title'];
// Extraire 1-3 mots significatifs du titre pour l'auto-recherche
$_sfStop = ['ou','et','un','une','le','la','les','de','du','des','en','au','aux','ce','cet',
'cette','ces','que','qui','par','sur','dans','son','sa','ses','mon','ton','nos',
'vos','leur','leurs','voir','comment','quoi','dont','votre','notre','selon','car',
'mais','donc','puis','plus','très','avec','pour','pas','est','sont','était',
'être','avoir','faire','tout','tous','toute','toutes'];
$_sfWords = preg_split('/[^a-zA-ZÀ-ÿ0-9]+/u', $articleTitle) ?: [];
$_sfKw = [];
foreach ($_sfWords as $_w) {
if (mb_strlen($_w) >= 3 && !in_array(mb_strtolower($_w), $_sfStop, true)) {
$_sfKw[] = $_w;
if (count($_sfKw) >= 3) {
break;
}
}
}
$autoSearchQuery = !empty($_sfKw) ? implode(' ', $_sfKw) : $articleTitle;
unset($_sfStop, $_sfWords, $_sfKw, $_w);
?>
<?php if (!empty($addFilesError)): ?>
<div class="alert alert-danger"><?= htmlspecialchars($addFilesError) ?></div>
<?php endif; ?>
<div class="d-flex align-items-center gap-3 mb-4">
<a href="/edit/<?= rawurlencode($articleUuid) ?>" class="btn btn-secondary btn-sm">← Retour</a>
<h1 class="h4 mb-0">Ajouter des fichiers</h1>
</div>
<p class="text-muted small mb-4">
Article : <strong><?= htmlspecialchars($articleTitle) ?></strong>
</p>
<div class="row g-4">
<!-- Upload -->
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title h6 mb-3">Uploader</h5>
<form method="POST"
action="/files/<?= rawurlencode($articleUuid) ?>/add"
enctype="multipart/form-data">
<div class="mb-3">
<input type="file" class="form-control" id="files" name="files[]" multiple required>
<div class="form-text">
Images → <code>sha256-taille.ext</code><br>
Vidéos, PDF, autres → nom sanitisé
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">Uploader</button>
<a href="/edit/<?= rawurlencode($articleUuid) ?>" class="btn btn-outline-secondary btn-sm">Annuler</a>
</div>
</form>
</div>
</div>
<!-- Fichiers déjà présents -->
<?php if ($existingFiles): ?>
<div class="card">
<div class="card-body">
<h5 class="card-title h6 mb-3">Fichiers existants</h5>
<div class="list-group list-group-flush">
<?php foreach ($existingFiles as $f):
$fileUrl = '/file?uuid=' . rawurlencode($articleUuid) . '&name=' . rawurlencode($f['name']);
?>
<div class="list-group-item d-flex align-items-center gap-2 px-0 py-1">
<?php if ($f['is_image']): ?>
<img src="<?= htmlspecialchars($fileUrl) ?>" alt=""
style="width:40px;height:40px;object-fit:cover;border-radius:4px;flex-shrink:0">
<?php else: ?>
<span style="width:40px;text-align:center;font-size:1.3rem;flex-shrink:0">
<?= match(true) {
str_starts_with($f['mime'], 'video/') => '🎬',
str_starts_with($f['mime'], 'audio/') => '🎵',
$f['mime'] === 'application/pdf' => '📑',
default => '📄',
} ?>
</span>
<?php endif; ?>
<div class="overflow-hidden" style="min-width:0">
<code class="d-block small text-truncate"><?= htmlspecialchars($f['name']) ?></code>
<small class="text-muted"><?= number_format($f['size'] / 1024, 1) ?> Ko</small>
</div>
<?php if (($addFilesArticle['cover'] ?? '') === $f['name']): ?>
<span class="badge bg-primary ms-auto flex-shrink-0">cover</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
</div>
<!-- Recherche dans les autres articles -->
<div class="col-lg-8">
<div id="sf-panel" data-uuid="<?= htmlspecialchars($articleUuid) ?>" class="card">
<div class="card-body">
<h5 class="card-title h6 mb-3">Fichiers d'autres articles</h5>
<div class="d-flex gap-2 mb-3">
<input type="text" id="sf-input" class="form-control form-control-sm"
value="<?= htmlspecialchars($autoSearchQuery) ?>"
placeholder="Titre, mot-clé…" autocomplete="off">
<button type="button" id="sf-btn" class="btn btn-sm btn-outline-secondary text-nowrap">
Chercher
</button>
</div>
<div id="sf-results"></div>
</div>
</div>
</div>
</div>
<script src="/assets/js/add_files.js"></script>
<?php
$content = ob_get_clean();
$title = 'Ajouter des fichiers — ' . htmlspecialchars($articleTitle);
include __DIR__ . '/layout.php';
+1067
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
<?php
ob_start();
$isAdminRole = ($editRole['name'] === 'admin');
?>
<div class="d-flex align-items-center gap-3 mb-4">
<a href="/admin/roles" class="btn btn-secondary btn-sm">← Retour</a>
<h1 class="h4 mb-0">Rôle : <?= htmlspecialchars($editRole['label']) ?></h1>
<code class="text-muted"><?= htmlspecialchars($editRole['name']) ?></code>
</div>
<form method="POST" action="/admin/role/<?= rawurlencode($editRole['name']) ?>">
<div class="row g-4">
<!-- Permissions -->
<div class="col-lg-8">
<?php if ($isAdminRole): ?>
<div class="alert alert-warning">
Le rôle <code>admin</code> a toutes les permissions implicitement — les cases à cocher sont ignorées.
</div>
<?php else: ?>
<div class="card">
<div class="card-body">
<?php foreach (CAPABILITY_GROUPS as $groupLabel => $groupCaps): ?>
<div class="mb-4">
<h6 class="fw-semibold text-muted text-uppercase small mb-3"><?= htmlspecialchars($groupLabel) ?></h6>
<?php foreach ($groupCaps as $cap): ?>
<?php if (!array_key_exists($cap, KNOWN_CAPABILITIES)) {
continue;
} ?>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox"
name="caps[]" value="<?= htmlspecialchars($cap) ?>"
id="cap_<?= htmlspecialchars($cap) ?>"
<?= in_array($cap, $editRoleCaps, true) ? 'checked' : '' ?>>
<label class="form-check-label" for="cap_<?= htmlspecialchars($cap) ?>">
<?= htmlspecialchars(KNOWN_CAPABILITIES[$cap]) ?>
</label>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<!-- Label + Actions -->
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-body">
<div class="mb-3">
<label for="role_label" class="form-label fw-semibold small">Label affiché</label>
<input type="text" id="role_label" name="label" class="form-control form-control-sm"
value="<?= htmlspecialchars($editRole['label']) ?>" required>
</div>
<button type="submit" class="btn btn-primary w-100">Enregistrer</button>
<a href="/admin/roles" class="btn btn-outline-secondary w-100 mt-2">Annuler</a>
</div>
</div>
</div>
</div>
</form>
<?php
$content = ob_get_clean();
$title = 'Rôle — ' . $editRole['label'];
include __DIR__ . '/layout.php';
+97
View File
@@ -0,0 +1,97 @@
<?php
require_once BASE_PATH . '/src/Parsedown.php';
$Parsedown = new Parsedown();
$_apName = $authorRow['display_name'] ?? '';
$_apSlug = $authorRow['profile_slug'] ?? '';
$_base = '/profil/' . rawurlencode($_apSlug) . '/article';
ob_start();
?>
<div class="mb-4">
<a href="/profil/<?= rawurlencode($_apSlug) ?>" class="text-muted">← <?= htmlspecialchars($_apName) ?></a>
<h1 class="mt-1">Articles de <?= htmlspecialchars($_apName) ?></h1>
</div>
<?php if (empty($posts)): ?>
<p class="text-muted">Aucun article publié.</p>
<?php else: ?>
<div class="post-grid">
<?php foreach ($posts as $post):
$html = $Parsedown->text($post['content']);
$preview = mb_strimwidth(strip_tags($html), 0, 120, '…');
$category = trim((string)($post['category'] ?? ''));
$gradient = coverGradient($category !== '' ? $category : $post['uuid'], $allCats ?? []);
$postUrl = '/post/' . rawurlencode($post['slug']);
$coverFile = $post['cover'] ?? '';
$coverStyle = $coverFile !== ''
? 'background-image: url(\'/file?uuid=' . rawurlencode($post['uuid']) . '&name=' . rawurlencode($coverFile) . '\')'
: 'background: ' . $gradient;
$isAvantPremiere = $post['published'] && strtotime((string)($post['published_at'] ?? '')) > time();
$isLocked = $isAvantPremiere && !hasCapability('view_previews');
?>
<article class="card">
<?php if ($isAvantPremiere): ?>
<div class="premiere-ribbon">Avant-première</div>
<?php endif; ?>
<div class="card-cover" style="<?= $coverStyle ?>">
<?php if ($category !== ''): ?>
<span class="cover-category"><?= htmlspecialchars($category) ?></span>
<?php endif; ?>
</div>
<div class="card-body d-flex flex-column">
<h2 class="card-title">
<?php if ($isLocked): ?>
<?= htmlspecialchars($post['title']) ?>
<?php else: ?>
<a href="<?= htmlspecialchars($postUrl) ?>"><?= htmlspecialchars($post['title']) ?></a>
<?php endif; ?>
</h2>
<p class="card-text flex-grow-1"><?= htmlspecialchars($preview) ?></p>
<div class="post-entry-meta mt-auto">
<?php if ($isAvantPremiere): ?>
<span class="text-muted">Disponible le <?= htmlspecialchars(date('d/m/Y \à H\hi', strtotime((string)($post['published_at'] ?? '')))) ?></span>
<?php else: ?>
<span><?= htmlspecialchars(date('d/m/Y', strtotime((string)($post['published_at'] ?? $post['created_at'] ?? '')))) ?></span>
<?php endif; ?>
<?php if (function_exists('isAdmin') && isAdmin()): ?>
<a href="/edit/<?= htmlspecialchars($post['uuid']) ?>" class="post-entry-edit">modifier</a>
<?php endif; ?>
<?php if (!$isLocked): ?>
<a href="<?= htmlspecialchars($postUrl) ?>" class="post-entry-read">→ lire</a>
<?php endif; ?>
</div>
</div>
<?php if (!$isLocked): ?>
<a href="<?= htmlspecialchars($postUrl) ?>" class="stretched-link"></a>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
<?php if ($prevCursor !== null || $nextCursor !== null): ?>
<nav class="pagination-nav mt-5" aria-label="Navigation">
<?php if ($prevCursor !== null): ?>
<?php $prevHref = $prevCursor === '' ? $_base : $_base . '/cursor/' . rawurlencode($prevCursor); ?>
<a class="pagination-btn" href="<?= htmlspecialchars($prevHref) ?>">← Plus récents</a>
<?php endif; ?>
<?php if ($nextCursor !== null): ?>
<a class="pagination-btn ms-auto" href="<?= htmlspecialchars($_base . '/cursor/' . rawurlencode($nextCursor)) ?>">Plus anciens →</a>
<?php endif; ?>
</nav>
<?php endif; ?>
<?php endif; ?>
<?php
$content = ob_get_clean();
$title = 'Articles de ' . htmlspecialchars($_apName) . ' — ' . siteTitle();
$seoTitle = 'Articles de ' . $_apName . ' — ' . siteTitle();
$canonical = rtrim(APP_URL, '/') . $_base;
$ogUrl = $canonical;
if (!empty($cursor)) {
$metaRobots = 'noindex, follow';
}
$mainClass = 'container-fluid';
include __DIR__ . '/layout.php';
+84
View File
@@ -0,0 +1,84 @@
<?php
require_once BASE_PATH . '/src/Parsedown.php';
$Parsedown = new Parsedown();
ob_start();
$_apName = $authorRow['display_name'] ?? '';
$_apUrl = $authorRow['profile_url'] ?? '';
$_apSlug = $authorRow['profile_slug'] ?? '';
$_apBio = $authorRow['bio'] ?? '';
$_initials = mb_strtoupper(mb_substr($_apName, 0, 1, 'UTF-8'), 'UTF-8');
?>
<div class="author-profile-hero mb-5">
<div class="author-avatar"><?= htmlspecialchars($_initials) ?></div>
<div class="author-profile-info">
<h1 class="author-profile-name"><?= htmlspecialchars($_apName) ?></h1>
<?php if ($_apUrl !== ''): ?>
<a href="<?= htmlspecialchars($_apUrl) ?>" target="_blank" rel="noopener" class="author-profile-link">
<?= htmlspecialchars(parse_url($_apUrl, PHP_URL_HOST) ?: $_apUrl) ?> ↗
</a>
<?php endif; ?>
<a href="/liens/<?= rawurlencode($_apSlug) ?>" class="liens-cta">Mes liens</a>
</div>
<?php if ($_apBio !== ''): ?>
<div class="author-bio-wrap">
<p class="author-profile-bio bio-clamped" id="author-bio"><?= nl2br(htmlspecialchars($_apBio)) ?></p>
<button class="bio-toggle" id="bio-toggle" hidden>plus</button>
</div>
<script src="/assets/js/bio-toggle.js" defer></script>
<?php endif; ?>
</div>
<?php if (empty($authorArticles)): ?>
<p class="text-muted">Aucun article publié.</p>
<?php else: ?>
<div class="post-grid">
<?php foreach (array_slice($authorArticles, 0, 6) as $post):
$html = $Parsedown->text($post['content']);
$preview = mb_strimwidth(strip_tags($html), 0, 120, '…');
$category = trim((string)($post['category'] ?? ''));
$gradient = coverGradient($category !== '' ? $category : $post['uuid'], $allCats ?? []);
$postUrl = '/post/' . rawurlencode($post['slug']);
$coverFile = $post['cover'] ?? '';
$coverStyle = $coverFile !== ''
? 'background-image: url(\'/file?uuid=' . rawurlencode($post['uuid']) . '&name=' . rawurlencode($coverFile) . '\')'
: 'background: ' . $gradient;
?>
<article class="card">
<div class="card-cover" style="<?= $coverStyle ?>">
<?php if ($category !== ''): ?>
<span class="cover-category"><?= htmlspecialchars($category) ?></span>
<?php endif; ?>
</div>
<div class="card-body d-flex flex-column">
<h2 class="card-title">
<a href="<?= htmlspecialchars($postUrl) ?>"><?= htmlspecialchars($post['title']) ?></a>
</h2>
<p class="card-text flex-grow-1"><?= htmlspecialchars($preview) ?></p>
<div class="post-entry-meta mt-auto">
<span><?= htmlspecialchars(date('d/m/Y', strtotime((string)($post['published_at'] ?? $post['created_at'] ?? '')))) ?></span>
<a href="<?= htmlspecialchars($postUrl) ?>" class="post-entry-read">→ lire</a>
</div>
</div>
<a href="<?= htmlspecialchars($postUrl) ?>" class="stretched-link"></a>
</article>
<?php endforeach; ?>
</div>
<?php if (count($authorArticles) > 6): ?>
<p class="text-center mt-4">
<a href="/profil/<?= rawurlencode($_apSlug) ?>/article" class="author-profile-link">Voir tous les articles →</a>
</p>
<?php endif; ?>
<?php endif; ?>
<?php
$content = ob_get_clean();
$title = htmlspecialchars($_apName) . ' — ' . siteTitle();
$seoTitle = $_apName . ' — ' . siteTitle();
$canonical = rtrim(APP_URL, '/') . '/profil/' . rawurlencode($_apSlug);
$ogUrl = $canonical;
$mainClass = 'container-fluid';
include __DIR__ . '/layout.php';
+158
View File
@@ -0,0 +1,158 @@
<?php ob_start(); ?>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="mb-0">Catégories</h1>
<a href="/" class="btn btn-secondary btn-sm">← Retour</a>
</div>
<div class="row g-4">
<!-- Liste des catégories existantes -->
<div class="col-lg-9">
<h5 class="mb-3">Catégories existantes</h5>
<?php if (empty($cats)): ?>
<p class="text-muted">Aucune catégorie définie.</p>
<?php else: ?>
<div class="d-flex flex-column gap-2">
<?php foreach ($cats as $cat => $count):
$gradient = coverGradient($cat, $cats); ?>
<div class="card">
<div class="card-body py-2 px-3 d-flex align-items-center gap-3">
<!-- Swatch -->
<div style="width:40px;height:40px;border-radius:8px;flex-shrink:0;background:<?= htmlspecialchars($gradient) ?>"></div>
<!-- Nom + count -->
<div style="min-width:140px">
<strong><?= htmlspecialchars($cat) ?></strong>
<small class="text-muted ms-2"><?= $count ?> article<?= $count > 1 ? 's' : '' ?></small>
</div>
<!-- Renommer -->
<form method="POST" action="/?action=rename_category"
class="d-flex align-items-center gap-2 flex-grow-1"
data-confirm="Renommer « <?= htmlspecialchars($cat) ?> » ?">
<input type="hidden" name="old" value="<?= htmlspecialchars($cat) ?>">
<input type="text" name="new" class="form-control form-control-sm"
placeholder="Nouveau nom" required>
<button type="submit" class="btn btn-outline-primary btn-sm text-nowrap">Renommer</button>
</form>
<!-- Privée -->
<?php $isPriv = in_array($cat, $privateCats, true); ?>
<form method="POST" action="/?action=toggle_private_category">
<input type="hidden" name="category" value="<?= htmlspecialchars($cat) ?>">
<button type="submit"
class="btn btn-sm text-nowrap <?= $isPriv ? 'btn-secondary' : 'btn-outline-secondary' ?>">
🔒 <?= $isPriv ? 'Privée' : 'Publique' ?>
</button>
</form>
<!-- Supprimer -->
<form method="POST" action="/?action=delete_category"
data-confirm="Retirer la catégorie « <?= htmlspecialchars($cat) ?> » de tous les articles ?">
<input type="hidden" name="category" value="<?= htmlspecialchars($cat) ?>">
<button type="submit" class="btn btn-outline-danger btn-sm">Supprimer</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- Palette & Nouvelle catégorie -->
<div class="col-lg-3">
<h5 class="mb-3">Nouvelle catégorie</h5>
<div class="card mb-3">
<div class="card-body">
<p class="text-muted small mb-3">
Créez une catégorie en l'assignant à un article.
La prochaine reçoit la couleur n°<?= count($cats) % 16 + 1 ?>.
</p>
<!-- Palette des 16 couleurs -->
<div class="d-flex flex-wrap gap-2">
<?php
$nextIdx = count($cats) % 16;
foreach (COLOR_PALETTE_16 as $i => $rgb):
$g = _paletteGradient($rgb, 0);
$active = $i === $nextIdx;
?>
<div title="Couleur <?= $i + 1 ?>"
style="width:28px;height:28px;border-radius:6px;background:<?= htmlspecialchars($g) ?>;
<?= $active ? 'outline:2px solid #0d6efd;outline-offset:2px' : 'opacity:.75' ?>">
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</div>
<?php if (isAdmin()): ?>
<hr class="my-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 mb-0">Types de tags</h2>
</div>
<div class="row g-4">
<!-- Types existants -->
<div class="col-lg-9">
<?php if (empty($tagTypes)): ?>
<p class="text-muted">Aucun type de tag défini.</p>
<?php else: ?>
<div class="d-flex flex-column gap-2">
<?php foreach ($tagTypes as $_key => $_label): ?>
<div class="card">
<div class="card-body py-2 px-3 d-flex align-items-center gap-3">
<code class="text-muted small" style="min-width:8rem"><?= htmlspecialchars($_key) ?></code>
<strong class="flex-grow-1"><?= htmlspecialchars($_label) ?></strong>
<form method="POST" action="/?action=delete_tag_type"
data-confirm="Supprimer le type «<?= htmlspecialchars($_label) ?>» ? Les tags associés aux articles ne seront pas supprimés.">
<input type="hidden" name="type_key" value="<?= htmlspecialchars($_key) ?>">
<button type="submit" class="btn btn-outline-danger btn-sm">Supprimer</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- Nouveau type -->
<div class="col-lg-3">
<h5 class="mb-3">Nouveau type</h5>
<div class="card">
<div class="card-body">
<form method="POST" action="/?action=create_tag_type">
<div class="mb-2">
<label class="form-label small fw-semibold">Identifiant</label>
<input type="text" name="type_key" class="form-control form-control-sm font-monospace"
placeholder="ex : logiciels" required
pattern="[a-z0-9_]+" title="Minuscules, chiffres et _ uniquement">
<div class="form-text">Minuscules, chiffres, _ (sans accent)</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Libellé</label>
<input type="text" name="type_label" class="form-control form-control-sm"
placeholder="ex : Logiciels" required>
</div>
<button type="submit" class="btn btn-primary btn-sm w-100">Créer</button>
</form>
</div>
</div>
</div>
</div>
<?php endif; ?>
<?php
$content = ob_get_clean();
$title = 'Catégories';
include __DIR__ . '/layout.php';
+138
View File
@@ -0,0 +1,138 @@
<?php
// Variables attendues (injectées depuis index.php) :
// $article — tableau article courant (uuid, slug, title)
// $reactionStats — array<string, int>
// $visitorReactions — string[] (types déjà cliqués par ce visiteur)
// $comments — array de commentaires publiés
// $commentFlash — bool|null (commentaire soumis, email envoyé)
// $commentVerified — bool|null (commentaire vérifié et publié)
// $commentError — string|null (message d'erreur)
$_reactionDefs = [
'useful' => ['👍', 'Utile'],
'important' => ['🔥', 'Important'],
'interesting' => ['🤔', 'À creuser'],
];
$_csrfToken = bin2hex(random_bytes(16));
$_SESSION['comment_csrf'] = $_csrfToken;
?>
<?php if (!empty($alsoReadArticles ?? [])): ?>
<!-- ── À lire aussi ──────────────────────────────────────────────── -->
<div class="also-read mb-4" id="also-read">
<h6 class="also-read-title">À lire aussi</h6>
<div class="also-read-grid">
<?php foreach ($alsoReadArticles as $_also):
$_alsoCover = $_also['cover'] ?? '';
$_alsoCat = trim($_also['category'] ?? '');
$_alsoGradient = coverGradient($_alsoCat !== '' ? $_alsoCat : $_also['uuid'], $allCats ?? []);
$_alsoDate = date('d/m/Y', strtotime((string)($_also['published_at'] ?? $_also['created_at'] ?? '')));
?>
<a href="/post/<?= rawurlencode($_also['slug'] ?? '') ?>" class="related-card">
<div class="related-card-thumb" style="<?= $_alsoCover !== ''
? 'background-image:url(/file?uuid=' . rawurlencode($_also['uuid']) . '&name=' . rawurlencode($_alsoCover) . ');background-size:cover;background-position:center'
: 'background:' . htmlspecialchars($_alsoGradient) ?>"></div>
<div class="related-card-body">
<div class="related-card-title"><?= htmlspecialchars($_also['title']) ?></div>
<div class="related-card-date"><?= $_alsoDate ?></div>
</div>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- ── Commentaires ───────────────────────────────────────────────── -->
<div id="comments" class="mb-4">
<h5 class="mb-3">
Commentaires
<?php if (!empty($comments)): ?>
<span class="badge bg-secondary ms-1"><?= count($comments) ?></span>
<?php endif; ?>
</h5>
<?php if ($commentFlash ?? false): ?>
<div class="alert alert-info">
Un code de confirmation vous a été envoyé par email.
Cliquez sur le lien reçu, puis saisissez le code à 6 chiffres pour publier votre commentaire.
</div>
<?php endif; ?>
<?php if ($commentVerified ?? false): ?>
<div class="alert alert-success">Votre commentaire a été publié. Merci !</div>
<?php endif; ?>
<?php if (!empty($commentError ?? null)): ?>
<div class="alert alert-danger"><?= htmlspecialchars($commentError) ?></div>
<?php endif; ?>
<?php foreach ($comments as $c): ?>
<div class="card mb-3 comment-card" id="comment-<?= (int)$c['id'] ?>">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<strong class="comment-author"><?= htmlspecialchars($c['author_name']) ?></strong>
<small class="text-muted">
<?= htmlspecialchars(date('d/m/Y à H\hi', strtotime((string)$c['created_at']))) ?>
</small>
</div>
<div class="comment-content"><?= nl2br(htmlspecialchars((string)$c['content'])) ?></div>
<?php if (function_exists('isAdmin') && isAdmin()): ?>
<div class="mt-2">
<form method="post" action="/comment-moderate" class="d-inline">
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<input type="hidden" name="pub" value="0">
<button type="submit" class="btn btn-sm btn-outline-danger">Masquer</button>
</form>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($comments) && !($commentFlash ?? false) && !($commentVerified ?? false)): ?>
<p class="text-muted mb-4">Aucun commentaire pour l'instant. Soyez le premier !</p>
<?php endif; ?>
<!-- Formulaire -->
<div class="card mt-3" id="comment-form-card">
<div class="card-body">
<h6 class="card-title mb-3">Laisser un commentaire</h6>
<form method="post" action="/comment" id="comment-form">
<input type="hidden" name="_token" value="<?= htmlspecialchars($_csrfToken) ?>">
<input type="hidden" name="uuid" value="<?= htmlspecialchars($article['uuid']) ?>">
<!-- honeypot -->
<div class="d-none" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label" for="comment-name">Nom <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="comment-name" name="author_name"
maxlength="100" required placeholder="Votre nom">
</div>
<div class="col-sm-6">
<label class="form-label" for="comment-email">
Email <span class="text-danger">*</span>
<span class="text-muted fw-normal">(non publié)</span>
</label>
<input type="email" class="form-control" id="comment-email" name="author_email"
maxlength="254" required placeholder="votre@email.fr">
</div>
<div class="col-12">
<label class="form-label" for="comment-content">Commentaire <span class="text-danger">*</span></label>
<textarea class="form-control" id="comment-content" name="content"
rows="4" maxlength="2000" required
placeholder="Votre commentaire (2000 caractères max)"></textarea>
</div>
<div class="col-12 d-flex align-items-center gap-3">
<button type="submit" class="btn btn-primary">Envoyer</button>
<small class="text-muted">Un code de vérification sera envoyé à votre adresse email.</small>
</div>
</div>
</form>
</div>
</div>
</div>
+145
View File
@@ -0,0 +1,145 @@
<?php
// Session pour CSRF et rate-limit
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$contactEmail = $_ENV['CONTACT_EMAIL'] ?? '';
$error = null;
$success = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// CSRF
$token = $_POST['_token'] ?? '';
if (!hash_equals($_SESSION['contact_csrf'] ?? '', $token)) {
$error = 'Requête invalide. Veuillez réessayer.';
}
// Honeypot (champ caché que les bots remplissent)
if (!$error && ($_POST['_hp'] ?? '') !== '') {
$error = 'Requête invalide.';
}
// Rate-limit : 1 message par 5 minutes par session
if (!$error) {
$lastSent = $_SESSION['contact_last_sent'] ?? 0;
if (time() - $lastSent < 300) {
$error = 'Merci d\'attendre quelques minutes avant d\'envoyer un nouveau message.';
}
}
// Validation des champs
$name = trim($_POST['name'] ?? '');
$from = trim($_POST['email'] ?? '');
$subject = trim($_POST['subject'] ?? 'Contact depuis varlog');
$body = trim($_POST['message'] ?? '');
if (!$error) {
if ($name === '' || mb_strlen($name) > 100) {
$error = 'Nom invalide.';
} elseif (!filter_var($from, FILTER_VALIDATE_EMAIL)) {
$error = 'Adresse e-mail invalide.';
} elseif ($body === '' || mb_strlen($body) > 5000) {
$error = 'Message vide ou trop long (max 5000 caractères).';
}
}
if (!$error && $contactEmail !== '') {
$subjectClean = mb_encode_mimeheader(
'[' . siteTitle() . ' contact] ' . mb_strimwidth($subject, 0, 100, '…'),
'UTF-8',
'B'
);
$nameClean = mb_encode_mimeheader($name, 'UTF-8', 'B');
$fromEmail = $_ENV['CONTACT_FROM_EMAIL'] ?? ('noreply@' . (parse_url(APP_URL, PHP_URL_HOST) ?? 'localhost'));
$headers = 'From: =?UTF-8?B?' . base64_encode(siteTitle() . ' contact') . "?= <{$fromEmail}>\r\n";
$headers .= "Reply-To: {$nameClean} <{$from}>\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
$headers .= "Content-Transfer-Encoding: 8bit\r\n";
$fullBody = "De : {$name} <{$from}>\n\n{$body}\n\n---\nEnvoyé depuis varlog";
if (@mail($contactEmail, $subjectClean, $fullBody, $headers)) {
$_SESSION['contact_last_sent'] = time();
$success = true;
} else {
$error = 'Erreur lors de l\'envoi. Veuillez réessayer plus tard.';
}
} elseif (!$error) {
$error = 'Formulaire de contact non configuré.';
}
}
// Génère un nouveau token CSRF à chaque affichage du formulaire
if (!$success) {
$_SESSION['contact_csrf'] = bin2hex(random_bytes(16));
}
ob_start();
?>
<div class="posts-list">
<h1 class="mb-1">Contact</h1>
<p class="text-muted mb-4">Envoyez-moi un message. Votre adresse e-mail ne sera pas publiée.</p>
<?php if ($success): ?>
<div class="alert alert-success" role="alert">
Message envoyé. Je vous répondrai dès que possible.
</div>
<?php else: ?>
<?php if ($error): ?>
<div class="alert alert-danger" role="alert"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<div class="card">
<div class="card-body">
<form method="POST" action="/?action=contact" novalidate>
<input type="hidden" name="_token" value="<?= htmlspecialchars($_SESSION['contact_csrf']) ?>">
<!-- Honeypot -->
<div style="display:none" aria-hidden="true">
<input type="text" name="_hp" tabindex="-1" autocomplete="off">
</div>
<div class="mb-3">
<label for="contact-name" class="form-label">Nom <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="contact-name" name="name"
value="<?= htmlspecialchars($_POST['name'] ?? '') ?>"
maxlength="100" required autocomplete="name">
</div>
<div class="mb-3">
<label for="contact-email" class="form-label">Adresse e-mail <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="contact-email" name="email"
value="<?= htmlspecialchars($_POST['email'] ?? '') ?>"
required autocomplete="email">
</div>
<div class="mb-3">
<label for="contact-subject" class="form-label">Sujet</label>
<input type="text" class="form-control" id="contact-subject" name="subject"
value="<?= htmlspecialchars($_POST['subject'] ?? '') ?>"
maxlength="150" autocomplete="off">
</div>
<div class="mb-4">
<label for="contact-message" class="form-label">Message <span class="text-danger">*</span></label>
<textarea class="form-control" id="contact-message" name="message"
rows="7" maxlength="5000" required><?= htmlspecialchars($_POST['message'] ?? '') ?></textarea>
</div>
<button type="submit" class="btn btn-success">Envoyer</button>
</form>
</div>
</div>
<?php endif; ?>
</div>
<?php
$content = ob_get_clean();
$title = 'Contact — ' . siteTitle();
include __DIR__ . '/layout.php';
+113
View File
@@ -0,0 +1,113 @@
<?php ob_start(); ?>
<div class="row justify-content-center">
<div class="col-lg-8 col-xl-7">
<div class="d-flex align-items-center gap-3 mb-4">
<a href="/import/<?= rawurlencode($ackArticle['uuid']) ?>"
class="btn btn-secondary btn-sm">← Retour</a>
<h1 class="h4 mb-0">Confirmation — droits d'auteur</h1>
</div>
<div class="alert alert-warning d-flex gap-2 mb-4">
<span style="font-size:1.3rem;flex-shrink:0">⚠</span>
<div>
<strong>Vous êtes sur le point de copier ce fichier sur votre serveur :</strong>
<code class="d-block mt-1 text-break small"><?= htmlspecialchars($ackUrl) ?></code>
</div>
</div>
<!-- Contexte légal -->
<div class="card mb-3">
<div class="card-header fw-semibold">Ce que dit la loi française</div>
<div class="card-body small lh-base">
<p>En France, <strong>toute œuvre de l'esprit est protégée dès sa création</strong> sans
formalité d'enregistrement (art. L.111-1 CPI). Cela inclut les photographies, illustrations,
textes, vidéos et musiques.</p>
<p>Reproduire ou diffuser publiquement une œuvre sans l'autorisation de son auteur constitue
une <strong>contrefaçon</strong> (art. L.335-2 CPI), passible de :</p>
<ul class="mb-2">
<li><strong>3 ans d'emprisonnement</strong></li>
<li><strong>300 000 € d'amende</strong></li>
</ul>
<p class="mb-0">L'exception d'<em>usage privé</em> (art. L.122-5 1° CPI) est strictement
personnelle et <strong>ne couvre pas la publication sur un blog</strong>, même non commercial
et même à audience restreinte.</p>
</div>
</div>
<!-- Cas autorisés -->
<div class="card mb-3 border-success">
<div class="card-header fw-semibold text-success-emphasis bg-success-subtle">
✓ Cas où vous pouvez légalement télécharger ce fichier
</div>
<ul class="list-group list-group-flush small">
<li class="list-group-item">
<strong>Vous êtes l'auteur</strong> ou co-auteur du fichier
</li>
<li class="list-group-item">
Le fichier est distribué sous une <strong>licence libre compatible</strong> avec la
reproduction publique : <span class="font-monospace">CC0</span>,
<span class="font-monospace">CC BY</span>,
<span class="font-monospace">CC BY-SA</span>,
<span class="font-monospace">CC BY-ND</span>,
domaine public, etc.<br>
<small class="text-danger">⚠ CC BY-NC ne suffit pas si le blog génère des revenus,
même indirects.</small>
</li>
<li class="list-group-item">
Vous disposez d'une <strong>autorisation écrite explicite</strong> de l'auteur ou
du titulaire des droits patrimoniaux
</li>
<li class="list-group-item">
L'œuvre est dans le <strong>domaine public</strong> : 70 ans révolus après le décès
de l'auteur en Union Européenne (art. L.123-1 CPI)
</li>
</ul>
</div>
<!-- Formulaire de confirmation -->
<form method="POST"
action="/?action=add_file_from_url&uuid=<?= rawurlencode($ackArticle['uuid']) ?>">
<input type="hidden" name="image_url" value="<?= htmlspecialchars($ackUrl) ?>">
<input type="hidden" name="img_title" value="<?= htmlspecialchars($ackTitle) ?>">
<input type="hidden" name="img_author" value="<?= htmlspecialchars($ackAuthor) ?>">
<input type="hidden" name="img_source" value="<?= htmlspecialchars($ackSource) ?>">
<input type="hidden" name="meta_json" value="<?= htmlspecialchars($ackMetaJson) ?>">
<input type="hidden" name="mode" value="download">
<input type="hidden" name="copyright_acked" value="1">
<?php if ($ackIsCover): ?>
<input type="hidden" name="is_cover" value="1">
<?php endif; ?>
<div class="card border-primary mb-4">
<div class="card-body">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="ack_check" name="ack_check" required>
<label class="form-check-label" for="ack_check">
<strong>Je certifie disposer des droits nécessaires</strong> pour reproduire
et publier ce fichier sur ce blog, conformément au Code de la Propriété
Intellectuelle. Je comprends que cette déclaration engage ma responsabilité
personnelle en cas de contentieux.
</label>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Télécharger et insérer</button>
<a href="/import/<?= rawurlencode($ackArticle['uuid']) ?>"
class="btn btn-outline-secondary">Annuler</a>
</div>
</form>
</div>
</div>
<?php
$content = ob_get_clean();
$title = 'Droits d\'auteur — confirmation';
include __DIR__ . '/layout.php';
+78
View File
@@ -0,0 +1,78 @@
<?php
ob_start();
$revMeta = $revisions[$revIndex] ?? [];
?>
<div class="d-flex align-items-center gap-3 mb-3 flex-wrap">
<a href="/edit/<?= htmlspecialchars($article['uuid']) ?>" class="btn btn-secondary btn-sm">← Retour</a>
<div>
<strong><?= htmlspecialchars($article['title']) ?></strong>
&mdash; révision #<?= (int)($revMeta['n'] ?? $revIndex + 1) ?>
<span class="text-muted small">
du <?= htmlspecialchars(date('d/m/Y H:i', strtotime((string)($revMeta['date'] ?? '')))) ?>
<?= !empty($revMeta['comment']) ? '— ' . htmlspecialchars($revMeta['comment']) : '' ?>
</span>
</div>
</div>
<div class="d-flex gap-4 mb-2 small">
<span class="diff-del px-2 py-1 rounded"> Supprimé</span>
<span class="diff-ins px-2 py-1 rounded">+ Ajouté</span>
<span class="diff-eq px-2 py-1 rounded text-muted">= Inchangé</span>
</div>
<?php if ($diffLines === []): ?>
<div class="alert alert-success">Aucune différence — le contenu est identique.</div>
<?php else: ?>
<?php
// Groupe les lignes : affiche contexte de 3 lignes autour des changements
$CONTEXT = 3;
$total = count($diffLines);
$show = [];
for ($i = 0; $i < $total; $i++) {
if ($diffLines[$i][0] !== '=') {
for ($c = max(0, $i - $CONTEXT); $c <= min($total - 1, $i + $CONTEXT); $c++) {
$show[$c] = true;
}
}
}
$inEllipsis = false;
?>
<div class="diff-view font-monospace small">
<?php for ($i = 0; $i < $total; $i++): ?>
<?php [$op, $line] = $diffLines[$i]; ?>
<?php if (!isset($show[$i])): ?>
<?php if (!$inEllipsis): $inEllipsis = true; ?>
<div class="diff-ellipsis text-muted px-2">⋯</div>
<?php endif;
continue; ?>
<?php else: $inEllipsis = false; endif; ?>
<?php if ($op === '!'): ?>
<div class="diff-warning text-warning px-2"><?= htmlspecialchars($line) ?></div>
<?php elseif ($op === '-'): ?>
<div class="diff-del px-2">&nbsp;<?= htmlspecialchars($line) ?></div>
<?php elseif ($op === '+'): ?>
<div class="diff-ins px-2">+&nbsp;<?= htmlspecialchars($line) ?></div>
<?php else: ?>
<div class="diff-eq px-2 text-muted">&nbsp;&nbsp;<?= htmlspecialchars($line) ?></div>
<?php endif; ?>
<?php endfor; ?>
</div>
<?php endif; ?>
<style>
.diff-view { border: 1px solid var(--bs-border-color, #dee2e6); border-radius: 6px; overflow-x: auto; }
.diff-view > div { padding: 1px 8px; white-space: pre; line-height: 1.5; }
.diff-del { background: #ffeef0; color: #b91c1c; }
.diff-ins { background: #e6ffec; color: #15803d; }
.diff-eq { }
.diff-ellipsis { background: #f8f9fa; padding: 2px 8px; user-select: none; }
</style>
<?php
$content = ob_get_clean();
$title = 'Diff — ' . htmlspecialchars($article['title']);
include __DIR__ . '/layout.php';
+119
View File
@@ -0,0 +1,119 @@
<?php
ob_start();
$_editUrl = '/edit/' . rawurlencode($uuid);
$_postUrl = '/edit/' . rawurlencode($uuid) . '/tags/' . rawurlencode($tagType);
$_typeLabel = $isCatField ? 'Catégorie' : ($tagTypes[$tagType] ?? ucfirst($tagType));
?>
<div class="d-flex align-items-center gap-3 mb-4 flex-wrap">
<a href="<?= htmlspecialchars($_editUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour à l'édition</a>
<div>
<h1 class="h4 mb-0">
Suggestions : <span class="text-primary"><?= htmlspecialchars($_typeLabel) ?></span>
</h1>
<p class="text-muted small mb-0">
<?= htmlspecialchars($article['title']) ?>
</p>
</div>
</div>
<form method="POST" action="<?= htmlspecialchars($_postUrl) ?>">
<?php if ($isCatField): /* ── Mode catégorie ───────────────────────────── */ ?>
<p class="text-muted small mb-3">
Sélectionnez une catégorie. Les chiffres indiquent combien de fois le mot apparaît dans le texte.
</p>
<div class="row g-2 mb-4">
<?php foreach ($suggestions as $_cat => $_cnt): ?>
<?php $_isCurrentCat = ($_cat === $currentCat); ?>
<div class="col-sm-6 col-md-4 col-lg-3">
<label class="d-flex align-items-center gap-2 p-2 border rounded <?= $_isCurrentCat ? 'border-primary bg-primary bg-opacity-10' : '' ?>"
style="cursor:pointer">
<input type="radio" name="category" value="<?= htmlspecialchars($_cat) ?>"
<?= $_isCurrentCat ? 'checked' : '' ?>>
<span class="flex-grow-1"><?= htmlspecialchars($_cat) ?></span>
<?php if ($_cnt > 0): ?>
<span class="badge bg-secondary"><?= $_cnt ?>×</span>
<?php endif; ?>
</label>
</div>
<?php endforeach; ?>
</div>
<?php else: /* ── Mode tags ─────────────────────────────────────────────── */ ?>
<?php
$_current = array_filter($suggestions, fn ($s) => $s['current']);
$_known = array_filter($suggestions, fn ($s) => !$s['current'] && $s['known']);
$_abbrevs = array_filter($suggestions, fn ($s) => !$s['current'] && !$s['known'] && $s['group'] === 'abbrev');
$_camel = array_filter($suggestions, fn ($s) => !$s['current'] && !$s['known'] && $s['group'] === 'camel');
$_proper = array_filter($suggestions, fn ($s) => !$s['current'] && !$s['known'] && in_array($s['group'], ['proper'], true));
?>
<p class="text-muted small mb-3">
Cochez les termes à associer à cet article. Les termes déjà taggués sont présélectionnés.
</p>
<?php
function renderTagGroup(string $title, array $items, bool $checked, bool $collapsible = false, string $collapseId = ''): void
{ ?>
<?php if (empty($items)): return; endif; ?>
<div class="mb-4">
<?php if ($collapsible && count($items) > 10): ?>
<details>
<summary class="fw-semibold mb-2 text-muted" style="cursor:pointer;list-style:none">
<span class="me-1">▸</span><?= htmlspecialchars($title) ?>
<span class="badge bg-secondary ms-1"><?= count($items) ?></span>
</summary>
<div class="mt-2">
<?php else: ?>
<h6 class="fw-semibold text-muted mb-2"><?= htmlspecialchars($title) ?>
<span class="badge bg-secondary ms-1"><?= count($items) ?></span>
</h6>
<?php endif; ?>
<div class="d-flex flex-wrap gap-2">
<?php foreach ($items as $_term => $_info): ?>
<label class="d-inline-flex align-items-center gap-1 px-2 py-1 rounded border
<?= $_info['current'] ? 'border-primary bg-primary bg-opacity-10 fw-semibold' : 'border-secondary-subtle' ?>"
style="cursor:pointer;font-size:.875rem">
<input type="checkbox" name="selected[]" value="<?= htmlspecialchars($_term) ?>"
<?= ($checked || $_info['current']) ? 'checked' : '' ?>>
<span><?= htmlspecialchars($_term) ?></span>
<span class="text-muted" style="font-size:.75rem"><?= $_info['count'] ?>×</span>
</label>
<?php endforeach; ?>
</div>
<?php if ($collapsible && count($items) > 10): ?>
</div>
</details>
<?php endif; ?>
</div>
<?php } ?>
<?php renderTagGroup('Déjà taggués', $_current, true); ?>
<?php renderTagGroup('Valeurs connues dans d\'autres articles', $_known, false); ?>
<?php renderTagGroup('Abréviations détectées', $_abbrevs, false, true); ?>
<?php renderTagGroup('Noms composés détectés', $_camel + $_proper, false, true); ?>
<?php if (empty($suggestions)): ?>
<p class="text-muted">Aucun terme détecté dans cet article.</p>
<?php endif; ?>
<?php endif; ?>
<div class="d-flex gap-3 mt-4 pt-3 border-top">
<button type="submit" class="btn btn-success">Enregistrer</button>
<a href="<?= htmlspecialchars($_editUrl) ?>" class="btn btn-outline-secondary">Annuler</a>
</div>
</form>
<?php
$content = ob_get_clean();
$title = 'Tags — ' . $article['title'];
include __DIR__ . '/layout.php';
+48
View File
@@ -0,0 +1,48 @@
<?php ob_start(); ?>
<div class="d-flex align-items-center gap-3 mb-5">
<h1 class="h4 mb-0">Flux agrégés</h1>
</div>
<?php if (empty($fluxItems)): ?>
<p class="text-muted">Aucun article disponible pour l'instant.</p>
<?php else: ?>
<div class="flux-list">
<?php foreach ($fluxItems as $_item):
$_date = $_item['date'] > 0 ? date('d/m/Y', $_item['date']) : '';
$_authorName = $_item['author_name'] ?? '';
$_authorSlug = $_item['author_slug'] ?? '';
?>
<article class="flux-item">
<div class="flux-item-meta">
<?php if ($_authorSlug !== ''): ?>
<a href="/profil/<?= rawurlencode($_authorSlug) ?>" class="flux-author"><?= htmlspecialchars($_authorName) ?></a>
<?php elseif ($_authorName !== ''): ?>
<span class="flux-author"><?= htmlspecialchars($_authorName) ?></span>
<?php endif; ?>
<?php if ($_item['feed_title'] !== ''): ?>
<span class="flux-feed-name"><?= htmlspecialchars($_item['feed_title']) ?></span>
<?php endif; ?>
<?php if ($_date !== ''): ?>
<span class="flux-date"><?= $_date ?></span>
<?php endif; ?>
</div>
<h2 class="flux-item-title">
<a href="<?= htmlspecialchars($_item['url']) ?>" target="_blank" rel="noopener">
<?= htmlspecialchars($_item['title']) ?> ↗
</a>
</h2>
<?php if ($_item['summary'] !== ''): ?>
<p class="flux-item-summary"><?= htmlspecialchars($_item['summary']) ?></p>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php
$content = ob_get_clean();
$title = 'Flux — ' . siteTitle();
$canonical = rtrim(APP_URL, '/') . '/flux';
$mainClass = 'container-fluid';
include __DIR__ . '/layout.php';
+10
View File
@@ -0,0 +1,10 @@
<div class="container">
<footer class="py-3 my-4">
<ul class="nav justify-content-center border-bottom pb-3 mb-3">
<li class="nav-item"><a href="https://alpinux.org/mentions-legales" class="nav-link px-2 text-body-secondary">Mentions légales</a></li>
<li class="nav-item"><a href="/index/a-propos" class="nav-link px-2 text-body-secondary">A propos</a></li>
</ul>
<p class="text-center text-body-secondary">Association 1901 - <a href="https://alpinux.org/">Alpinux, le LUG de Savoie</a></p>
</footer>
</div>
+49
View File
@@ -0,0 +1,49 @@
<div class="container">
<header class="d-flex flex-wrap align-items-center justify-content-center justify-content-md-between py-3 mb-4 border-bottom">
<a href="/" class="d-flex align-items-center text-body-emphasis text-decoration-none">
<img width="32" src="/img/logo-mail.svg" class="bi me-2" >
<span class="fs-4">Mug ALPINUX</span>
</a>
<ul class="nav col-12 col-md-auto mb-2 justify-content-center mb-md-0">
</ul>
<?php
// Créer une instance de MessageManager avec le fichier de base de données SQLite
$messageManager = new ace\MessageManager('database.db');
if ($messageManager->sessionAlready()) {
?>
<div class="dropdown text-end">
<a href="#" class="d-block link-dark text-decoration-none dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<?php echo $messageManager->getUsername($_SESSION['user_id']); ?>
</a>
<ul class="dropdown-menu text-small">
<li><a class="dropdown-item" href="/user/parametres">Paramètres <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-sliders" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M11.5 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM9.05 3a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0V3h9.05zM4.5 7a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM2.05 8a2.5 2.5 0 0 1 4.9 0H16v1H6.95a2.5 2.5 0 0 1-4.9 0H0V8h2.05zm9.45 4a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm-2.45 1a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0v-1h9.05z"/>
</svg></a></li>
<li><a class="dropdown-item" href="/user/profil">Profil <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-vcard" viewBox="0 0 16 16">
<path d="M5 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm4-2.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5ZM9 8a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4A.5.5 0 0 1 9 8Zm1 2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5Z"/>
<path d="M2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2ZM1 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H8.96c.026-.163.04-.33.04-.5C9 10.567 7.21 9 5 9c-2.086 0-3.8 1.398-3.984 3.181A1.006 1.006 0 0 1 1 12V4Z"/>
</svg></a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/user/disconnect">Déconnexion <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z"/>
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
</svg></a></li>
</ul>
</div>
<?php
}
?>
</header>
</div>
+41
View File
@@ -0,0 +1,41 @@
<?php ob_start(); ?>
<div class="d-flex align-items-center gap-3 mb-4">
<a href="/edit/<?= rawurlencode($importArticle['uuid']) ?>" class="btn btn-secondary btn-sm">← Retour</a>
<h1 class="h4 mb-0">Importer un fichier depuis une URL</h1>
</div>
<p class="text-muted small mb-4">
Article : <strong><?= htmlspecialchars($importArticle['title']) ?></strong>
</p>
<?php if ($importError): ?>
<div class="alert alert-warning">
URL invalide ou inaccessible — vérifiez que le lien est correct et que le serveur peut y accéder.
</div>
<?php endif; ?>
<div class="card" style="max-width:640px">
<div class="card-body">
<form method="POST" action="/?action=import_image_step2&uuid=<?= rawurlencode($importArticle['uuid']) ?>">
<div class="mb-4">
<label class="form-label fw-semibold">URL du fichier <span class="text-danger">*</span></label>
<input type="url" name="image_url" class="form-control font-monospace"
placeholder="https://…/document.pdf"
value="<?= htmlspecialchars($_GET['image_url'] ?? '') ?>"
required autofocus>
<div class="form-text">Les métadonnées seront récupérées automatiquement à l'étape suivante.</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Suivant →</button>
<a href="/edit/<?= rawurlencode($importArticle['uuid']) ?>"
class="btn btn-outline-secondary">Annuler</a>
</div>
</form>
</div>
</div>
<?php
$content = ob_get_clean();
$title = 'Importer un fichier — ' . htmlspecialchars($importArticle['title']);
include __DIR__ . '/layout.php';
+212
View File
@@ -0,0 +1,212 @@
<?php
ob_start();
$isPdf = ($step2Meta['mime'] ?? '') === 'application/pdf';
$isHtml = str_starts_with($step2Meta['mime'] ?? '', 'text/html');
// Ordre + labels contextuels
$metaRows = [
'mime' => 'Type',
'size' => 'Taille',
// PDF
'pages' => 'Pages',
'page_size' => 'Format',
'pdf_version' => 'Version PDF',
// Image
'width' => 'Dimensions',
'camera' => 'Appareil',
// HTML
'site_name' => 'Site',
'og_type' => 'Type',
'language' => 'Langue',
// Commun
'author' => $isHtml ? 'Auteur' : ($isPdf ? 'Auteur' : 'Auteur EXIF'),
'date' => $isPdf ? 'Créé le' : ($isHtml ? 'Publié le' : 'Prise de vue'),
'description' => 'Description',
'subject' => 'Sujet',
'keywords' => 'Mots-clés',
'copyright' => 'Copyright',
// PDF logiciel
'creator' => 'Créé avec',
'producer' => 'Produit par',
// HTML liens
'canonical' => 'URL canonique',
'og_image' => 'Image OG',
];
$hasTitle = !empty($step2Meta['title']);
$preAuthor = $step2Meta['author'] ?? $step2Meta['credit'] ?? '';
$preSource = $step2Meta['canonical'] ?? $step2Meta['source'] ?? $step2Url;
?>
<div class="d-flex align-items-center gap-3 mb-4">
<a href="/import/<?= rawurlencode($step2Article['uuid']) ?>" class="btn btn-secondary btn-sm">← Retour</a>
<h1 class="h4 mb-0">Importer un fichier</h1>
</div>
<p class="text-muted small mb-4">
Article : <strong><?= htmlspecialchars($step2Article['title']) ?></strong>
</p>
<?php if ($step2Meta['blocked'] ?? false): ?>
<div class="alert alert-warning small mb-4">
Le site a bloqué la récupération automatique des métadonnées (protection anti-bot).
Renseignez le titre manuellement ci-dessous.
</div>
<?php endif; ?>
<?php if ($step2Screenshot ?? null): ?>
<!-- Aperçu screenshot -->
<div class="mb-4">
<p class="fw-semibold small mb-2">Aperçu de la page</p>
<?php
$previewMtime = @filemtime(BASE_PATH . '/data/' . $step2Article['uuid'] . '/files/' . $step2Screenshot) ?: time();
?>
<img src="/file?uuid=<?= rawurlencode($step2Article['uuid']) ?>&name=<?= rawurlencode($step2Screenshot) ?>&v=<?= $previewMtime ?>"
class="img-fluid rounded shadow-sm d-block"
style="max-height:320px;object-fit:cover;object-position:top"
alt="Aperçu">
</div>
<?php endif; ?>
<!-- Résumé métadonnées -->
<?php
$visibleRows = array_filter($metaRows, fn ($label, $key) => !empty($step2Meta[$key]), ARRAY_FILTER_USE_BOTH);
if ($visibleRows): ?>
<div class="card mb-4" style="max-width:640px">
<div class="card-header small fw-semibold">Métadonnées du fichier</div>
<div class="card-body p-0">
<table class="table table-sm table-borderless mb-0 small">
<tbody>
<?php foreach ($visibleRows as $key => $label): ?>
<?php
$val = $step2Meta[$key];
$cellHtml = match($key) {
'size' => htmlspecialchars(round($val / 1024) . ' Ko'),
'width' => htmlspecialchars($val . ' × ' . ($step2Meta['height'] ?? '?') . ' px'),
'og_image' => '<img src="' . htmlspecialchars((string)$val) . '" style="max-height:72px;max-width:200px;border-radius:4px" alt="">',
'canonical' => '<a href="' . htmlspecialchars((string)$val) . '" target="_blank" rel="noopener" class="small text-break">' . htmlspecialchars((string)$val) . '</a>',
default => htmlspecialchars((string)$val),
};
?>
<tr>
<th class="text-muted fw-normal ps-3 pe-3 text-nowrap align-top" style="width:130px"><?= $label ?></th>
<td class="pe-3"><?= $cellHtml ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- Formulaire -->
<div class="card" style="max-width:640px">
<div class="card-body">
<form method="POST" action="/?action=add_file_from_url&uuid=<?= rawurlencode($step2Article['uuid']) ?>">
<input type="hidden" name="image_url" value="<?= htmlspecialchars($step2Url) ?>">
<?php if ($step2Screenshot ?? null): ?>
<input type="hidden" name="screenshot_file" value="<?= htmlspecialchars($step2Screenshot) ?>">
<?php endif; ?>
<?php
$metaToStore = array_filter(
array_diff_key($step2Meta, array_flip(['ok', 'height'])),
fn ($v) => $v !== null && $v !== ''
);
?>
<input type="hidden" name="meta_json"
value="<?= htmlspecialchars(json_encode($metaToStore, JSON_UNESCAPED_UNICODE)) ?>">
<!-- Titre (obligatoire) -->
<div class="mb-3">
<label class="form-label fw-semibold">
Titre <span class="text-danger">*</span>
</label>
<input type="text" name="img_title" class="form-control"
placeholder="ex. Compte rendu du conseil municipal"
value="<?= htmlspecialchars($step2Meta['title'] ?? '') ?>"
required autofocus>
<?php if (!$hasTitle): ?>
<div class="form-text text-warning small">
Titre non trouvé dans les métadonnées — saisie requise.
</div>
<?php endif; ?>
</div>
<!-- Mode -->
<div class="mb-4">
<p class="form-label fw-semibold mb-2">Mode</p>
<div class="form-check mb-1">
<input class="form-check-input" type="radio" name="mode" id="mode_link" value="link" checked>
<label class="form-check-label" for="mode_link">
<strong>Lien externe</strong>
<span class="text-muted small"> — insère une référence, le fichier reste chez l'hôte</span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="mode" id="mode_download" value="download">
<label class="form-check-label" for="mode_download">
<strong>Télécharger sur le serveur</strong>
<span class="text-muted small"> — copie locale du fichier</span>
</label>
</div>
<?php if ($step2Screenshot ?? null): ?>
<div class="form-check">
<input class="form-check-input" type="radio" name="mode" id="mode_screenshot" value="screenshot">
<label class="form-check-label" for="mode_screenshot">
<strong>Enregistrer la capture d'écran</strong>
<span class="text-muted small"> — sauvegarde l'aperçu comme image</span>
</label>
</div>
<?php endif; ?>
<div id="copyright-warning" class="alert alert-warning mt-3 mb-0 small" style="display:none">
<strong>Droits d'auteur.</strong> Une page de confirmation légale vous sera
présentée avant le téléchargement.
</div>
</div>
<!-- Auteur / source : toujours visibles pour les pages web, download-only sinon -->
<div id="download-fields" <?= $isHtml ? '' : 'style="display:none"' ?>>
<?php if ($preAuthor || $isHtml): ?>
<div class="mb-3">
<label class="form-label fw-semibold">Auteur / crédit</label>
<input type="text" name="img_author" class="form-control"
placeholder="ex. Jane Doe"
value="<?= htmlspecialchars($preAuthor) ?>">
</div>
<?php endif; ?>
<div class="mb-3">
<label class="form-label fw-semibold">URL source</label>
<input type="url" name="img_source" class="form-control font-monospace"
placeholder="https://…"
value="<?= htmlspecialchars($preSource) ?>">
<div class="form-text">Laissé vide → URL du fichier utilisée comme source.</div>
</div>
<?php if (!$isHtml || ($step2Screenshot ?? null)): ?>
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_cover" id="is_cover">
<label class="form-check-label" for="is_cover">
Définir comme image de couverture
<span class="text-muted small">(images uniquement, sera nommée <code>cover.jpg</code>)</span>
</label>
</div>
</div>
<?php endif; ?>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Valider</button>
<a href="/edit/<?= rawurlencode($step2Article['uuid']) ?>"
class="btn btn-outline-secondary">Annuler</a>
</div>
</form>
</div>
</div>
<?php
$content = ob_get_clean();
$title = 'Importer un fichier — ' . htmlspecialchars($step2Article['title']);
include __DIR__ . '/layout.php';
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="<?= htmlspecialchars(siteLang()) ?>">
<head>
<meta charset="UTF-8">
<title><?= htmlspecialchars(($seoTitle ?? '') ?: ($title ?? siteTitle())) ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- SEO -->
<meta name="description" content="<?= htmlspecialchars(($seoDescription ?? '') ?: siteClaim()) ?>">
<meta name="robots" content="<?= htmlspecialchars($metaRobots ?? 'index, follow') ?>">
<link rel="canonical" href="<?= htmlspecialchars($canonical ?? $ogUrl ?? rtrim(APP_URL, '/') . '/') ?>">
<!-- Open Graph -->
<meta property="og:title" content="<?= htmlspecialchars(($seoTitle ?? '') ?: ($title ?? siteTitle())) ?>">
<meta property="og:description" content="<?= htmlspecialchars(($seoDescription ?? '') ?: siteClaim()) ?>">
<meta property="og:type" content="<?= htmlspecialchars($ogType ?? 'website') ?>">
<meta property="og:locale" content="<?= htmlspecialchars(siteLangOgLocale()) ?>">
<meta property="og:url" content="<?= htmlspecialchars($ogUrl ?? APP_URL) ?>">
<meta property="og:site_name" content="<?= htmlspecialchars(siteTitle()) ?>">
<?php if (!empty($ogImage ?? '')): ?>
<meta property="og:image" content="<?= htmlspecialchars($ogImage) ?>">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<?php endif; ?>
<?php if (!empty($metaAuthor ?? '')): ?>
<meta name="author" content="<?= htmlspecialchars($metaAuthor) ?>">
<?php endif; ?>
<?php if (!empty($articlePublishedAt ?? '')): ?>
<meta property="article:published_time" content="<?= htmlspecialchars(date('c', strtotime((string)$articlePublishedAt))) ?>">
<?php endif; ?>
<?php if (!empty($metaAuthorUrl ?? '')): ?>
<meta property="article:author" content="<?= htmlspecialchars($metaAuthorUrl) ?>">
<?php elseif (!empty($metaAuthor ?? '')): ?>
<meta property="article:author" content="<?= htmlspecialchars($metaAuthor) ?>">
<?php endif; ?>
<?php if (!empty($jsonLd ?? '')): ?>
<script type="application/ld+json"><?= $jsonLd ?></script>
<?php endif; ?>
<!-- RSS autodiscovery -->
<link rel="alternate" type="application/rss+xml" title="<?= htmlspecialchars(siteTitle()) ?>" href="/feed">
<!-- Favicon -->
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
<!-- CSS -->
<link href="/assets/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body<?php if (!empty($bodyClass ?? '')): ?> class="<?= htmlspecialchars($bodyClass) ?>"<?php endif; ?>>
<header>
<nav class="navbar navbar-expand-lg navbar-dark mb-0" role="navigation" aria-label="Navigation principale">
<div class="container-fluid">
<?php
$_layoutAction = $_GET['action'] ?? 'list';
$_layoutPrivateCats = isset($articles) ? $articles->getPrivateCategories() : [];
$_layoutCats = isset($articles) ? array_filter(
$articles->getCategories(),
function ($cat) use ($_layoutPrivateCats) {
return isLoggedIn() || !in_array($cat, $_layoutPrivateCats, true);
},
ARRAY_FILTER_USE_KEY
) : [];
$_layoutCurrentCat = trim($_GET['cat'] ?? '');
?>
<a class="navbar-brand d-flex flex-column lh-1" href="/">
<span><?= htmlspecialchars(siteTitle()) ?></span>
<small class="navbar-tagline"><?= htmlspecialchars(siteClaim()) ?></small>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent" aria-controls="navbarContent" aria-expanded="false" aria-label="Basculer la navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav me-auto"></ul>
<form class="search-form d-flex" action="/search" method="GET" role="search">
<input class="form-control form-control-sm search-input"
type="search" name="q"
value="<?= htmlspecialchars($_GET['q'] ?? '') ?>"
placeholder="Rechercher…"
aria-label="Rechercher">
</form>
<ul class="navbar-nav">
<?php if (function_exists('isLoggedIn') && isLoggedIn()): ?>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
<?= htmlspecialchars(function_exists('currentUserName') ? currentUserName() : (currentUserEmail() ?? '')) ?>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/profile">Mon identité</a></li>
<li><a class="dropdown-item" href="/admin"><?= (function_exists('isAdmin') && isAdmin()) ? 'Administration' : 'Mes articles' ?></a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-muted" href="/logout.php">Déconnexion</a></li>
</ul>
</li>
<?php else: ?>
<li class="nav-item"><a class="nav-link" href="/login">Connexion</a></li>
<?php endif; ?>
</ul>
</div>
</div>
</nav>
</header>
<main class="<?= htmlspecialchars($mainClass ?? 'container') ?>" role="main">
<?= $content ?>
</main>
<footer class="py-5 mt-5" role="contentinfo">
<div class="container">
<div class="footer-inner">
<div class="footer-about">
<strong><?= htmlspecialchars(siteTitle()) ?></strong>
<p><?= htmlspecialchars(siteClaim()) ?></p>
<small>&copy; <?= date('Y') ?> &mdash; <a href="<?= htmlspecialchars(siteLicenseUrl()) ?>" target="_blank" rel="noopener"><?= htmlspecialchars(siteLicenseLabel()) ?></a></small>
</div>
<nav class="footer-nav" aria-label="Liens du site">
<a href="/about">À propos</a>
<a href="/contact">Contact</a>
<a href="/legal">Mentions légales</a>
<a href="/licenses">Licences</a>
</nav>
</div>
</div>
</footer>
<!-- JS -->
<script src="/assets/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script>
<?php if (isset($reactionStats)): ?>
<script src="/assets/js/reactions.js"></script>
<?php endif; ?>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
<?php
ob_start();
?>
<div class="posts-list site-page">
<?= $siteContent ?>
</div>
<?php
$content = ob_get_clean();
$title = ($siteMeta['seo_title'] ?? $siteMeta['title'] ?? 'Mentions légales') . ' — ' . siteTitle();
include __DIR__ . '/layout.php';
+10
View File
@@ -0,0 +1,10 @@
<?php
ob_start();
?>
<div class="posts-list site-page">
<?= $siteContent ?>
</div>
<?php
$content = ob_get_clean();
$title = ($siteMeta['seo_title'] ?? $siteMeta['title'] ?? 'Licences') . ' — ' . siteTitle();
include __DIR__ . '/layout.php';
+49
View File
@@ -0,0 +1,49 @@
<?php ob_start(); ?>
<div class="liens-page">
<div class="liens-header">
<div class="liens-avatar"><?= htmlspecialchars($_lInitials) ?></div>
<h1 class="liens-name"><?= htmlspecialchars($_lName) ?></h1>
<?php if ($_lBio !== ''): ?>
<p class="liens-bio"><?= nl2br(htmlspecialchars($_lBio)) ?></p>
<?php endif; ?>
</div>
<?php if (!empty($profileLinks)): ?>
<hr class="liens-sep">
<?php endif; ?>
<div class="liens-list">
<?php
$_palette = ['#0d9488','#16a34a','#d97706','#db2777','#7c3aed','#1d4ed8','#0ea5e9'];
foreach ($profileLinks as $_i => $_link):
$_btnBg = $_palette[$_i % count($_palette)];
?>
<a href="<?= htmlspecialchars($_link['url']) ?>" target="_blank" rel="noopener"
class="liens-item" style="--btn-bg:<?= htmlspecialchars($_btnBg) ?>">
<span class="liens-item-title"><?= htmlspecialchars($_link['title'] ?: $_link['url']) ?></span>
<?php if ($_link['description'] !== ''): ?>
<span class="liens-item-desc"><?= htmlspecialchars($_link['description']) ?></span>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
<?php if ($_lSlug !== ''): ?>
<div class="liens-footer">
<a href="/profil/<?= rawurlencode($_lSlug) ?>"><?= htmlspecialchars($_lName) ?> sur <?= htmlspecialchars(siteTitle()) ?></a>
</div>
<?php endif; ?>
</div>
<?php
$bodyClass = 'liens-bg';
$content = ob_get_clean();
$title = 'Liens de ' . htmlspecialchars($_lName) . ' — ' . siteTitle();
$seoTitle = $title;
$canonical = rtrim(APP_URL, '/') . '/liens/' . rawurlencode($_lSlug);
$ogUrl = $canonical;
$mainClass = 'container-fluid';
include __DIR__ . '/layout.php';
+268
View File
@@ -0,0 +1,268 @@
<?php
ob_start();
$CONTEXT = 3;
$base = rtrim(APP_URL, '/');
$effectiveTitle = ($seoTitle !== '') ? $seoTitle : $title;
$effectiveDesc = ($seoDescription !== '') ? $seoDescription : $autoSeoDesc;
$effectiveSlug = $postSlug;
$coverFilename = ($newCover ?? '') !== '' ? $newCover : ($article['cover'] ?? '');
$suggestedOgImage = $coverFilename !== ''
? $base . '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($coverFilename)
: '';
?>
<div class="d-flex align-items-center gap-3 mb-4 flex-wrap">
<a href="/edit/<?= htmlspecialchars($uuid) ?>" class="btn btn-outline-secondary">← Retour à l'édition</a>
<h1 class="h4 mb-0">Confirmer les modifications</h1>
</div>
<form method="POST" action="/edit/<?= htmlspecialchars($uuid) ?>">
<input type="hidden" name="_confirm" value="1">
<input type="hidden" name="title" value="<?= htmlspecialchars($title) ?>">
<input type="hidden" name="content" value="<?= htmlspecialchars($content) ?>">
<?php if ($published): ?>
<input type="hidden" name="published" value="1">
<?php endif; ?>
<input type="hidden" name="published_at" value="<?= htmlspecialchars($published_at) ?>">
<!-- seo_title et seo_description sont des champs visibles dans la colonne droite -->
<!-- og_image est calculé depuis la couverture côté serveur -->
<input type="hidden" name="category" value="<?= htmlspecialchars($category) ?>">
<?php foreach ($confirmTags ?? [] as $_ctk => $_ctv): ?>
<input type="hidden" name="tags[<?= htmlspecialchars($_ctk) ?>]" value="<?= htmlspecialchars($_ctv) ?>">
<?php endforeach; ?>
<?php if (($newCover ?? '') !== ''): ?>
<input type="hidden" name="cover_file" value="<?= htmlspecialchars($newCover) ?>">
<?php endif; ?>
<?php foreach ($fmetaNames as $fi => $fname): ?>
<input type="hidden" name="fmeta_name[]" value="<?= htmlspecialchars($fname) ?>">
<input type="hidden" name="fmeta_author[]" value="<?= htmlspecialchars($fmetaAuthors[$fi] ?? '') ?>">
<input type="hidden" name="fmeta_source[]" value="<?= htmlspecialchars($fmetaSources[$fi] ?? '') ?>">
<?php endforeach; ?>
<div class="row g-4">
<div class="col-lg-8">
<?php if (!empty($changes)): ?>
<div class="alert alert-info py-2 mb-3">
<?= htmlspecialchars(ucfirst(implode(' · ', $changes))) ?>
</div>
<?php else: ?>
<div class="alert alert-secondary py-2 mb-3">Aucune modification détectée.</div>
<?php endif; ?>
<!-- ─── Diff ─────────────────────────────────────────────────────────────── -->
<div class="mb-4">
<h2 class="h6 fw-semibold mb-2">Diff du contenu</h2>
<?php if ($diffLines === []): ?>
<div class="text-muted small">Contenu identique.</div>
<?php else:
$total = count($diffLines);
$show = [];
for ($i = 0; $i < $total; $i++) {
if ($diffLines[$i][0] !== '=') {
for ($c = max(0, $i - $CONTEXT); $c <= min($total - 1, $i + $CONTEXT); $c++) {
$show[$c] = true;
}
}
}
$hasChanges = false;
foreach ($diffLines as [$op]) {
if ($op !== '=') {
$hasChanges = true;
break;
}
}
?>
<div class="d-flex gap-3 mb-1 small">
<span class="diff-del px-2 py-1 rounded"> Supprimé</span>
<span class="diff-ins px-2 py-1 rounded">+ Ajouté</span>
</div>
<div class="diff-view font-monospace small">
<?php $inEllipsis = false;
for ($i = 0; $i < $total; $i++): ?>
<?php [$op, $line] = $diffLines[$i]; ?>
<?php if (!isset($show[$i])): ?>
<?php if (!$inEllipsis): $inEllipsis = true; ?>
<div class="diff-ellipsis text-muted px-2">⋯</div>
<?php endif;
continue; ?>
<?php else: $inEllipsis = false; endif; ?>
<?php if ($op === '!'): ?>
<div class="diff-warning text-warning px-2"><?= htmlspecialchars($line) ?></div>
<?php elseif ($op === '-'): ?>
<div class="diff-del px-2">&nbsp;<?= htmlspecialchars($line) ?></div>
<?php elseif ($op === '+'): ?>
<div class="diff-ins px-2">+&nbsp;<?= htmlspecialchars($line) ?></div>
<?php else: ?>
<div class="diff-eq px-2 text-muted">&nbsp;&nbsp;<?= htmlspecialchars($line) ?></div>
<?php endif; ?>
<?php endfor; ?>
</div>
<?php endif; ?>
</div>
<!-- ─── Slug ─────────────────────────────────────────────────────────────── -->
<?php
$slugDefault = ($titleChanged && $autoSlug !== $postSlug) ? $autoSlug : $postSlug;
$slugOriginal = $postSlug;
?>
<div class="mb-3">
<label for="confirm-slug" class="form-label fw-semibold">
Slug (URL permanente)
<small class="text-muted fw-normal">— /post/<span id="slug-display"><?= htmlspecialchars($slugDefault) ?></span></small>
</label>
<input type="text" class="form-control form-control-sm font-monospace" id="confirm-slug" name="slug"
value="<?= htmlspecialchars($slugDefault) ?>"
pattern="[a-z0-9][a-z0-9\-]*"
oninput="document.getElementById('slug-display').textContent=this.value">
<?php if ($titleChanged && $autoSlug !== $slugOriginal): ?>
<div class="mt-2 d-flex align-items-center gap-2 flex-wrap">
<small class="text-muted">Slug recalculé depuis le nouveau titre. Slug initial :</small>
<code class="small"><?= htmlspecialchars($slugOriginal) ?></code>
<button type="button" id="slug-btn-keep" class="btn btn-sm btn-outline-secondary py-0"
data-slug-keep="<?= htmlspecialchars($slugOriginal) ?>">
Conserver
</button>
</div>
<?php endif; ?>
</div>
<!-- ─── Commentaire de révision ──────────────────────────────────────────── -->
<div class="mb-4">
<label for="revision_comment" class="form-label fw-semibold">
Commentaire de révision <small class="text-muted fw-normal">(optionnel)</small>
</label>
<input type="text" class="form-control" id="revision_comment" name="revision_comment"
value="<?= htmlspecialchars($autoRevisionComment) ?>"
placeholder="ex. Correction typos, ajout section X…">
</div>
<div class="d-flex gap-3 flex-wrap">
<button type="submit" class="btn btn-success">Confirmer et enregistrer</button>
<a href="/edit/<?= htmlspecialchars($uuid) ?>" class="btn btn-outline-secondary">← Retour</a>
</div>
</div><!-- /col-lg-8 -->
<!-- ─── SEO ───────────────────────────────────────────────────────────────── -->
<div class="col-lg-4">
<div class="card border-secondary mb-3">
<div class="card-header bg-transparent py-2">
<span class="fw-semibold small">SEO — titre, description, image</span>
</div>
<div class="card-body">
<div class="mb-3">
<label for="seo_title" class="form-label small">
Titre SEO <small class="text-muted">(og:title, &lt;title&gt;)</small>
</label>
<input type="text" class="form-control form-control-sm" id="seo_title" name="seo_title"
maxlength="70"
value="<?= htmlspecialchars($seoTitle) ?>"
placeholder="<?= htmlspecialchars($title) ?>">
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Idéal : 3060 car.</small>
<small id="seo_title_counter" class="text-muted">0 / 60</small>
</div>
</div>
<div class="mb-3">
<label for="seo_description" class="form-label small">
Description SEO <small class="text-muted">(meta description)</small>
</label>
<textarea class="form-control form-control-sm" id="seo_description" name="seo_description"
rows="3" maxlength="200"
placeholder="<?= htmlspecialchars(mb_strimwidth($autoSeoDesc, 0, 80, '…')) ?>"><?= htmlspecialchars($seoDescription) ?></textarea>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Idéal : 120155 car.</small>
<small id="seo_desc_counter" class="text-muted">0 / 155</small>
</div>
</div>
</div>
</div>
<div class="card border-secondary">
<div class="card-header bg-transparent py-2">
<span class="fw-semibold small">Aperçu dans les moteurs de recherche</span>
</div>
<div class="card-body p-3">
<?php
$pubTs = strtotime((string)($published_at ?? $article['published_at'] ?? ''));
$modTs = time(); // sera mis à jour à la sauvegarde
$pubFmt = $pubTs ? date('d/m/Y H:i', $pubTs) : '—';
$modFmt = date('d/m/Y H:i', $modTs);
$catVal = trim($category ?? '');
?>
<div class="seo-preview mb-3">
<div class="seo-preview-url small text-truncate mb-1" id="preview-url">
<?= htmlspecialchars(rtrim($base, '/') . '/post/' . $effectiveSlug) ?>
</div>
<div class="seo-preview-title mb-1" id="preview-title">
<?= htmlspecialchars($effectiveTitle) ?>
</div>
<div class="seo-preview-desc small" id="preview-desc">
<?= htmlspecialchars($effectiveDesc) ?>
</div>
</div>
<table class="table table-sm table-borderless mb-0 small">
<tbody>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap" style="width:1%">Auteur</th>
<td><?= htmlspecialchars(currentUserName() ?: (currentUserEmail() ?? '—')) ?></td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Publication</th>
<td><?= htmlspecialchars($pubFmt) ?></td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Modification</th>
<td><?= htmlspecialchars($modFmt) ?> <small class="text-muted">(après enreg.)</small></td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Langue</th>
<td>fr-FR</td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Catégorie</th>
<td><?= $catVal !== '' ? htmlspecialchars($catVal) : '<span class="text-muted">—</span>' ?></td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">og:image</th>
<td class="text-truncate" style="max-width:0">
<?= $suggestedOgImage !== '' ? '<span title="' . htmlspecialchars($suggestedOgImage) . '">' . htmlspecialchars($coverFilename) . '</span>' : '<span class="text-muted">— (pas de couverture)</span>' ?>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div><!-- /row -->
</form>
<style>
.diff-view { border: 1px solid var(--bs-border-color, #dee2e6); border-radius: 6px; overflow-x: auto; }
.diff-view > div { padding: 1px 8px; white-space: pre; line-height: 1.5; }
.diff-del { background: #ffeef0; color: #b91c1c; }
.diff-ins { background: #e6ffec; color: #15803d; }
.diff-eq { }
.diff-ellipsis { background: #f8f9fa; padding: 2px 8px; user-select: none; }
.seo-preview { border: 1px solid #dee2e6; border-radius: 6px; padding: 10px 12px; background: #fff; }
.seo-preview-url { color: #006621; font-size: .78rem; }
.seo-preview-title { color: #1a0dab; font-size: 1.05rem; font-weight: 500; line-height: 1.3; word-break: break-word; }
.seo-preview-desc { color: #545454; line-height: 1.5; }
</style>
<div id="pc-data" hidden
data-default-title="<?= htmlspecialchars($effectiveTitle) ?>"
data-default-desc="<?= htmlspecialchars($effectiveDesc) ?>"
data-base-url="<?= htmlspecialchars(rtrim($base, '/') . '/post/') ?>"></div>
<script src="/assets/js/post_confirm.js"></script>
<?php
$content = ob_get_clean();
$title = 'Confirmer — ' . $title;
include __DIR__ . '/layout.php';
+515
View File
@@ -0,0 +1,515 @@
<?php
ob_start();
$dateValue = isset($published_at)
? (str_contains($published_at, ' ')
? date('Y-m-d\TH:i', strtotime($published_at))
: $published_at)
: date('Y-m-d\TH:i');
?>
<?php if ($action === 'edit'): ?>
<div id="vl-page"
data-uuid="<?= htmlspecialchars($uuid) ?>"
data-insert-url="<?= htmlspecialchars($insertUrl ?? '') ?>"
hidden></div>
<form method="POST" action="<?= htmlspecialchars($formAction) ?>">
<div class="row g-4">
<div class="col-lg-8">
<h1 class="mb-4">Modifier l'article</h1>
<?php else: ?>
<h1 class="mb-4">Nouvel article</h1>
<form method="POST" action="<?= htmlspecialchars($formAction) ?>" enctype="multipart/form-data">
<?php endif; ?>
<?php if (!empty($errors)): ?>
<div class="alert alert-danger">
<ul class="mb-0">
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="mb-3">
<label for="title" class="form-label">Titre</label>
<input type="text" class="form-control" id="title" name="title" required
value="<?= htmlspecialchars($title ?? '') ?>">
</div>
<?php if ($action === 'edit'): ?>
<input type="hidden" id="slug" name="slug" value="<?= htmlspecialchars($postSlug ?? '') ?>">
<?php else: ?>
<div class="mb-3">
<label for="slug" class="form-label">
Slug <small class="text-muted">(URL : /post/<span id="slug-preview"><?= htmlspecialchars($postSlug ?? '') ?></span>)</small>
</label>
<input type="text" class="form-control form-control-sm font-monospace" id="slug" name="slug"
value="<?= htmlspecialchars($postSlug ?? '') ?>"
pattern="[a-z0-9][a-z0-9\-]*"
placeholder="généré automatiquement depuis le titre">
</div>
<?php endif; ?>
<?php if ($action === 'create'): ?>
<div class="mb-3">
<label for="category" class="form-label">Catégorie</label>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control form-control-sm" id="category" name="category"
value="<?= htmlspecialchars($category ?? '') ?>"
placeholder="ex : informatique, loisirs, photo…"
autocomplete="off">
<div id="cat-swatch" title="" style="width:40px;height:28px;border-radius:6px;flex-shrink:0;background:#e5e7eb;transition:background .25s"></div>
</div>
<small id="cat-hint" class="text-muted d-block mt-1"></small>
<div id="cat-free-swatches" class="d-flex flex-wrap gap-1 mt-2"></div>
</div>
<?php if (!empty($tagTypes)): ?>
<div class="mb-3">
<p class="form-label fw-semibold small mb-2">Tags</p>
<?php foreach ($tagTypes as $_tagKey => $_tagLabel):
$_tagVal = implode(', ', $articleTags[$_tagKey] ?? []);
$_suggestId = 'tag-suggest-create-' . htmlspecialchars($_tagKey);
$_tagVals = $allTagValues[$_tagKey] ?? [];
?>
<div class="mb-2">
<label class="form-label small" for="tag-create-<?= htmlspecialchars($_tagKey) ?>">
<?= htmlspecialchars($_tagLabel) ?>
</label>
<input type="text" class="form-control form-control-sm"
id="tag-create-<?= htmlspecialchars($_tagKey) ?>"
name="tags[<?= htmlspecialchars($_tagKey) ?>]"
value="<?= htmlspecialchars($_tagVal) ?>"
placeholder="valeur1, valeur2…"
list="<?= $_suggestId ?>">
<datalist id="<?= $_suggestId ?>">
<?php foreach ($_tagVals as $_v): ?>
<option value="<?= htmlspecialchars($_v) ?>">
<?php endforeach; ?>
</datalist>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<div class="mb-2">
<small class="text-muted">
Écris en <strong>Markdown</strong> — les fichiers uploadés sont référençables dans le contenu :
<code>![alt](nom-du-fichier.jpg)</code>
</small>
</div>
<div class="mb-3">
<label for="content" class="form-label">Contenu</label>
<textarea class="form-control font-monospace" id="content" name="content" rows="12"><?= htmlspecialchars($content ?? '') ?></textarea>
</div>
<?php if ($action === 'create'): ?>
<div class="row mb-3">
<div class="col-md-6">
<label for="published_at" class="form-label">Date de publication</label>
<input type="datetime-local" class="form-control" id="published_at" name="published_at" value="<?= $dateValue ?>">
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="published" name="published"
<?= ($published ?? false) ? 'checked' : '' ?>>
<label class="form-check-label" for="published">Publié</label>
</div>
</div>
</div>
<div class="mb-3">
<label for="files" class="form-label">Ajouter des fichiers</label>
<input type="file" class="form-control" id="files" name="files[]" multiple>
<div class="form-text">Images → nommées <code>sha256-taille.ext</code>. Vidéos, PDF → nom sanitisé.</div>
</div>
<?php endif; ?>
<?php if ($action === 'edit'): ?>
</div><!-- /col-lg-8 -->
<div class="col-lg-4">
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<label for="category" class="form-label mb-0">Catégorie</label>
<a href="/edit/<?= rawurlencode($uuid) ?>/tags/categorie"
class="btn btn-outline-secondary btn-sm py-0 px-1"
style="font-size:.7rem" title="Suggestions">⚡ Suggestions</a>
</div>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control form-control-sm" id="category" name="category"
value="<?= htmlspecialchars($category ?? '') ?>"
placeholder="ex : informatique, loisirs, photo…"
autocomplete="off">
<div id="cat-swatch" title="" style="width:40px;height:28px;border-radius:6px;flex-shrink:0;background:#e5e7eb;transition:background .25s"></div>
</div>
<small id="cat-hint" class="text-muted d-block mt-1"></small>
<div id="cat-free-swatches" class="d-flex flex-wrap gap-1 mt-2"></div>
</div>
<?php if (!empty($tagTypes)): ?>
<div class="mb-3">
<p class="form-label fw-semibold small mb-2">Tags</p>
<?php foreach ($tagTypes as $_tagKey => $_tagLabel):
$_tagVal = implode(', ', $articleTags[$_tagKey] ?? []);
$_suggestId = 'tag-suggest-' . htmlspecialchars($_tagKey);
$_tagVals = $allTagValues[$_tagKey] ?? [];
?>
<div class="mb-2">
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label small mb-0" for="tag-<?= htmlspecialchars($_tagKey) ?>">
<?= htmlspecialchars($_tagLabel) ?>
</label>
<?php if (isset($uuid)): ?>
<a href="/edit/<?= rawurlencode($uuid) ?>/tags/<?= rawurlencode($_tagKey) ?>"
class="btn btn-outline-secondary btn-sm py-0 px-1"
style="font-size:.7rem" title="Suggestions automatiques">⚡ Suggestions</a>
<?php endif; ?>
</div>
<input type="text" class="form-control form-control-sm"
id="tag-<?= htmlspecialchars($_tagKey) ?>"
name="tags[<?= htmlspecialchars($_tagKey) ?>]"
value="<?= htmlspecialchars($_tagVal) ?>"
placeholder="valeur1, valeur2…"
list="<?= $_suggestId ?>">
<datalist id="<?= $_suggestId ?>">
<?php foreach ($_tagVals as $_v): ?>
<option value="<?= htmlspecialchars($_v) ?>">
<?php endforeach; ?>
</datalist>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="mb-3">
<label for="published_at" class="form-label">Date de publication</label>
<input type="datetime-local" class="form-control" id="published_at" name="published_at" value="<?= $dateValue ?>">
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="published" name="published"
<?= ($published ?? false) ? 'checked' : '' ?>>
<label class="form-check-label" for="published">Publié</label>
</div>
</div>
<?php if (function_exists('isAdmin') && isAdmin()): ?>
<div class="mb-3">
<form method="POST" action="/edit/<?= htmlspecialchars($uuid) ?>">
<input type="hidden" name="_toggle_featured" value="1">
<button type="submit" class="btn btn-sm <?= !empty($article['featured'] ?? false) ? 'btn-warning' : 'btn-outline-secondary' ?> w-100">
<?= !empty($article['featured'] ?? false) ? '★ À la une (actif)' : '☆ Mettre à la une' ?>
</button>
</form>
</div>
<?php endif; ?>
<div class="d-flex align-items-center gap-3 flex-wrap mb-4">
<button type="submit" class="btn btn-success">Enregistrer</button>
<a href="/" class="btn btn-secondary">Annuler</a>
<span id="autosave-indicator" class="text-muted small"></span>
</div>
<hr class="my-3">
<?php if (!empty($existingFiles)): ?>
<?php $coverFile = $article['cover'] ?? ''; ?>
<?php $filesMeta = $article['files_meta'] ?? []; ?>
<div class="mb-3">
<p class="form-label fw-semibold small mb-2">Fichiers existants</p>
<div class="list-group">
<?php foreach ($existingFiles as $i => $f): ?>
<?php
$fileUrl = '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($f['name']);
$fmeta = $filesMeta[$f['name']] ?? [];
$isCoverFile = ($f['name'] === $coverFile);
?>
<div class="list-group-item py-2 px-2">
<div class="d-flex align-items-start gap-2">
<!-- Miniature -->
<?php if ($f['is_image']): ?>
<a href="<?= htmlspecialchars($fileUrl) ?>" target="_blank" rel="noopener" class="flex-shrink-0">
<img src="<?= htmlspecialchars($fileUrl) ?>" alt=""
style="width:48px;height:48px;object-fit:cover;border-radius:4px;<?= $isCoverFile ? 'outline:2px solid #0d6efd' : '' ?>">
</a>
<?php else: ?>
<span style="width:48px;text-align:center;font-size:1.4rem;flex-shrink:0;line-height:48px">
<?= match(true) {
str_starts_with($f['mime'], 'video/') => '🎬',
str_starts_with($f['mime'], 'audio/') => '🎵',
$f['mime'] === 'application/pdf' => '📑',
default => '📄',
} ?>
</span>
<?php endif; ?>
<!-- Infos + méta -->
<div class="flex-grow-1 overflow-hidden" style="min-width:0">
<div class="d-flex align-items-center gap-1 mb-1 flex-wrap">
<code class="text-truncate small" style="max-width:100%"><?= htmlspecialchars($f['name']) ?></code>
<small class="text-muted text-nowrap"><?= number_format($f['size'] / 1024, 1) ?> Ko</small>
<?php if ($isCoverFile): ?>
<span class="badge bg-primary">cover</span>
<?php endif; ?>
</div>
<div class="d-flex gap-1 mb-1">
<input type="hidden" name="fmeta_name[]" value="<?= htmlspecialchars($f['name']) ?>">
<input type="text" name="fmeta_author[]"
class="form-control form-control-sm"
placeholder="Auteur / crédit"
value="<?= htmlspecialchars($fmeta['author'] ?? '') ?>">
<input type="url" name="fmeta_source[]"
class="form-control form-control-sm font-monospace"
placeholder="URL source"
value="<?= htmlspecialchars($fmeta['source_url'] ?? '') ?>">
</div>
<div class="d-flex align-items-center gap-1 flex-wrap">
<?php if ($f['is_image'] && !$isCoverFile): ?>
<div class="form-check mb-0">
<input class="form-check-input" type="radio"
name="cover_file" id="cover_<?= $i ?>"
value="<?= htmlspecialchars($f['name']) ?>">
<label class="form-check-label small" for="cover_<?= $i ?>">Cover</label>
</div>
<?php elseif ($isCoverFile): ?>
<input type="hidden" name="cover_file" value="<?= htmlspecialchars($f['name']) ?>">
<small class="text-primary">✓ Cover</small>
<?php endif; ?>
<div class="d-flex gap-1 ms-auto">
<button type="button" class="btn btn-sm btn-outline-secondary"
data-copy-md-name="<?= htmlspecialchars($fmeta['title'] ?? $f['name']) ?>"
data-copy-md-is-image="<?= $f['is_image'] ? '1' : '0' ?>">
MD
</button>
<button type="submit" form="del-file-<?= $i ?>"
class="btn btn-sm btn-outline-danger"
data-confirm="Supprimer « <?= htmlspecialchars($f['name']) ?> » ?">
</button>
</div>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<hr class="my-3">
<?php endif; ?>
<?php $sidebarImages = array_filter($existingFiles ?? [], fn ($f) => $f['is_image']); ?>
<?php if ($sidebarImages): ?>
<div class="mb-3">
<p class="fw-semibold small mb-2">
Images disponibles
<span class="text-muted fw-normal">(clic → insère dans le contenu)</span>
</p>
<div class="d-flex flex-wrap gap-2">
<?php foreach ($sidebarImages as $img): ?>
<?php $imgUrl = '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($img['name']); ?>
<img src="<?= htmlspecialchars($imgUrl) ?>"
alt="<?= htmlspecialchars($img['name']) ?>"
title="<?= htmlspecialchars($img['name']) ?>"
data-insert-ref="<?= htmlspecialchars($img['name']) ?>"
style="width:72px;height:72px;object-fit:cover;border-radius:6px;cursor:pointer;border:2px solid transparent;transition:border-color .15s">
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php $externalLinks = $article['external_links'] ?? []; ?>
<?php if ($externalLinks): ?>
<div class="mb-3">
<p class="fw-semibold small mb-2">Liens externes</p>
<ul class="list-group list-group-flush">
<?php foreach ($externalLinks as $extLink): ?>
<?php
$elUrl = $extLink['url'];
$elName = $extLink['name'];
$elIsImg = (bool)preg_match('/\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i', $elUrl);
?>
<li class="list-group-item px-0 py-1 d-flex align-items-center gap-2 border-0 border-bottom">
<span class="flex-shrink-0" style="font-size:1rem">
<?= $elIsImg ? '🖼' : '📄' ?>
</span>
<span class="flex-grow-1 text-truncate small"
title="<?= htmlspecialchars($elUrl) ?>"
data-insert-ref="<?= htmlspecialchars($elUrl) ?>"
style="cursor:pointer;color:#0d6efd;text-decoration:underline dotted">
<?= htmlspecialchars($elName) ?>
</span>
<form method="POST" action="/?action=delete_external_link&uuid=<?= rawurlencode($uuid) ?>"
class="d-inline flex-shrink-0">
<input type="hidden" name="url" value="<?= htmlspecialchars($elUrl) ?>">
<button type="submit" class="btn btn-link btn-sm text-danger p-0 lh-1"
data-confirm="Supprimer ce lien externe ?"
title="Supprimer">✕</button>
</form>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="d-flex flex-column gap-2">
<a href="/files/<?= rawurlencode($uuid) ?>/add" class="btn btn-outline-secondary btn-sm">
+ Ajouter des fichiers
</a>
<a href="/import/<?= rawurlencode($uuid) ?>" class="btn btn-outline-secondary btn-sm">
+ Importer depuis une URL
</a>
<?php
$hasSources = !empty($article['external_links']) || !empty($existingFiles);
if ($hasSources):
?>
<a href="/sources/<?= rawurlencode($uuid) ?>" class="btn btn-outline-secondary btn-sm">
Sources &amp; métadonnées
</a>
<?php endif; ?>
</div>
</div><!-- /col-lg-4 -->
</div><!-- /row -->
</form>
<?php if (!empty($existingFiles)): ?>
<?php foreach ($existingFiles as $i => $f): ?>
<form id="del-file-<?= $i ?>" method="POST"
action="/?action=delete_file&uuid=<?= rawurlencode($uuid) ?>">
<input type="hidden" name="name" value="<?= htmlspecialchars($f['name']) ?>">
</form>
<?php endforeach; ?>
<?php endif; ?>
<?php if (!empty($article['revisions'])): ?>
<hr class="my-4">
<div>
<div class="d-flex align-items-center gap-3 mb-1">
<button class="btn btn-sm btn-link text-secondary text-decoration-none p-0 fw-semibold"
type="button" data-bs-toggle="collapse" data-bs-target="#historyPanel">
▸ Historique des révisions (<?= count($article['revisions']) ?>)
</button>
<form method="POST" action="/?action=delete_all_revisions&uuid=<?= rawurlencode($uuid) ?>" class="d-inline ms-auto">
<button type="submit" class="btn btn-sm btn-outline-danger"
data-confirm="Supprimer tout l'historique des révisions ?">
Tout supprimer
</button>
</form>
</div>
<div class="collapse mt-3" id="historyPanel">
<table class="table table-sm table-hover align-middle">
<thead>
<tr><th>#</th><th>Date</th><th>Titre à l'époque</th><th>Commentaire</th><th></th></tr>
</thead>
<tbody>
<?php foreach (array_reverse($article['revisions']) as $rev): ?>
<?php $revN = (int)($rev['n'] ?? 0); ?>
<tr>
<td class="text-muted small"><?= $revN ?></td>
<td class="small text-nowrap">
<?= htmlspecialchars(date('d/m/Y H:i', strtotime((string)($rev['date'] ?? '')))) ?>
</td>
<td class="small text-truncate" style="max-width:200px">
<?= htmlspecialchars($rev['title'] ?? '') ?>
</td>
<td class="small text-muted">
<?= htmlspecialchars($rev['comment'] ?? '') ?: '<span class="text-muted"></span>' ?>
</td>
<td class="text-nowrap">
<a href="/diff/<?= rawurlencode($uuid) ?>/<?= $revN ?>"
class="btn btn-outline-secondary btn-sm" target="_blank">
Diff
</a>
<form method="POST" action="/?action=delete_revision&uuid=<?= rawurlencode($uuid) ?>" class="d-inline">
<input type="hidden" name="rev_n" value="<?= $revN ?>">
<button type="submit" class="btn btn-sm btn-outline-danger"
data-confirm="Supprimer la révision #<?= $revN ?> ?">
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php else: /* create */ ?>
<div class="card mb-3 border-secondary">
<div class="card-header bg-transparent py-2">
<button class="btn btn-sm btn-link text-secondary text-decoration-none p-0 fw-semibold"
type="button" data-bs-toggle="collapse" data-bs-target="#seoPanel" aria-expanded="false">
▸ SEO — titre, description, image
</button>
</div>
<div class="collapse" id="seoPanel">
<div class="card-body">
<div class="mb-3">
<label for="seo_title" class="form-label">
Titre SEO
<small class="text-muted">(balise &lt;title&gt; et og:title)</small>
</label>
<input type="text" class="form-control" id="seo_title" name="seo_title"
maxlength="70"
value="<?= htmlspecialchars($seoTitle ?? '') ?>"
placeholder="Généré automatiquement depuis le titre">
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Idéal : 3060 caractères</small>
<small id="seo_title_counter" class="text-muted">0 / 60</small>
</div>
</div>
<div class="mb-3">
<label for="seo_description" class="form-label">
Description SEO
<small class="text-muted">(meta description et og:description)</small>
</label>
<textarea class="form-control" id="seo_description" name="seo_description"
rows="3" maxlength="200"
placeholder="Générée automatiquement depuis le début du contenu"><?= htmlspecialchars($seoDescription ?? '') ?></textarea>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Idéal : 120155 caractères</small>
<small id="seo_desc_counter" class="text-muted">0 / 155</small>
</div>
</div>
<div class="mb-2">
<label for="og_image" class="form-label">
Image Open Graph
<small class="text-muted">(URL absolue, optionnel)</small>
</label>
<input type="url" class="form-control font-monospace" id="og_image" name="og_image"
value="<?= htmlspecialchars($ogImage ?? '') ?>"
placeholder="https://varlog.a5l.fr/…">
</div>
</div>
</div>
</div>
<div class="d-flex align-items-center gap-3 flex-wrap">
<button type="submit" class="btn btn-success">Enregistrer</button>
<a href="/" class="btn btn-secondary">Annuler</a>
</div>
</form>
<?php endif; ?>
<?php
$content = ob_get_clean();
$title = $action === 'edit' ? 'Modifier l\'article' : 'Nouvel article';
include __DIR__ . '/layout.php';
+330
View File
@@ -0,0 +1,330 @@
<?php
require_once BASE_PATH . '/src/Parsedown.php';
$Parsedown = new Parsedown();
ob_start();
// ─── Helpers locaux ────────────────────────────────────────────────────────
function _cardCoverStyle(array $post, array $allCats): string
{
$coverFile = $post['cover'] ?? '';
if ($coverFile !== '') {
return 'background-image: url(\'/file?uuid=' . rawurlencode($post['uuid']) . '&name=' . rawurlencode($coverFile) . '\')';
}
$cat = trim($post['category'] ?? '');
return 'background: ' . coverGradient($cat !== '' ? $cat : $post['uuid'], $allCats);
}
function _cardExcerpt(array $post, \Parsedown $pd, int $len = 120): string
{
return mb_strimwidth(strip_tags($pd->text($post['content'])), 0, $len, '…');
}
function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown $pd): void
{
$postUrl = '/post/' . rawurlencode($post['slug']);
$isDraft = !$post['published'];
$isAvantPremiere = $post['published'] && strtotime((string)($post['published_at'] ?? '')) > time();
$postCat = trim($post['category'] ?? '');
$isPrivate = $postCat !== '' && in_array($postCat, $privateCats, true);
$isLocked = $isAvantPremiere && !hasCapability('view_previews');
$coverStyle = _cardCoverStyle($post, $allCats);
$preview = _cardExcerpt($post, $pd);
?>
<article class="card">
<?php if ($isDraft): ?>
<div class="draft-ribbon">Brouillon</div>
<?php elseif ($isAvantPremiere): ?>
<div class="premiere-ribbon">Avant-première</div>
<?php elseif ($isPrivate): ?>
<div class="private-ribbon">Privé</div>
<?php endif; ?>
<div class="card-cover" style="<?= $coverStyle ?>">
<?php if ($postCat !== ''): ?>
<span class="cover-category"><?= htmlspecialchars($postCat) ?></span>
<?php endif; ?>
</div>
<div class="card-body d-flex flex-column">
<h2 class="card-title">
<?php if ($isLocked): ?>
<?= htmlspecialchars($post['title']) ?>
<?php else: ?>
<a href="<?= htmlspecialchars($postUrl) ?>"><?= htmlspecialchars($post['title']) ?></a>
<?php endif; ?>
</h2>
<p class="card-text flex-grow-1"><?= htmlspecialchars($preview) ?></p>
<div class="post-entry-meta mt-auto">
<?php if ($isAvantPremiere): ?>
<span class="text-muted">Disponible le <?= htmlspecialchars(date('d/m/Y \à H\hi', strtotime((string)($post['published_at'] ?? '')))) ?></span>
<?php else: ?>
<span><?= htmlspecialchars(date('d/m/Y', strtotime((string)($post['published_at'] ?? $post['created_at'] ?? '')))) ?></span>
<?php endif; ?>
<?php if (!$isLocked): ?>
<a href="<?= htmlspecialchars($postUrl) ?>" class="post-entry-read">→ lire</a>
<?php endif; ?>
</div>
</div>
<?php if (!$isLocked): ?>
<a href="<?= htmlspecialchars($postUrl) ?>" class="stretched-link"></a>
<?php endif; ?>
</article>
<?php
}
?>
<?php if ($isHomepage ?? false): ?>
<?php /* ─── PAGE D'ACCUEIL ─────────────────────────────────────────────── */ ?>
<div class="hero-search">
<form class="hero-search-form" action="/search" method="GET" role="search">
<input class="hero-search-input"
type="search" name="q"
value="<?= htmlspecialchars($_GET['q'] ?? '') ?>"
placeholder="Rechercher un article…"
aria-label="Rechercher un article"
autofocus>
<button type="submit" class="hero-search-btn">Rechercher</button>
</form>
<p class="hero-search-stats">
<?= $totalPublished ?> article<?= $totalPublished > 1 ? 's' : '' ?>
<?php if ($totalUpcoming > 0): ?>
· <?= $totalUpcoming ?> à venir
<?php endif; ?>
</p>
</div>
<?php /* ─── Héro + derniers articles ─────────────────────────────────── */ ?>
<?php if ($heroPost): ?>
<section class="home-section home-section--first">
<h2 class="home-section-title">
Derniers articles
<?php if (!empty($heroPost['featured'] ?? false)): ?>
<span class="home-section-badge">À la une</span>
<?php endif; ?>
</h2>
<?php
$heroUrl = '/post/' . rawurlencode($heroPost['slug']);
$heroCat = trim($heroPost['category'] ?? '');
$heroCover = $heroPost['cover'] ?? '';
$heroCoverStyle = $heroCover !== ''
? 'background-image: url(\'/file?uuid=' . rawurlencode($heroPost['uuid']) . '&name=' . rawurlencode($heroCover) . '\')'
: 'background: ' . coverGradient($heroCat !== '' ? $heroCat : $heroPost['uuid'], $allCats ?? []);
$heroExcerpt = _cardExcerpt($heroPost, $Parsedown, 200);
$heroDate = date('d/m/Y', strtotime((string)($heroPost['published_at'] ?? $heroPost['created_at'] ?? '')));
$heroLocked = $heroPost['published'] && strtotime((string)($heroPost['published_at'] ?? '')) > time() && !hasCapability('view_previews');
?>
<a href="<?= $heroLocked ? '#' : htmlspecialchars($heroUrl) ?>" class="home-hero-card">
<div class="home-hero-card-cover" style="<?= $heroCoverStyle ?>">
<?php if (!empty($heroPost['featured'] ?? false)): ?>
<span class="home-hero-badge">À la une</span>
<?php endif; ?>
<div class="home-hero-card-meta">
<?php if ($heroCat !== ''): ?>
<div class="home-hero-card-cat"><?= htmlspecialchars($heroCat) ?></div>
<?php endif; ?>
<h3 class="home-hero-card-title"><?= htmlspecialchars($heroPost['title']) ?></h3>
<p class="home-hero-card-excerpt"><?= htmlspecialchars($heroExcerpt) ?></p>
<div class="home-hero-card-date"><?= htmlspecialchars($heroDate) ?> &nbsp;→ lire</div>
</div>
</div>
</a>
<?php if (!empty($latestPosts)): ?>
<div class="post-grid">
<?php foreach ($latestPosts as $_lp): ?>
<?php _renderCard($_lp, $privateCats ?? [], $allCats ?? [], $Parsedown); ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php
$moreCursor = $allPosts[5]['uuid'] ?? null;
if ($moreCursor === null && !empty($latestPosts)) {
$moreCursor = end($latestPosts)['uuid'] ?? null;
}
?>
<?php if ($moreCursor && count($allPosts) > 6): ?>
<div class="home-more-link">
<a href="/page/<?= rawurlencode($moreCursor) ?>" class="btn btn-outline-secondary btn-sm">
Voir plus d'articles →
</a>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<?php /* ─── Tendances ───────────────────────────────────────────────────── */ ?>
<?php if (!empty($popularPosts)): ?>
<section class="home-section">
<h2 class="home-section-title">
Tendances <span class="home-section-title-sub">· 10 derniers jours</span>
</h2>
<div class="post-grid">
<?php foreach ($popularPosts as $_pp): ?>
<?php _renderCard($_pp, $privateCats ?? [], $allCats ?? [], $Parsedown); ?>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php /* ─── Récemment mis à jour ──────────────────────────────────────── */ ?>
<?php if (!empty($recentlyUpdated)): ?>
<section class="home-section">
<h2 class="home-section-title">Récemment mis à jour</h2>
<div class="home-compact-list">
<?php foreach ($recentlyUpdated as $_ru):
$_ruUrl = '/post/' . rawurlencode($_ru['slug']);
$_ruCat = trim($_ru['category'] ?? '');
$_ruThumb = _cardCoverStyle($_ru, $allCats ?? []);
$_ruDate = date('d/m/Y', strtotime((string)($_ru['updated_at'] ?? $_ru['published_at'] ?? '')));
?>
<a href="<?= htmlspecialchars($_ruUrl) ?>" class="home-compact-item">
<div class="home-compact-thumb" style="<?= $_ruThumb ?>"></div>
<div class="home-compact-meta">
<div class="home-compact-title"><?= htmlspecialchars($_ru['title']) ?></div>
<div class="home-compact-date">mis à jour le <?= htmlspecialchars($_ruDate) ?></div>
</div>
</a>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php /* ─── Redécouvertes ──────────────────────────────────────────────── */ ?>
<?php if (!empty($redecouvertes)): ?>
<section class="home-section">
<h2 class="home-section-title">Redécouvertes</h2>
<div class="home-compact-list">
<?php foreach ($redecouvertes as $_rd):
$_rdUrl = '/post/' . rawurlencode($_rd['slug']);
$_rdCat = trim($_rd['category'] ?? '');
$_rdThumb = _cardCoverStyle($_rd, $allCats ?? []);
$_rdDate = date('d/m/Y', strtotime((string)($_rd['published_at'] ?? $_rd['created_at'] ?? '')));
?>
<a href="<?= htmlspecialchars($_rdUrl) ?>" class="home-compact-item">
<div class="home-compact-thumb" style="<?= $_rdThumb ?>"></div>
<div class="home-compact-meta">
<div class="home-compact-title"><?= htmlspecialchars($_rd['title']) ?></div>
<div class="home-compact-date"><?= htmlspecialchars($_rdDate) ?></div>
</div>
</a>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php else: /* ─── VUE PAGINÉE / FILTRÉE ─────────────────────────────── */ ?>
<?php if ($cursor === '' && $filterCat === ''): ?>
<div class="hero-search">
<form class="hero-search-form" action="/search" method="GET" role="search">
<input class="hero-search-input"
type="search" name="q"
value="<?= htmlspecialchars($_GET['q'] ?? '') ?>"
placeholder="Rechercher un article…"
aria-label="Rechercher un article"
autofocus>
<button type="submit" class="hero-search-btn">Rechercher</button>
</form>
<p class="hero-search-stats">
<?= $totalPublished ?> article<?= $totalPublished > 1 ? 's' : '' ?>
<?php if ($totalUpcoming > 0): ?>· <?= $totalUpcoming ?> à venir<?php endif; ?>
</p>
</div>
<?php endif; ?>
<div class="post-grid">
<?php foreach ($posts as $post): ?>
<?php _renderCard($post, $privateCats ?? [], $allCats ?? [], $Parsedown); ?>
<?php endforeach; ?>
</div>
<?php if ($prevCursor !== null || $nextCursor !== null): ?>
<nav class="pagination-nav mt-5" aria-label="Navigation">
<?php
$hasCat = $filterCat !== '';
$catBase = $hasCat ? '/categorie/' . rawurlencode($filterCat) : null;
?>
<?php if ($prevCursor !== null): ?>
<?php
if ($prevCursor === '') {
$prevHref = $hasCat ? $catBase : '/';
} elseif ($hasCat) {
$prevHref = $catBase . '?cursor=' . rawurlencode($prevCursor);
} else {
$prevHref = '/page/' . rawurlencode($prevCursor);
}
?>
<a class="pagination-btn" href="<?= htmlspecialchars($prevHref) ?>">← Plus récents</a>
<?php endif; ?>
<?php if ($nextCursor !== null): ?>
<?php $nextHref = $hasCat ? $catBase . '?cursor=' . rawurlencode($nextCursor) : '/page/' . rawurlencode($nextCursor); ?>
<a class="pagination-btn ms-auto" href="<?= htmlspecialchars($nextHref) ?>">Plus anciens →</a>
<?php endif; ?>
</nav>
<?php endif; ?>
<?php endif; /* fin homepage / paginated */ ?>
<?php
$_tagCats = array_filter(
$allCats ?? [],
static fn ($cat) => isLoggedIn() || !in_array($cat, $privateCats ?? [], true),
ARRAY_FILTER_USE_KEY
);
arsort($_tagCats);
if (!empty($_tagCats)):
$_minCount = min($_tagCats);
$_maxCount = max($_tagCats);
?>
<nav class="tag-cloud mt-5" aria-label="Catégories">
<?php foreach ($_tagCats as $_catName => $_catCount):
$_size = $_minCount === $_maxCount
? 1.1
: round(0.8 + ($_catCount - $_minCount) / ($_maxCount - $_minCount) * 0.9, 2);
$_active = ($filterCat === $_catName);
?>
<a href="/categorie/<?= rawurlencode($_catName) ?>"
class="tag-cloud-item<?= $_active ? ' active' : '' ?>">
<?= htmlspecialchars($_catName) ?>
<span class="tag-count"><?= $_catCount ?></span>
</a>
<?php endforeach; ?>
<?php if ($filterCat !== ''): ?>
<a href="/" class="tag-cloud-reset">× tout afficher</a>
<?php endif; ?>
</nav>
<?php endif; ?>
<?php if (function_exists('isLoggedIn') && isLoggedIn()): ?>
<a href="/new" class="fab-new" title="Nouvel article" aria-label="Nouvel article">+</a>
<?php endif; ?>
<?php
$content = ob_get_clean();
$title = siteTitle();
if (!empty($cursor)) {
$metaRobots = 'noindex, follow';
$canonical = rtrim(APP_URL, '/') . '/';
} elseif ($filterCat !== '') {
$canonical = rtrim(APP_URL, '/') . '/categorie/' . rawurlencode($filterCat);
} else {
$canonical = rtrim(APP_URL, '/') . '/';
}
if (empty($cursor) && $filterCat === '') {
$jsonLd = json_encode([
'@context' => 'https://schema.org',
'@type' => 'WebSite',
'name' => siteTitle(),
'url' => rtrim(APP_URL, '/') . '/',
'description' => siteClaim(),
'inLanguage' => siteLang(),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
$mainClass = 'container-fluid';
include __DIR__ . '/layout.php';
+354
View File
@@ -0,0 +1,354 @@
<?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 = [];
$_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($rawContent)
);
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 &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';
+227
View File
@@ -0,0 +1,227 @@
<?php ob_start(); ?>
<form method="post" action="/profile">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<h1 class="h4 mb-0">Mon profil</h1>
<button type="submit" class="btn btn-primary btn-sm">Enregistrer</button>
</div>
<?php if ($profileSuccess): ?>
<div class="alert alert-success py-2 small mb-3">Profil mis à jour.</div>
<?php endif; ?>
<?php if ($profileError !== ''): ?>
<div class="alert alert-danger py-2 small mb-3"><?= htmlspecialchars($profileError) ?></div>
<?php endif; ?>
<div class="row g-4">
<!-- Colonne gauche : identité -->
<div class="col-md-4">
<div class="card h-100">
<div class="card-header small fw-semibold">Identité</div>
<div class="card-body d-flex flex-column gap-3">
<div>
<label class="form-label fw-semibold" for="display_name">Nom affiché</label>
<input type="text" id="display_name" name="display_name"
class="form-control"
value="<?= htmlspecialchars($profileCurrentName) ?>"
placeholder="Prénom Nom" required>
<div class="form-text">
Affiché comme auteur sur vos articles.
<?php if (($profileCurrentSlug ?? '') !== ''): ?>
<br>Page publique : <a href="/profil/<?= rawurlencode($profileCurrentSlug) ?>">/profil/<?= htmlspecialchars($profileCurrentSlug) ?></a>
<?php endif; ?>
</div>
</div>
<div>
<label class="form-label fw-semibold text-muted">Email</label>
<input type="text" class="form-control" value="<?= htmlspecialchars(currentUserEmail() ?? '') ?>" disabled>
</div>
</div>
</div>
</div>
<!-- Colonne droite : page publique -->
<div class="col-md-8">
<div class="card h-100">
<div class="card-header small fw-semibold">Page publique</div>
<div class="card-body d-flex flex-column gap-3">
<div>
<label class="form-label fw-semibold" for="bio">Biographie</label>
<textarea id="bio" name="bio" class="form-control" rows="5"
placeholder="Quelques mots sur vous…"><?= htmlspecialchars($profileCurrentBio ?? '') ?></textarea>
<div class="form-text">Affichée sur votre page de profil public.</div>
</div>
<div>
<label class="form-label fw-semibold" for="profile_url">URL externe</label>
<input type="url" id="profile_url" name="profile_url"
class="form-control"
value="<?= htmlspecialchars($profileCurrentUrl ?? '') ?>"
placeholder="https://example.com/~vous">
<div class="form-text">Lien vers un site ou profil externe (utilisé dans les métadonnées article:author, JSON-LD).</div>
</div>
</div>
</div>
</div>
</div>
</form>
<!-- Flux RSS -->
<!-- Mes liens -->
<div class="mt-4" id="links">
<div class="d-flex align-items-center gap-3 mb-3">
<h2 class="h6 text-muted mb-0">Mes liens</h2>
<?php if (($profileCurrentSlug ?? '') !== ''): ?>
<a href="/liens/<?= rawurlencode($profileCurrentSlug) ?>" class="small" target="_blank">↗ voir la page publique</a>
<?php endif; ?>
</div>
<div class="row g-3 align-items-start">
<div class="col-md-8">
<?php if (!empty($profileLinks)): ?>
<div class="card mb-3">
<ul class="list-group list-group-flush" id="links-sortable">
<?php foreach ($profileLinks as $_link): ?>
<li class="list-group-item d-flex align-items-center gap-2 py-2" data-id="<?= (int)$_link['id'] ?>">
<span class="drag-handle text-muted me-1" style="cursor:grab">⠿</span>
<div class="flex-grow-1 min-w-0">
<div class="fw-semibold small text-truncate"><?= htmlspecialchars($_link['title'] ?: $_link['url']) ?></div>
<div class="text-muted small text-truncate"><?= htmlspecialchars($_link['url']) ?></div>
</div>
<form method="post" action="/link/delete" class="flex-shrink-0">
<input type="hidden" name="link_id" value="<?= (int)$_link['id'] ?>">
<button class="btn btn-sm btn-outline-danger py-0" data-confirm="Supprimer ce lien ?">✕</button>
</form>
</li>
<?php endforeach; ?>
</ul>
</div>
<form method="post" action="/link/reorder" id="reorder-form" class="d-none">
<?php foreach ($profileLinks as $__i => $_link): ?>
<input type="hidden" name="order[]" value="<?= (int)$_link['id'] ?>">
<?php endforeach; ?>
</form>
<script src="/assets/js/links-sortable.js" defer></script>
<?php endif; ?>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header small fw-semibold">Ajouter un lien</div>
<div class="card-body">
<form method="post" action="/link/add">
<div class="mb-2">
<input type="url" name="link_url" class="form-control form-control-sm"
placeholder="https://…" required>
</div>
<div class="mb-2">
<input type="text" name="link_title" class="form-control form-control-sm"
placeholder="Titre" maxlength="100">
</div>
<div class="mb-3">
<input type="text" name="link_desc" class="form-control form-control-sm"
placeholder="Description courte (optionnel)" maxlength="200">
</div>
<button class="btn btn-primary btn-sm w-100">Ajouter</button>
</form>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4" id="feeds">
<h2 class="h6 text-muted mb-3">Flux RSS</h2>
<div class="row g-3 align-items-start">
<div class="col-md-8">
<?php if (!empty($profileFeeds)): ?>
<div class="card mb-3">
<ul class="list-group list-group-flush">
<?php foreach ($profileFeeds as $_feed): ?>
<li class="list-group-item d-flex align-items-center gap-2 py-2">
<div class="flex-grow-1 min-w-0">
<div class="fw-semibold small text-truncate"><?= htmlspecialchars($_feed['label'] ?: $_feed['feed_url']) ?></div>
<div class="text-muted small text-truncate"><?= htmlspecialchars($_feed['feed_url']) ?></div>
</div>
<form method="post" action="/feed/delete" class="flex-shrink-0">
<input type="hidden" name="feed_id" value="<?= (int)$_feed['id'] ?>">
<button class="btn btn-sm btn-outline-danger py-0" data-confirm="Supprimer ce flux ?">✕</button>
</form>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header small fw-semibold">Ajouter un flux</div>
<div class="card-body">
<form method="post" action="/feed/add">
<div class="mb-2">
<input type="url" name="feed_url" class="form-control form-control-sm"
placeholder="https://example.com/feed.xml" required>
</div>
<div class="mb-3">
<input type="text" name="feed_label" class="form-control form-control-sm"
placeholder="Libellé (optionnel)" maxlength="100">
</div>
<button class="btn btn-primary btn-sm w-100">Ajouter</button>
</form>
</div>
</div>
</div>
</div>
</div>
<?php
$pdo = dbPdo();
$_profileRoles = [];
if ($pdo) {
$st = $pdo->prepare(
'SELECT r.name, r.label, COALESCE(array_agg(rc.capability) FILTER (WHERE rc.capability IS NOT NULL), \'{}\') AS caps
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
LEFT JOIN role_capabilities rc ON rc.role_id = r.id
WHERE ur.user_email = :email
GROUP BY r.id, r.name, r.label
ORDER BY r.name'
);
$st->execute([':email' => currentUserEmail()]);
$_profileRoles = $st->fetchAll(PDO::FETCH_ASSOC);
}
if (!empty($_profileRoles)): ?>
<div class="mt-4">
<h2 class="h6 text-muted mb-3">Rôles &amp; droits</h2>
<div class="row g-3">
<?php foreach ($_profileRoles as $_role):
$_caps = array_filter(
explode(',', trim((string)$_role['caps'], '{}')),
static fn ($c) => $c !== ''
);
?>
<div class="col-md-4">
<div class="card">
<div class="card-header d-flex align-items-center gap-2 py-2">
<strong><?= htmlspecialchars($_role['label']) ?></strong>
<code class="text-muted small"><?= htmlspecialchars($_role['name']) ?></code>
</div>
<?php if (!empty($_caps)): ?>
<ul class="list-group list-group-flush small">
<?php foreach ($_caps as $_cap):
$_label = KNOWN_CAPABILITIES[trim($_cap)] ?? trim($_cap); ?>
<li class="list-group-item py-1"><?= htmlspecialchars($_label) ?></li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<div class="card-body py-2 small text-muted">Aucun droit associé à ce rôle.</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php
$content = ob_get_clean();
$title = 'Mon profil';
include __DIR__ . '/layout.php';
+78
View File
@@ -0,0 +1,78 @@
<?php ob_start(); ?>
<div class="search-page">
<form class="search-bar mb-5" action="/search" method="GET" role="search">
<div class="input-group">
<input type="search" class="form-control form-control-lg" name="q"
value="<?= htmlspecialchars($searchQuery) ?>"
placeholder="Rechercher…" autofocus autocomplete="off"
aria-label="Rechercher">
<button class="btn btn-primary" type="submit">Rechercher</button>
</div>
<div class="form-text mt-1">
Plusieurs mots = recherche ET · Fautes de frappe tolérées · Accents ignorés
</div>
</form>
<?php if ($searchQuery !== ''): ?>
<?php if (empty($searchResults)): ?>
<p class="text-muted">Aucun résultat pour <strong><?= htmlspecialchars($searchQuery) ?></strong>.</p>
<?php else: ?>
<p class="text-muted mb-4">
<?= count($searchResults) ?> résultat<?= count($searchResults) > 1 ? 's' : '' ?>
pour <strong><?= htmlspecialchars($searchQuery) ?></strong>
</p>
<?php
$tierLabels = [1 => 'Dans le titre', 2 => 'Dans le texte', 3 => 'À peu près'];
$byTier = [];
foreach ($searchResults as $r) {
$byTier[$r['tier'] ?? 3][] = $r;
}
$multiTier = count($byTier) > 1;
?>
<div class="search-results">
<?php foreach ($byTier as $tierNum => $tierResults): ?>
<?php if ($multiTier): ?>
<h3 class="h6 text-muted mt-4 mb-2 border-bottom pb-1"><?= $tierLabels[$tierNum] ?? '' ?></h3>
<?php endif; ?>
<?php foreach ($tierResults as $r):
$a = $r['article'];
$postUrl = '/post/' . rawurlencode($a['slug'] ?? '');
$cat = trim($a['category'] ?? '');
$date = $a['published_at'] ? date('d/m/Y', strtotime((string)$a['published_at'])) : '';
?>
<article class="search-result">
<div class="search-result-meta">
<?php if ($cat !== ''): ?>
<a class="search-result-cat" href="/categorie/<?= rawurlencode($cat) ?>"><?= htmlspecialchars($cat) ?></a>
<?php endif; ?>
<?php if ($date !== ''): ?>
<time class="search-result-date"><?= $date ?></time>
<?php endif; ?>
</div>
<h2 class="search-result-title">
<a href="<?= htmlspecialchars($postUrl) ?>"><?= htmlspecialchars($a['title'] ?? '') ?></a>
</h2>
<?php if ($r['snippet'] !== ''): ?>
<p class="search-result-snippet"><?= $r['snippet'] ?></p>
<?php endif; ?>
</article>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php
$content = ob_get_clean();
$title = $searchQuery !== '' ? 'Recherche : ' . $searchQuery : 'Recherche';
$metaRobots = 'noindex, follow';
$canonical = rtrim(APP_URL, '/') . '/';
include __DIR__ . '/layout.php';
+242
View File
@@ -0,0 +1,242 @@
<?php ob_start();
$metaLabels = [
'mime' => 'Type MIME',
'size' => 'Taille originale',
'pages' => 'Pages',
'page_size' => 'Format',
'pdf_version' => 'Version PDF',
'width' => 'Dimensions',
'camera' => 'Appareil photo',
'site_name' => 'Site',
'og_type' => 'Type OG',
'language' => 'Langue',
'date' => 'Date',
'description' => 'Description',
'subject' => 'Sujet',
'keywords' => 'Mots-clés',
'copyright' => 'Copyright',
'credit' => 'Crédit',
'creator' => 'Créé avec',
'producer' => 'Produit par',
'canonical' => 'URL canonique',
'og_image' => 'Image OG',
];
function renderMetaCell(string $key, mixed $val, array $row = []): string
{
return match($key) {
'size' => htmlspecialchars(number_format((float)$val / 1024, 1)) . ' Ko',
'width' => htmlspecialchars((string)$val) . ' × ' . htmlspecialchars((string)($row['height'] ?? '?')) . ' px',
'og_image' => str_starts_with((string)$val, '/')
? '<img src="' . htmlspecialchars((string)$val) . '" style="max-height:64px;max-width:160px;border-radius:4px" alt="">'
: '<a href="' . htmlspecialchars((string)$val) . '" target="_blank" rel="noopener" class="small text-break font-monospace">' . htmlspecialchars((string)$val) . '</a>',
'canonical' => '<a href="' . htmlspecialchars((string)$val) . '" target="_blank" rel="noopener" class="text-break font-monospace small">' . htmlspecialchars((string)$val) . '</a>',
default => htmlspecialchars((string)$val),
};
}
?>
<div class="d-flex align-items-center gap-3 mb-1">
<a href="/edit/<?= rawurlencode($article['uuid']) ?>" class="btn btn-secondary btn-sm">← Modifier</a>
<h1 class="h4 mb-0">Sources &amp; médias</h1>
</div>
<p class="text-muted small mb-4"><?= htmlspecialchars($article['title']) ?></p>
<?php
// ── Liens & sources externes ──────────────────────────────────────────────────
$externalLinks = $article['external_links'] ?? [];
?>
<section class="mb-5">
<h2 class="h5 border-bottom pb-2 mb-3">
Liens &amp; sources externes
<span class="badge bg-secondary fw-normal ms-1"><?= count($externalLinks) ?></span>
</h2>
<?php if (empty($externalLinks)): ?>
<p class="text-muted">Aucun lien externe enregistré.</p>
<?php else: ?>
<div class="d-flex flex-column gap-3">
<?php foreach ($externalLinks as $lnk):
$lMeta = $lnk['meta'] ?? [];
$lMime = $lMeta['mime'] ?? 'text/html';
$isPdf = ($lMime === 'application/pdf');
$isImg = str_starts_with($lMime, 'image/');
$lHost = parse_url($lnk['url'] ?? '', PHP_URL_HOST) ?? '';
?>
<div class="card">
<div class="card-body p-3">
<div class="d-flex gap-3 align-items-start">
<!-- Vignette (locale uniquement pour respecter la CSP) -->
<?php if (!empty($lMeta['og_image']) && str_starts_with($lMeta['og_image'], '/')): ?>
<img src="<?= htmlspecialchars($lMeta['og_image']) ?>" alt=""
style="width:80px;height:60px;object-fit:cover;border-radius:4px;flex-shrink:0">
<?php else: ?>
<div style="width:40px;font-size:1.8rem;flex-shrink:0;text-align:center;padding-top:2px">
<?= $isPdf ? '📑' : ($isImg ? '🖼' : '🔗') ?>
</div>
<?php endif; ?>
<div class="flex-grow-1 overflow-hidden">
<!-- Titre + URL -->
<div class="fw-semibold mb-1"><?= htmlspecialchars($lnk['name'] ?? '') ?></div>
<a href="<?= htmlspecialchars($lnk['url'] ?? '') ?>" target="_blank" rel="noopener"
class="small text-break font-monospace text-muted">
<?= htmlspecialchars($lnk['url'] ?? '') ?>
</a>
<!-- Auteur · Date ajout -->
<div class="d-flex flex-wrap gap-3 mt-2 small">
<?php if (!empty($lnk['author'])): ?>
<span><span class="text-muted">Auteur :</span> <?= htmlspecialchars($lnk['author']) ?></span>
<?php endif; ?>
<?php if (!empty($lnk['added_at'])): ?>
<span class="text-muted">Ajouté le <?= htmlspecialchars(date('d/m/Y à H:i', strtotime((string)$lnk['added_at']))) ?></span>
<?php endif; ?>
</div>
<!-- Métadonnées -->
<?php
$visibleMeta = array_filter($lMeta, fn ($v, $k) => isset($metaLabels[$k]) && $v !== null && $v !== '' && $k !== 'height', ARRAY_FILTER_USE_BOTH);
?>
<?php if ($visibleMeta): ?>
<table class="table table-sm table-borderless mb-0 mt-2 small">
<tbody>
<?php foreach ($metaLabels as $key => $label):
if (!isset($lMeta[$key]) || $lMeta[$key] === '' || $lMeta[$key] === null) {
continue;
}
?>
<tr>
<th class="text-muted fw-normal text-nowrap pe-3 align-top" style="width:140px"><?= $label ?></th>
<td><?= renderMetaCell($key, $lMeta[$key], $lMeta) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php
// ── Pièces jointes ────────────────────────────────────────────────────────────
$filesMeta = $article['files_meta'] ?? [];
$coverFile = $article['cover'] ?? '';
$realFiles = array_values(array_filter($sourcesFiles, fn ($f) => !str_starts_with($f['name'], '_thumb_')));
?>
<section class="mb-5">
<h2 class="h5 border-bottom pb-2 mb-3">
Pièces jointes
<span class="badge bg-secondary fw-normal ms-1"><?= count($realFiles) ?></span>
</h2>
<?php if (empty($realFiles)): ?>
<p class="text-muted">Aucun fichier joint.</p>
<?php else: ?>
<div class="d-flex flex-column gap-3">
<?php foreach ($sourcesFiles as $f):
if (str_starts_with($f['name'], '_thumb_')) {
continue;
}
$fmeta = $filesMeta[$f['name']] ?? [];
$fExtra = $fmeta['meta'] ?? [];
$fileUrl = '/file?uuid=' . rawurlencode($article['uuid']) . '&name=' . rawurlencode($f['name']);
$isCover = ($f['name'] === $coverFile);
?>
<div class="card">
<div class="card-body p-3">
<div class="d-flex gap-3 align-items-start">
<!-- Vignette -->
<?php if ($f['is_image']): ?>
<a href="<?= htmlspecialchars($fileUrl) ?>" target="_blank" rel="noopener" class="flex-shrink-0">
<img src="<?= htmlspecialchars($fileUrl) ?>" alt=""
style="width:80px;height:60px;object-fit:cover;border-radius:4px;<?= $isCover ? 'outline:2px solid #0d6efd' : '' ?>">
</a>
<?php else: ?>
<div style="width:40px;font-size:1.8rem;flex-shrink:0;text-align:center;padding-top:2px">
<?= match(true) {
str_starts_with($f['mime'], 'video/') => '🎬',
str_starts_with($f['mime'], 'audio/') => '🎵',
$f['mime'] === 'application/pdf' => '📑',
default => '📄',
} ?>
</div>
<?php endif; ?>
<div class="flex-grow-1 overflow-hidden">
<!-- Titre + nom fichier -->
<div class="d-flex align-items-center gap-2 flex-wrap mb-1">
<a href="<?= htmlspecialchars($fileUrl) ?>" target="_blank" rel="noopener" class="fw-semibold">
<?= htmlspecialchars($fmeta['title'] ?? $f['name']) ?>
</a>
<?php if (!empty($fmeta['title'])): ?>
<code class="small text-muted"><?= htmlspecialchars($f['name']) ?></code>
<?php endif; ?>
<?php if ($isCover): ?>
<span class="badge bg-primary">cover</span>
<?php endif; ?>
</div>
<!-- Auteur · Source · Taille -->
<div class="d-flex flex-wrap gap-3 small mb-1">
<span class="text-muted"><?= htmlspecialchars(number_format($f['size'] / 1024, 1)) ?> Ko</span>
<?php if (!empty($fmeta['author'])): ?>
<span><span class="text-muted">Auteur :</span> <?= htmlspecialchars($fmeta['author']) ?></span>
<?php endif; ?>
<?php if (!empty($fmeta['source_url'])): ?>
<span>
<span class="text-muted">Source :</span>
<a href="<?= htmlspecialchars($fmeta['source_url']) ?>" target="_blank" rel="noopener"
class="font-monospace text-break small">
<?= htmlspecialchars($fmeta['source_url']) ?>
</a>
</span>
<?php endif; ?>
</div>
<!-- Métadonnées EXIF/PDF -->
<?php
$visibleExtra = array_filter($fExtra, fn ($v, $k) => isset($metaLabels[$k]) && $v !== null && $v !== '' && $k !== 'height', ARRAY_FILTER_USE_BOTH);
?>
<?php if ($visibleExtra): ?>
<table class="table table-sm table-borderless mb-0 mt-1 small">
<tbody>
<?php foreach ($metaLabels as $key => $label):
if (!isset($fExtra[$key]) || $fExtra[$key] === '' || $fExtra[$key] === null) {
continue;
}
?>
<tr>
<th class="text-muted fw-normal text-nowrap pe-3 align-top" style="width:140px"><?= $label ?></th>
<td><?= renderMetaCell($key, $fExtra[$key], $fExtra) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if (empty($fmeta) && empty($fExtra)): ?>
<span class="text-muted small">Pas de métadonnées enregistrées.</span>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
$title = 'Sources — ' . htmlspecialchars($article['title']);
include __DIR__ . '/layout.php';