From dbd76556fbad9cf59613c84e2bbdc70f03d7dc74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Fri, 15 May 2026 00:47:41 +0200 Subject: [PATCH] =?UTF-8?q?feat=20:=20notion=20de=20livre=20=E2=80=94=20gr?= =?UTF-8?q?ouper=20des=20pages=20en=20s=C3=A9rie=20ordonn=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute un concept de "livre" (série de pages ordonnées) avec : - BookManager : CRUD JSON dans data/books/.json - Route /book/ → page de sommaire (table des matières) - Navigation chapitre ← → en bas de chaque article membre du livre - Bandeau "Chapitre X/N — Nom du livre" en haut de l'article - Admin → onglet Livres : créer, éditer, supprimer un livre, ajouter/ordonner les pages via textarea slug Co-Authored-By: Claude Sonnet 4.6 --- public/.htaccess | 3 + public/assets/css/style.css | 168 ++++++++++++++++++++++++++++++++++++ src/BookManager.php | 131 ++++++++++++++++++++++++++++ templates/admin.php | 10 +++ templates/book.php | 62 +++++++++++++ templates/post_view.php | 48 +++++++++++ 6 files changed, 422 insertions(+) create mode 100644 src/BookManager.php create mode 100644 templates/book.php diff --git a/public/.htaccess b/public/.htaccess index ab70c0c..9e81872 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -15,6 +15,9 @@ RewriteRule ^ - [L] # URL propre pour les articles : /post/ RewriteRule ^post/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=view&slug=$1 [L,QSA] +# Livres : /book/ +RewriteRule ^book/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=book&book_slug=$1 [L,QSA] + # Filtre par catégorie : /categorie/ RewriteRule ^categorie/(.+?)/?$ /index.php?cat=$1 [L,QSA,B] diff --git a/public/assets/css/style.css b/public/assets/css/style.css index cf82aba..cbcd4dc 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -1807,3 +1807,171 @@ footer.mt-5 { margin-top: 0 !important; } color: var(--vl-muted); margin-top: 0.15rem; } + +/* ─── Livres ─────────────────────────────────────────────────────── */ + +/* Bandeau dans un article appartenant à un livre */ +.book-article-banner { + border-radius: var(--vl-radius); + background: var(--vl-accent-soft); + border: 1px solid rgba(79,70,229,.18); + overflow: hidden; +} +.book-article-banner-link { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 1rem; + text-decoration: none; + color: var(--vl-accent); + transition: background 0.15s; +} +.book-article-banner-link:hover { + background: rgba(79,70,229,.08); + color: var(--vl-accent-dark); +} +.book-article-banner-icon { font-size: 1.1rem; flex-shrink: 0; } +.book-article-banner-text { flex: 1; font-size: 0.875rem; } +.book-article-banner-cta { font-size: 0.8rem; opacity: .75; white-space: nowrap; } + +/* Navigation précédent/suivant en bas d'article */ +.book-chapter-nav { + border-top: 1px solid var(--vl-border); + margin-top: 1.5rem; + padding-top: 1rem; +} +.book-chapter-nav-inner { + display: flex; + gap: 0.75rem; + align-items: stretch; +} +.book-nav-btn { + flex: 1; + display: flex; + flex-direction: column; + padding: 0.65rem 0.875rem; + background: var(--vl-surface); + border: 1px solid var(--vl-border); + border-radius: var(--vl-radius); + text-decoration: none; + color: var(--vl-text); + transition: border-color 0.15s, box-shadow 0.15s; + min-width: 0; +} +.book-nav-btn:hover { + border-color: var(--vl-accent); + box-shadow: var(--vl-shadow-sm); + color: var(--vl-text); +} +.book-nav-btn--disabled { + opacity: .45; + cursor: default; + pointer-events: none; +} +.book-nav-btn--next { text-align: right; } +.book-nav-dir { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--vl-muted); + display: block; +} +.book-nav-title { + font-size: 0.875rem; + font-weight: 500; + display: block; + margin-top: 0.15rem; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.book-nav-toc { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem 0.75rem; + border: 1px solid var(--vl-border); + border-radius: var(--vl-radius); + color: var(--vl-muted); + text-decoration: none; + font-size: 1.1rem; + transition: border-color 0.15s, color 0.15s; + flex-shrink: 0; +} +.book-nav-toc:hover { + border-color: var(--vl-accent); + color: var(--vl-accent); +} + +/* Page sommaire d'un livre (/book/) */ +.book-page { max-width: 720px; margin: 0 auto; padding: 2rem 0; } +.book-label { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--vl-accent); +} +.book-chapters { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.book-chapter-link { + display: flex; + align-items: center; + gap: 0.875rem; + padding: 0.75rem 1rem; + background: var(--vl-surface); + border: 1px solid var(--vl-border); + border-radius: var(--vl-radius); + text-decoration: none; + color: var(--vl-text); + transition: border-color 0.15s, box-shadow 0.15s; +} +.book-chapter-link:hover { + border-color: var(--vl-accent); + box-shadow: var(--vl-shadow-sm); + color: var(--vl-text); +} +.book-chapter-num { + width: 1.75rem; + height: 1.75rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--vl-accent-soft); + color: var(--vl-accent); + border-radius: 50%; + font-size: 0.8rem; + font-weight: 700; + flex-shrink: 0; +} +.book-chapter-thumb { + width: 56px; + height: 44px; + border-radius: 6px; + flex-shrink: 0; + background: var(--vl-accent-soft); + background-size: cover; + background-position: center; +} +.book-chapter-body { flex: 1; min-width: 0; } +.book-chapter-title { + font-size: 0.9375rem; + font-weight: 600; + line-height: 1.3; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.book-chapter-meta { + font-size: 0.78rem; + color: var(--vl-muted); + margin-top: 0.15rem; +} diff --git a/src/BookManager.php b/src/BookManager.php new file mode 100644 index 0000000..d62b2cf --- /dev/null +++ b/src/BookManager.php @@ -0,0 +1,131 @@ +booksDir)) { + return $books; + } + foreach (scandir($this->booksDir) as $file) { + if (!str_ends_with($file, '.json')) { + continue; + } + $raw = file_get_contents($this->booksDir . '/' . $file); + if ($raw === false) { + continue; + } + $book = json_decode($raw, true); + if (!is_array($book) || empty($book['slug'])) { + continue; + } + $books[] = $book; + } + usort($books, static fn ($a, $b) => strcmp($a['title'] ?? '', $b['title'] ?? '')); + return $books; + } + + public function getBySlug(string $slug): ?array + { + $path = $this->bookPath($slug); + if (!file_exists($path)) { + return null; + } + $raw = file_get_contents($path); + if ($raw === false) { + return null; + } + $book = json_decode($raw, true); + return is_array($book) && !empty($book['slug']) ? $book : null; + } + + /** + * Cherche dans quel livre se trouve un article (par son slug). + * Retourne le contexte complet ou null si l'article n'appartient à aucun livre. + * + * @return array{book: array, position: int, total: int, prev: ?string, next: ?string}|null + */ + public function findForArticle(string $articleSlug): ?array + { + foreach ($this->getAll() as $book) { + $arts = $book['articles'] ?? []; + $pos = array_search($articleSlug, $arts, true); + if ($pos === false) { + continue; + } + $pos = (int) $pos; + return [ + 'book' => $book, + 'position' => $pos + 1, + 'total' => count($arts), + 'prev' => $pos > 0 ? $arts[$pos - 1] : null, + 'next' => $pos < count($arts) - 1 ? $arts[$pos + 1] : null, + ]; + } + return null; + } + + // ------------------------------------------------------------------ // + // Écriture + // ------------------------------------------------------------------ // + + public function save(array $book): void + { + $slug = $this->sanitizeSlug($book['slug'] ?? ''); + if ($slug === '') { + return; + } + $book['slug'] = $slug; + $book['articles'] = array_values(array_filter(array_map('strval', $book['articles'] ?? []))); + if (!is_dir($this->booksDir)) { + mkdir($this->booksDir, 0755, true); + } + file_put_contents( + $this->bookPath($slug), + json_encode($book, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n" + ); + } + + public function delete(string $slug): void + { + $path = $this->bookPath($slug); + if (file_exists($path)) { + @unlink($path); + } + } + + // ------------------------------------------------------------------ // + // Helpers + // ------------------------------------------------------------------ // + + private function bookPath(string $slug): string + { + return $this->booksDir . '/' . $slug . '.json'; + } + + private function sanitizeSlug(string $slug): string + { + $map = [ + 'à' => 'a', 'â' => 'a', 'ä' => 'a', + 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e', + 'î' => 'i', 'ï' => 'i', + 'ô' => 'o', 'ö' => 'o', + 'ù' => 'u', 'û' => 'u', 'ü' => 'u', + 'ç' => 'c', 'æ' => 'ae', 'œ' => 'oe', + ]; + $slug = mb_strtolower(strtr(trim($slug), $map), 'UTF-8'); + $slug = (string) preg_replace('/[^a-z0-9]+/', '-', $slug); + return trim($slug, '-'); + } +} diff --git a/templates/admin.php b/templates/admin.php index dc4dd46..ff5bd77 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -69,6 +69,10 @@ function adminStatusBadge(array $a, int $now): string Livres +
  • + Statistiques +
  • @@ -1300,6 +1304,12 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb): + + + + + + + +
    + +
    +

    Livre

    +

    + +

    + +

    page 1 ? 's' : '' ?>

    +
    + + +

    Ce livre ne contient pas encore de pages publiées.

    + +
      + $a): + $cat = trim($a['category'] ?? ''); + $gradient = coverGradient($cat !== '' ? $cat : $a['uuid'], $allCats); + $cover = $a['cover'] ?? ''; + $date = $a['published_at'] ? date('d/m/Y', strtotime((string)$a['published_at'])) : ''; + ?> +
    1. + + +
      +
      +
      +
      +
      + + · + +
      + + Brouillon + +
      +
      +
    2. + +
    + + + +
    + + ✎ Modifier ce livre + +
    + + +
    + +
    + +
    + + 📖 + + Chapitre / — + + + Voir le sommaire → + +
    + +
    Brouillon
    @@ -163,6 +176,41 @@ $hasSources = (!empty($externalLinks) || !empty($files))
    + + +
    +
    + + + ← Précédent + + + + + Premier chapitre + + + + + ☰ + + + + + Suivant → + + + + + Dernier chapitre + + +
    +
    +