feat : notion de livre — grouper des pages en série ordonnée

Ajoute un concept de "livre" (série de pages ordonnées) avec :
- BookManager : CRUD JSON dans data/books/<slug>.json
- Route /book/<slug> → 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 00:47:41 +02:00
parent 3bb83b3ffd
commit dbd76556fb
6 changed files with 422 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
class BookManager
{
public function __construct(private string $booksDir)
{
}
// ------------------------------------------------------------------ //
// Lecture
// ------------------------------------------------------------------ //
public function getAll(): array
{
$books = [];
if (!is_dir($this->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, '-');
}
}