feat: page profil public /profil/{slug} avec liste des articles
This commit is contained in:
@@ -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 <> '';
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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 ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user