feat: page profil public /profil/{slug} avec liste des articles

This commit is contained in:
Cedric Abonnel
2026-05-12 23:49:21 +02:00
parent e1c179b536
commit 654542f13b
7 changed files with 195 additions and 8 deletions
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS profile_slug TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_profile_slug_idx ON user_profiles (profile_slug) WHERE profile_slug <> '';
+3
View File
@@ -41,6 +41,9 @@ RewriteRule ^categories/?$ /index.php?action=categories [L,QSA]
RewriteRule ^profile/?$ /index.php?action=profile [L,QSA] RewriteRule ^profile/?$ /index.php?action=profile [L,QSA]
RewriteRule ^search/?$ /index.php?action=search [L,QSA] RewriteRule ^search/?$ /index.php?action=search [L,QSA]
# Profil public auteur
RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=author&slug=$1 [L,QSA]
# Pages statiques # Pages statiques
RewriteRule ^about/?$ /index.php?action=about [L,QSA] RewriteRule ^about/?$ /index.php?action=about [L,QSA]
RewriteRule ^legal/?$ /index.php?action=legal [L,QSA] RewriteRule ^legal/?$ /index.php?action=legal [L,QSA]
+35
View File
@@ -1242,3 +1242,38 @@ footer.mt-5 { margin-top: 0 !important; }
} }
.tag-cloud-reset:hover { color: var(--vl-accent); } .tag-cloud-reset:hover { color: var(--vl-accent); }
/* ─── Profil public auteur ───────────────── */
.author-profile-hero {
display: flex;
align-items: center;
gap: 1.5rem;
}
.author-avatar {
flex-shrink: 0;
width: 4rem;
height: 4rem;
border-radius: 50%;
background: var(--vl-accent);
color: #fff;
font-size: 1.75rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.author-profile-name {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 .25rem;
}
.author-profile-link {
font-size: .875rem;
color: var(--vl-muted);
}
.author-profile-link:hover { color: var(--vl-accent); }
+35 -4
View File
@@ -851,6 +851,36 @@ switch ($action) {
header('Location: /categories'); header('Location: /categories');
exit; exit;
case 'author':
$authorSlug = trim($_GET['slug'] ?? '');
$authorRow = profileBySlug($authorSlug);
if (!$authorRow) {
http_response_code(404);
$content = '<div class="container py-5"><p class="text-muted">Profil introuvable.</p></div>';
$title = 'Profil introuvable';
include BASE_PATH . '/templates/layout.php';
break;
}
$privateCats = $articles->getPrivateCategories();
$authorArticles = array_values(array_filter(
$articles->getAll(publishedOnly: true),
static function (array $a) use ($authorRow, $privateCats): bool {
if (($a['author'] ?? '') !== $authorRow['email']) {
return false;
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) {
return false;
}
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
return false;
}
return true;
}
));
include BASE_PATH . '/templates/author_profile.php';
break;
case 'about': case 'about':
include BASE_PATH . '/templates/about.php'; include BASE_PATH . '/templates/about.php';
break; break;
@@ -1785,12 +1815,13 @@ switch ($action) {
$pdo = dbPdo(); $pdo = dbPdo();
if ($pdo) { if ($pdo) {
try { try {
$newSlug = slugify($newName);
$st = $pdo->prepare( $st = $pdo->prepare(
'INSERT INTO user_profiles (email, display_name, profile_url, updated_at) 'INSERT INTO user_profiles (email, display_name, profile_url, profile_slug, updated_at)
VALUES (:e, :n, :u, now()) VALUES (:e, :n, :u, :s, now())
ON CONFLICT (email) DO UPDATE SET display_name = :n, profile_url = :u, updated_at = now()' ON CONFLICT (email) DO UPDATE SET display_name = :n, profile_url = :u, profile_slug = :s, updated_at = now()'
); );
$st->execute([':e' => currentUserEmail(), ':n' => $newName, ':u' => $newUrl]); $st->execute([':e' => currentUserEmail(), ':n' => $newName, ':u' => $newUrl, ':s' => $newSlug]);
$_SESSION['user_display_name'] = $newName; $_SESSION['user_display_name'] = $newName;
$profileSuccess = true; $profileSuccess = true;
} catch (\Throwable $ex) { } catch (\Throwable $ex) {
+44 -3
View File
@@ -49,7 +49,7 @@ function authorProfile(string $email): array
static $cache = []; static $cache = [];
$key = strtolower(trim($email)); $key = strtolower(trim($email));
if ($key === '') { if ($key === '') {
return ['name' => '', 'url' => '']; return ['name' => '', 'url' => '', 'slug' => ''];
} }
if (array_key_exists($key, $cache)) { if (array_key_exists($key, $cache)) {
return $cache[$key]; return $cache[$key];
@@ -57,23 +57,64 @@ function authorProfile(string $email): array
$pdo = dbPdo(); $pdo = dbPdo();
if ($pdo) { if ($pdo) {
try { try {
$st = $pdo->prepare('SELECT display_name, profile_url FROM user_profiles WHERE email = :e'); $st = $pdo->prepare('SELECT display_name, profile_url, profile_slug FROM user_profiles WHERE email = :e');
$st->execute([':e' => $key]); $st->execute([':e' => $key]);
$row = $st->fetch(PDO::FETCH_ASSOC); $row = $st->fetch(PDO::FETCH_ASSOC);
if ($row) { if ($row) {
$cache[$key] = [ $cache[$key] = [
'name' => ($row['display_name'] !== '') ? $row['display_name'] : explode('@', $key)[0], 'name' => ($row['display_name'] !== '') ? $row['display_name'] : explode('@', $key)[0],
'url' => $row['profile_url'] ?? '', 'url' => $row['profile_url'] ?? '',
'slug' => $row['profile_slug'] ?? '',
]; ];
return $cache[$key]; return $cache[$key];
} }
} catch (\Throwable) { } catch (\Throwable) {
} }
} }
$cache[$key] = ['name' => explode('@', $key)[0], 'url' => '']; $cache[$key] = ['name' => explode('@', $key)[0], 'url' => '', 'slug' => ''];
return $cache[$key]; return $cache[$key];
} }
function authorSlug(string $email): string
{
return authorProfile($email)['slug'];
}
function profileBySlug(string $slug): ?array
{
if ($slug === '') {
return null;
}
$pdo = dbPdo();
if (!$pdo) {
return null;
}
try {
$st = $pdo->prepare('SELECT email, display_name, profile_url, profile_slug FROM user_profiles WHERE profile_slug = :s');
$st->execute([':s' => $slug]);
$row = $st->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
} catch (\Throwable) {
return null;
}
}
function slugify(string $text): string
{
$map = [
'à' => 'a', 'â' => 'a', 'ä' => 'a',
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
'î' => 'i', 'ï' => 'i',
'ô' => 'o', 'ö' => 'o',
'ù' => 'u', 'û' => 'u', 'ü' => 'u',
'ç' => 'c', 'æ' => 'ae', 'œ' => 'oe',
];
$s = mb_strtolower($text, 'UTF-8');
$s = strtr($s, $map);
$s = preg_replace('/[^a-z0-9]+/', '-', $s);
return trim((string)$s, '-');
}
function dbPdo(): ?PDO function dbPdo(): ?PDO
{ {
static $pdo = null; static $pdo = null;
+70
View File
@@ -0,0 +1,70 @@
<?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'] ?? '';
$_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; ?>
</div>
</div>
<?php if (empty($authorArticles)): ?>
<p class="text-muted">Aucun article publié.</p>
<?php else: ?>
<div class="post-grid">
<?php foreach ($authorArticles 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 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';
+6 -1
View File
@@ -46,6 +46,7 @@ $externalLinks = $article['external_links'] ?? [];
$authorEmail = $article['author'] ?? ''; $authorEmail = $article['author'] ?? '';
$authorName = ($authorEmail !== '' && function_exists('authorDisplayName')) ? authorDisplayName($authorEmail) : ''; $authorName = ($authorEmail !== '' && function_exists('authorDisplayName')) ? authorDisplayName($authorEmail) : '';
$authorProfileUrl = ($authorEmail !== '' && function_exists('authorProfileUrl')) ? authorProfileUrl($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'] ?? '')))); $pubDate = htmlspecialchars(date('d/m/Y', strtotime((string)($article['published_at'] ?? $article['created_at'] ?? ''))));
$hasCover = $coverFile !== ''; $hasCover = $coverFile !== '';
$heroExtraClass = $hasCover ? '' : ' article-cover--gradient'; $heroExtraClass = $hasCover ? '' : ' article-cover--gradient';
@@ -80,7 +81,11 @@ $hasSources = (!empty($externalLinks) || !empty($files))
<h1 class="article-title"><?= htmlspecialchars($article['title']) ?></h1> <h1 class="article-title"><?= htmlspecialchars($article['title']) ?></h1>
<p class="article-hero-meta"> <p class="article-hero-meta">
<?php if ($authorName !== ''): ?> <?php if ($authorName !== ''): ?>
<span><?= htmlspecialchars($authorName) ?></span> <?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> <span class="mx-1 opacity-50">·</span>
<?php endif; ?> <?php endif; ?>
<?= $pubDate ?> <?= $pubDate ?>