From a926e1825dbd8adfb244b862ac8c17ee0845f5cb Mon Sep 17 00:00:00 2001
From: Cedric Abonnel
Date: Wed, 13 May 2026 00:57:02 +0200
Subject: [PATCH] =?UTF-8?q?feat:=20filtres=20et=20suppression=20massive=20?=
=?UTF-8?q?dans=20admin/articles,=20profil=20auteur=20am=C3=A9lior=C3=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Admin articles : filtres auteur/catégorie/statut par GET, compteur de résultats
- Admin articles : suppression massive avec checkboxes, confirmation JS, contrôle d'ownership
- Profil auteur : bio tronquée à 3 lignes avec bouton 'plus', limite à 6 articles affichés
- Profil auteur : bouton CTA pill 'Mes liens' vers /liens/{slug}
- Page liens : boutons pill colorés (palette auto par index), fond gris clair, grand avatar
---
data/private_cats.json | 2 +-
public/.htaccess | 2 +
public/assets/css/style.css | 27 ++++++++
public/assets/js/bio-toggle.js | 14 ++++
public/assets/js/links-sortable.js | 29 +++++++++
public/index.php | 101 +++++++++++++++++++++++++++++
templates/admin.php | 72 ++++++++++++++++++++
templates/author_articles.php | 97 +++++++++++++++++++++++++++
templates/author_profile.php | 18 +----
templates/post_list.php | 7 +-
templates/profile.php | 32 +--------
11 files changed, 350 insertions(+), 51 deletions(-)
create mode 100644 public/assets/js/bio-toggle.js
create mode 100644 public/assets/js/links-sortable.js
create mode 100644 templates/author_articles.php
diff --git a/data/private_cats.json b/data/private_cats.json
index 141462c..9272d7d 100644
--- a/data/private_cats.json
+++ b/data/private_cats.json
@@ -1 +1 @@
-["scolaire"]
\ No newline at end of file
+["scolaire","perso","Produits","Journal personnel","Vie pratique"]
\ No newline at end of file
diff --git a/public/.htaccess b/public/.htaccess
index 9575fa6..359b71d 100644
--- a/public/.htaccess
+++ b/public/.htaccess
@@ -45,6 +45,8 @@ RewriteRule ^feed/add/?$ /index.php?action=add_feed [L,QSA]
RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA]
# Profil public auteur + page liens
+RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/article/cursor/([0-9a-f-]{36})/?$ /index.php?action=author_articles&slug=$1&cursor=$2 [L,QSA]
+RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/article/?$ /index.php?action=author_articles&slug=$1 [L,QSA]
RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=author&slug=$1 [L,QSA]
RewriteRule ^liens/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=liens&slug=$1 [L,QSA]
RewriteRule ^link/add/?$ /index.php?action=add_link [L,QSA]
diff --git a/public/assets/css/style.css b/public/assets/css/style.css
index 850bcef..04bdc1b 100644
--- a/public/assets/css/style.css
+++ b/public/assets/css/style.css
@@ -1479,3 +1479,30 @@ footer.mt-5 { margin-top: 0 !important; }
margin: 0;
line-height: 1.6;
}
+
+/* Bouton flottant "+" — nouvel article */
+.fab-new {
+ position: fixed;
+ bottom: 2rem;
+ right: 2rem;
+ z-index: 1050;
+ width: 3.25rem;
+ height: 3.25rem;
+ border-radius: 50%;
+ background: var(--vl-accent);
+ color: #fff;
+ font-size: 1.75rem;
+ line-height: 3.25rem;
+ text-align: center;
+ text-decoration: none;
+ box-shadow: 0 4px 16px rgba(79,70,229,.45);
+ transition: background 0.15s, box-shadow 0.15s, transform 0.15s;
+ user-select: none;
+}
+.fab-new:hover,
+.fab-new:focus {
+ background: var(--vl-accent-dark);
+ color: #fff;
+ box-shadow: 0 6px 20px rgba(79,70,229,.55);
+ transform: scale(1.08);
+}
diff --git a/public/assets/js/bio-toggle.js b/public/assets/js/bio-toggle.js
new file mode 100644
index 0000000..549bcad
--- /dev/null
+++ b/public/assets/js/bio-toggle.js
@@ -0,0 +1,14 @@
+(function(){
+ var bio = document.getElementById('author-bio');
+ var btn = document.getElementById('bio-toggle');
+ if (!bio || !btn) return;
+ requestAnimationFrame(function() {
+ if (bio.scrollHeight > bio.clientHeight + 2) { btn.hidden = false; }
+ });
+ btn.addEventListener('click', function() {
+ var exp = btn.getAttribute('aria-expanded') === 'true';
+ bio.classList.toggle('bio-clamped', exp);
+ btn.textContent = exp ? 'plus' : 'moins';
+ btn.setAttribute('aria-expanded', exp ? 'false' : 'true');
+ });
+})();
diff --git a/public/assets/js/links-sortable.js b/public/assets/js/links-sortable.js
new file mode 100644
index 0000000..7a02393
--- /dev/null
+++ b/public/assets/js/links-sortable.js
@@ -0,0 +1,29 @@
+(function() {
+ const list = document.getElementById('links-sortable');
+ if (!list) return;
+ let dragged = null;
+ list.querySelectorAll('li').forEach(li => {
+ li.setAttribute('draggable', true);
+ li.addEventListener('dragstart', () => { dragged = li; li.style.opacity = '.4'; });
+ li.addEventListener('dragend', () => { dragged = null; li.style.opacity = ''; saveOrder(); });
+ li.addEventListener('dragover', e => { e.preventDefault(); const after = getDragAfter(list, e.clientY); after ? list.insertBefore(dragged, after) : list.appendChild(dragged); });
+ });
+ function getDragAfter(container, y) {
+ return [...container.querySelectorAll('li:not([style*="opacity"])')].reduce((closest, el) => {
+ const box = el.getBoundingClientRect();
+ const offset = y - box.top - box.height / 2;
+ return offset < 0 && offset > closest.offset ? { offset, element: el } : closest;
+ }, { offset: Number.NEGATIVE_INFINITY }).element;
+ }
+ function saveOrder() {
+ const form = document.getElementById('reorder-form');
+ if (!form) return;
+ form.querySelectorAll('input').forEach(i => i.remove());
+ list.querySelectorAll('li[data-id]').forEach(li => {
+ const inp = document.createElement('input');
+ inp.type = 'hidden'; inp.name = 'order[]'; inp.value = li.dataset.id;
+ form.appendChild(inp);
+ });
+ form.submit();
+ }
+})();
diff --git a/public/index.php b/public/index.php
index 5f6abce..11b4809 100644
--- a/public/index.php
+++ b/public/index.php
@@ -881,6 +881,55 @@ switch ($action) {
include BASE_PATH . '/templates/author_profile.php';
break;
+ case 'author_articles':
+ $authorSlug = trim($_GET['slug'] ?? '');
+ $authorRow = profileBySlug($authorSlug);
+ if (!$authorRow) {
+ http_response_code(404);
+ $content = '';
+ $title = 'Profil introuvable';
+ include BASE_PATH . '/templates/layout.php';
+ break;
+ }
+ $privateCats = $articles->getPrivateCategories();
+ $allCats = $articles->getCategories();
+ $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;
+ }
+ ));
+ $perPage = postsPerPage();
+ $cursor = trim($_GET['cursor'] ?? '');
+ $offset = 0;
+ if ($cursor !== '') {
+ foreach ($authorArticles as $i => $a) {
+ if ($a['uuid'] === $cursor) {
+ $offset = $i + 1;
+ break;
+ }
+ }
+ }
+ $posts = array_slice($authorArticles, $offset, $perPage);
+ $nextCursor = count($posts) === $perPage ? end($posts)['uuid'] : null;
+ $prevCursor = null;
+ if ($offset > 0) {
+ $prevOffset = max(0, $offset - $perPage);
+ $prevCursor = $prevOffset > 0 ? $authorArticles[$prevOffset - 1]['uuid'] : '';
+ }
+ include BASE_PATH . '/templates/author_articles.php';
+ break;
+
case 'liens':
$liensSlug = trim($_GET['slug'] ?? '');
$liensRow = profileBySlug($liensSlug);
@@ -1622,6 +1671,34 @@ switch ($action) {
$allArticles = array_values(array_filter($allArticles, fn ($a) => ($a['author'] ?? '') === $me));
}
usort($allArticles, fn ($a, $b) => strcmp($b['updated_at'] ?? '', $a['updated_at'] ?? ''));
+
+ $adminData['filter_authors'] = array_values(array_unique(array_filter(array_column($allArticles, 'author'))));
+ $adminData['filter_categories'] = array_values(array_unique(array_filter(array_column($allArticles, 'category'))));
+ sort($adminData['filter_authors']);
+ sort($adminData['filter_categories']);
+
+ $filterAuthor = trim($_GET['filter_author'] ?? '');
+ $filterCategory = trim($_GET['filter_category'] ?? '');
+ $filterStatus = trim($_GET['filter_status'] ?? '');
+ $adminData['filter_author'] = $filterAuthor;
+ $adminData['filter_category'] = $filterCategory;
+ $adminData['filter_status'] = $filterStatus;
+
+ $nowTs = time();
+ if ($filterAuthor !== '') {
+ $allArticles = array_values(array_filter($allArticles, fn ($a) => ($a['author'] ?? '') === $filterAuthor));
+ }
+ if ($filterCategory !== '') {
+ $allArticles = array_values(array_filter($allArticles, fn ($a) => trim($a['category'] ?? '') === $filterCategory));
+ }
+ if ($filterStatus === 'published') {
+ $allArticles = array_values(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) <= $nowTs));
+ } elseif ($filterStatus === 'draft') {
+ $allArticles = array_values(array_filter($allArticles, fn ($a) => !$a['published']));
+ } elseif ($filterStatus === 'preview') {
+ $allArticles = array_values(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) > $nowTs));
+ }
+
$adminData['articles'] = $allArticles;
}
@@ -1706,6 +1783,30 @@ switch ($action) {
include BASE_PATH . '/templates/admin.php';
break;
+ case 'admin_bulk_delete':
+ requireAuth();
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $uuids = $_POST['uuids'] ?? [];
+ if (is_array($uuids)) {
+ $me = currentUserEmail() ?? '';
+ foreach ($uuids as $uid) {
+ $uid = trim((string)$uid);
+ if ($uid === '') {
+ continue;
+ }
+ $art = $articles->getByUuid($uid);
+ if (!$art) {
+ continue;
+ }
+ if (isAdmin() || ($art['author'] ?? '') === $me) {
+ $articles->delete($uid);
+ }
+ }
+ }
+ }
+ header('Location: /admin/articles');
+ exit;
+
case 'admin_grant_role':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
diff --git a/templates/admin.php b/templates/admin.php
index b6d015f..39bc278 100644
--- a/templates/admin.php
+++ b/templates/admin.php
@@ -105,12 +105,74 @@ function adminStatusBadge(array $a, int $now): string
+
+
+
Aucun article.
+
+
diff --git a/templates/author_articles.php b/templates/author_articles.php
new file mode 100644
index 0000000..9f8d530
--- /dev/null
+++ b/templates/author_articles.php
@@ -0,0 +1,97 @@
+
+
+
+
+
+ Aucun article publié.
+
+
+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');
+ ?>
+
+
+ Avant-première
+
+
+
+ = htmlspecialchars($category) ?>
+
+
+
+
+
= htmlspecialchars($preview) ?>
+
+
+
Disponible le = htmlspecialchars(date('d/m/Y \à H\hi', strtotime((string)($post['published_at'] ?? '')))) ?>
+
+
= htmlspecialchars(date('d/m/Y', strtotime((string)($post['published_at'] ?? $post['created_at'] ?? '')))) ?>
+
+
+
modifier
+
+
+
→ lire
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+= nl2br(htmlspecialchars($_apBio)) ?>
-
+
@@ -82,7 +68,7 @@ $_initials = mb_strtoupper(mb_substr($_apName, 0, 1, 'UTF-8'), 'UTF-8');
6): ?>
- Voir tous les articles →
+ Voir tous les articles →
diff --git a/templates/post_list.php b/templates/post_list.php
index 6308b7b..ad85eeb 100644
--- a/templates/post_list.php
+++ b/templates/post_list.php
@@ -55,9 +55,6 @@ ob_start();
= htmlspecialchars(date('d/m/Y', strtotime((string)($post['published_at'] ?? $post['created_at'] ?? '')))) ?>
-
- modifier
-
→ lire
@@ -125,6 +122,10 @@ if (!empty($_tagCats)):
+
++
+
+
">
-
+