first commit

This commit is contained in:
julien
2026-03-20 22:16:20 +01:00
commit 42a4ba3e9a
136 changed files with 10141 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Post\Application\Command;
/**
* Commande applicative décrivant la création d'un article.
*/
final readonly class CreatePostCommand
{
public function __construct(
public string $title,
public string $content,
public int $authorId,
public ?int $categoryId = null,
) {}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Post\Application\Command;
/**
* Commande applicative décrivant la mise à jour d'un article.
*/
final readonly class UpdatePostCommand
{
public function __construct(
public int $id,
public string $title,
public string $content,
public string $newSlugInput = '',
public ?int $categoryId = null,
) {}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Post\Application;
use App\Post\Application\Command\CreatePostCommand;
use App\Post\Application\Command\UpdatePostCommand;
use App\Post\Application\UseCase\CreatePost;
use App\Post\Application\UseCase\DeletePost;
use App\Post\Application\UseCase\UpdatePost;
use App\Post\Domain\Entity\Post;
use App\Post\Domain\Repository\PostRepositoryInterface;
use Netig\Netslim\Kernel\Pagination\Application\PaginatedResult;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
final class PostApplicationService implements PostServiceInterface
{
public function __construct(
private readonly PostRepositoryInterface $postRepository,
private readonly CreatePost $createPost,
private readonly UpdatePost $updatePost,
private readonly DeletePost $deletePost,
) {}
/** @return Post[] */
public function findAll(?int $categoryId = null): array
{
return $this->postRepository->findAll($categoryId);
}
/** @return PaginatedResult<Post> */
public function findPaginated(int $page, int $perPage, ?int $categoryId = null): PaginatedResult
{
$page = max(1, $page);
$total = $this->postRepository->countAll($categoryId);
$offset = ($page - 1) * $perPage;
return new PaginatedResult(
$this->postRepository->findPage($perPage, $offset, $categoryId),
$total,
$page,
$perPage,
);
}
/** @return Post[] */
public function findRecent(int $limit = 20): array
{
return $this->postRepository->findRecent($limit);
}
/** @return Post[] */
public function findByUserId(int $userId, ?int $categoryId = null): array
{
return $this->postRepository->findByUserId($userId, $categoryId);
}
/** @return PaginatedResult<Post> */
public function findByUserIdPaginated(int $userId, int $page, int $perPage, ?int $categoryId = null): PaginatedResult
{
$page = max(1, $page);
$total = $this->postRepository->countByUserId($userId, $categoryId);
$offset = ($page - 1) * $perPage;
return new PaginatedResult(
$this->postRepository->findByUserPage($userId, $perPage, $offset, $categoryId),
$total,
$page,
$perPage,
);
}
public function findBySlug(string $slug): Post
{
$post = $this->postRepository->findBySlug($slug);
if ($post === null) {
throw new NotFoundException('Article', $slug);
}
return $post;
}
public function findById(int $id): Post
{
$post = $this->postRepository->findById($id);
if ($post === null) {
throw new NotFoundException('Article', $id);
}
return $post;
}
public function create(string $title, string $content, int $authorId, ?int $categoryId = null): int
{
return $this->createPost->handle(new CreatePostCommand($title, $content, $authorId, $categoryId));
}
public function update(int $id, string $title, string $content, string $newSlugInput = '', ?int $categoryId = null): void
{
$this->updatePost->handle(new UpdatePostCommand($id, $title, $content, $newSlugInput, $categoryId));
}
/** @return Post[] */
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array
{
return $this->postRepository->search($query, $categoryId, $authorId);
}
/** @return PaginatedResult<Post> */
public function searchPaginated(string $query, int $page, int $perPage, ?int $categoryId = null, ?int $authorId = null): PaginatedResult
{
$page = max(1, $page);
$total = $this->postRepository->countSearch($query, $categoryId, $authorId);
$offset = ($page - 1) * $perPage;
return new PaginatedResult(
$this->postRepository->searchPage($query, $perPage, $offset, $categoryId, $authorId),
$total,
$page,
$perPage,
);
}
public function delete(int $id): void
{
$this->deletePost->handle($id);
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Post\Application;
use App\Post\Domain\Entity\Post;
use Netig\Netslim\Kernel\Pagination\Application\PaginatedResult;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
/**
* Contrat applicatif du domaine Post.
*/
interface PostServiceInterface
{
/**
* Retourne l'ensemble des articles, éventuellement filtrés par catégorie.
*
* @return list<Post>
*/
public function findAll(?int $categoryId = null): array;
/**
* Retourne une page d'articles, éventuellement filtrés par catégorie.
*
* @return PaginatedResult<Post>
*/
public function findPaginated(int $page, int $perPage, ?int $categoryId = null): PaginatedResult;
/**
* Retourne les articles les plus récents.
*
* @return list<Post>
*/
public function findRecent(int $limit = 20): array;
/**
* Retourne les articles d'un auteur, éventuellement filtrés par catégorie.
*
* @return list<Post>
*/
public function findByUserId(int $userId, ?int $categoryId = null): array;
/**
* Retourne une page d'articles pour un auteur, éventuellement filtrés par catégorie.
*
* @return PaginatedResult<Post>
*/
public function findByUserIdPaginated(int $userId, int $page, int $perPage, ?int $categoryId = null): PaginatedResult;
/**
* Retourne un article par slug.
*
* @throws NotFoundException Si aucun article ne correspond au slug fourni.
*/
public function findBySlug(string $slug): Post;
/**
* Retourne un article par identifiant.
*
* @throws NotFoundException Si aucun article ne correspond à l'identifiant fourni.
*/
public function findById(int $id): Post;
/**
* Recherche des articles selon une requête textuelle et des filtres optionnels.
*
* @return list<Post>
*/
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array;
/**
* Retourne une page de résultats de recherche.
*
* @return PaginatedResult<Post>
*/
public function searchPaginated(string $query, int $page, int $perPage, ?int $categoryId = null, ?int $authorId = null): PaginatedResult;
/**
* Crée un article et retourne son identifiant persistant.
*/
public function create(string $title, string $content, int $authorId, ?int $categoryId = null): int;
/**
* Met à jour un article existant.
*
* @throws NotFoundException Si l'article n'existe pas.
*/
public function update(int $id, string $title, string $content, string $slug = '', ?int $categoryId = null): void;
/**
* Supprime un article existant.
*
* @throws NotFoundException Si l'article n'existe pas.
*/
public function delete(int $id): void;
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Post\Application\UseCase;
use App\Post\Application\Command\CreatePostCommand;
use App\Post\Domain\Entity\Post;
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
use App\Post\Domain\Repository\PostRepositoryInterface;
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
use App\Post\Domain\Service\PostSlugGenerator;
use Netig\Netslim\Kernel\Html\Application\HtmlSanitizerInterface;
use Netig\Netslim\Kernel\Persistence\Application\TransactionManagerInterface;
/**
* Cas d'usage de création d'un article avec sanitation HTML et génération de slug unique.
*/
final readonly class CreatePost
{
public function __construct(
private PostRepositoryInterface $postRepository,
private HtmlSanitizerInterface $htmlSanitizer,
private PostSlugGenerator $slugGenerator,
private TransactionManagerInterface $transactionManager,
private PostMediaReferenceExtractorInterface $postMediaReferenceExtractor,
private PostMediaUsageRepositoryInterface $postMediaUsageRepository,
) {}
/**
* Crée un article, nettoie son HTML et retourne son identifiant.
*/
public function handle(CreatePostCommand $command): int
{
$sanitizedContent = $this->htmlSanitizer->sanitize($command->content);
$post = new Post(0, $command->title, $sanitizedContent);
$slug = $this->generateUniqueSlug($post->generateSlug());
$mediaIds = $this->postMediaReferenceExtractor->extractMediaIds($sanitizedContent);
return $this->transactionManager->run(function () use ($post, $slug, $command, $mediaIds): int {
$postId = $this->postRepository->create($post, $slug, $command->authorId, $command->categoryId);
$this->postMediaUsageRepository->syncPostMedia($postId, $mediaIds);
return $postId;
});
}
private function generateUniqueSlug(string $baseSlug): string
{
return $this->slugGenerator->unique(
$baseSlug,
fn (string $slug): bool => $this->postRepository->slugExists($slug),
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Post\Application\UseCase;
use App\Post\Domain\Repository\PostRepositoryInterface;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
/**
* Cas d'usage de suppression d'un article existant.
*/
final readonly class DeletePost
{
public function __construct(private PostRepositoryInterface $postRepository) {}
public function handle(int $id): void
{
$affected = $this->postRepository->delete($id);
if ($affected === 0) {
throw new NotFoundException('Article', $id);
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Post\Application\UseCase;
use App\Post\Application\Command\UpdatePostCommand;
use App\Post\Domain\Entity\Post;
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
use App\Post\Domain\Repository\PostRepositoryInterface;
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
use App\Post\Domain\Service\PostSlugGenerator;
use Netig\Netslim\Kernel\Html\Application\HtmlSanitizerInterface;
use Netig\Netslim\Kernel\Persistence\Application\TransactionManagerInterface;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
/**
* Cas d'usage de mise à jour d'un article existant.
*/
final readonly class UpdatePost
{
public function __construct(
private PostRepositoryInterface $postRepository,
private HtmlSanitizerInterface $htmlSanitizer,
private PostSlugGenerator $slugGenerator,
private TransactionManagerInterface $transactionManager,
private PostMediaReferenceExtractorInterface $postMediaReferenceExtractor,
private PostMediaUsageRepositoryInterface $postMediaUsageRepository,
) {}
public function handle(UpdatePostCommand $command): void
{
$current = $this->postRepository->findById($command->id);
if ($current === null) {
throw new NotFoundException('Article', $command->id);
}
$sanitizedContent = $this->htmlSanitizer->sanitize($command->content);
$post = new Post($command->id, $command->title, $sanitizedContent);
$slugToUse = $current->getStoredSlug();
$cleanSlugInput = $this->slugGenerator->normalize(trim($command->newSlugInput));
if ($cleanSlugInput !== '' && $cleanSlugInput !== $current->getStoredSlug()) {
$slugToUse = $this->generateUniqueSlug($cleanSlugInput, $command->id);
}
$mediaIds = $this->postMediaReferenceExtractor->extractMediaIds($sanitizedContent);
$this->transactionManager->run(function () use ($command, $post, $slugToUse, $mediaIds): void {
$affected = $this->postRepository->update($command->id, $post, $slugToUse, $command->categoryId);
if ($affected === 0) {
throw new NotFoundException('Article', $command->id);
}
$this->postMediaUsageRepository->syncPostMedia($command->id, $mediaIds);
});
}
private function generateUniqueSlug(string $baseSlug, ?int $excludeId = null): string
{
return $this->slugGenerator->unique(
$baseSlug,
fn (string $slug): bool => $this->postRepository->slugExists($slug, $excludeId),
);
}
}

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Post\Domain\Entity;
use DateTime;
use Netig\Netslim\Kernel\Support\Util\DateParser;
use Netig\Netslim\Kernel\Support\Util\SlugHelper;
/**
* Modèle représentant un article de blog.
*
* Encapsule les données et la validation d'un article.
* Ce modèle est immuable après construction.
* Le nom d'auteur est dénormalisé (chargé par JOIN dans PostRepository)
* pour éviter des requêtes supplémentaires à l'affichage.
* La logique de présentation (excerpt, formatage) est déléguée à TwigPostExtension.
*
* Distinction slug :
* - getStoredSlug() : slug lu depuis la base de données (canonique, peut comporter
* un suffixe numérique pour lever les collisions, ex: "mon-article-2")
* - generateSlug() : slug calculé dynamiquement depuis le titre, utilisé uniquement
* par PostApplicationService lors de la création/modification pour produire le slug à stocker
*/
final class Post
{
/**
* @var DateTime Date de création — toujours non nulle après construction
* (le constructeur accepte ?DateTime mais affecte `new DateTime()` si null)
*/
private readonly DateTime $createdAt;
/**
* @var DateTime Date de dernière modification — toujours non nulle après construction
*/
private readonly DateTime $updatedAt;
/**
* @param int $id Identifiant en base (0 pour un nouvel article)
* @param string $title Titre de l'article (1255 caractères)
* @param string $content Contenu HTML de l'article (165 535 caractères)
* @param string $slug Slug URL canonique, tel que stocké en base
* @param int|null $authorId Identifiant de l'auteur (null si le compte a été supprimé)
* @param string|null $authorUsername Nom de l'auteur dénormalisé (null si le compte a été supprimé)
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
* @param string|null $categoryName Nom de la catégorie dénormalisé (null si sans catégorie)
* @param string|null $categorySlug Slug de la catégorie dénormalisé (null si sans catégorie)
* @param DateTime|null $createdAt Date de création (défaut : maintenant)
* @param DateTime|null $updatedAt Date de dernière modification (défaut : maintenant)
*
* @throws \InvalidArgumentException Si les données ne passent pas la validation
*/
public function __construct(
private readonly int $id,
private readonly string $title,
private readonly string $content,
private readonly string $slug = '',
private readonly ?int $authorId = null,
private readonly ?string $authorUsername = null,
private readonly ?int $categoryId = null,
private readonly ?string $categoryName = null,
private readonly ?string $categorySlug = null,
?DateTime $createdAt = null,
?DateTime $updatedAt = null,
) {
$this->createdAt = $createdAt ?? new DateTime();
$this->updatedAt = $updatedAt ?? new DateTime();
$this->validate();
}
/**
* Crée une instance depuis un tableau associatif (ligne de base de données).
*
* @param array<string, mixed> $data Données issues de la base de données (avec JOIN users)
*
* @return self L'instance hydratée
*/
public static function fromArray(array $data): self
{
return new self(
id: (int) ($data['id'] ?? 0),
title: (string) ($data['title'] ?? ''),
content: (string) ($data['content'] ?? ''),
slug: (string) ($data['slug'] ?? ''),
authorId: isset($data['author_id']) ? (int) $data['author_id'] : null,
authorUsername: isset($data['author_username']) ? (string) $data['author_username'] : null,
categoryId: isset($data['category_id']) ? (int) $data['category_id'] : null,
categoryName: isset($data['category_name']) ? (string) $data['category_name'] : null,
categorySlug: isset($data['category_slug']) ? (string) $data['category_slug'] : null,
createdAt: DateParser::parse($data['created_at'] ?? null),
updatedAt: DateParser::parse($data['updated_at'] ?? null),
);
}
/**
* Retourne l'identifiant de l'article.
*
* @return int L'identifiant en base (0 si non encore persisté)
*/
public function getId(): int
{
return $this->id;
}
/**
* Retourne le titre de l'article.
*
* @return string Le titre
*/
public function getTitle(): string
{
return $this->title;
}
/**
* Retourne le contenu HTML de l'article.
*
* @return string Le contenu HTML sanitisé (purifié par HTMLPurifier à l'écriture)
*/
public function getContent(): string
{
return $this->content;
}
/**
* Retourne le slug canonique tel que stocké en base de données.
*
* Ce slug peut différer du résultat de generateSlug() si un suffixe numérique
* a été ajouté lors de la création pour lever une collision
* (ex: titre "Mon article" → slug en DB "mon-article-2").
* C'est cette valeur qu'il faut utiliser pour construire les URLs publiques.
*
* @return string Le slug canonique (vide si l'article n'a pas encore été persisté)
*/
public function getStoredSlug(): string
{
return $this->slug;
}
/**
* Retourne l'identifiant de l'auteur.
*
* @return int|null L'identifiant de l'auteur, ou null si le compte a été supprimé
*/
public function getAuthorId(): ?int
{
return $this->authorId;
}
/**
* Retourne le nom d'utilisateur de l'auteur.
*
* @return string|null Le nom d'utilisateur, ou null si le compte a été supprimé
*/
public function getAuthorUsername(): ?string
{
return $this->authorUsername;
}
/**
* Retourne l'identifiant de la catégorie de l'article.
*
* @return int|null L'identifiant de la catégorie, ou null si l'article est sans catégorie
*/
public function getCategoryId(): ?int
{
return $this->categoryId;
}
/**
* Retourne le nom de la catégorie de l'article.
*
* @return string|null Le nom de la catégorie, ou null si l'article est sans catégorie
*/
public function getCategoryName(): ?string
{
return $this->categoryName;
}
/**
* Retourne le slug de la catégorie de l'article.
*
* @return string|null Le slug de la catégorie, ou null si l'article est sans catégorie
*/
public function getCategorySlug(): ?string
{
return $this->categorySlug;
}
/**
* Retourne la date de création de l'article.
*
* @return DateTime La date de création
*/
public function getCreatedAt(): DateTime
{
return $this->createdAt;
}
/**
* Retourne la date de dernière modification de l'article.
*
* @return DateTime La date de dernière modification
*/
public function getUpdatedAt(): DateTime
{
return $this->updatedAt;
}
/**
* Génère un slug URL-friendly calculé à partir du titre courant.
*
* Cette méthode est réservée à PostApplicationService pour produire le slug à stocker
* lors de la création ou de la modification d'un article.
* Pour construire une URL publique, utiliser getStoredSlug() qui retourne
* le slug canonique tel qu'il est enregistré en base de données.
*
* La génération est déléguée à SlugHelper::generate() — voir sa documentation
* pour le détail de l'algorithme (translittération ASCII, minuscules, tirets).
*
* @return string Le slug en minuscules avec tirets (ex: "ete-en-foret")
*/
public function generateSlug(): string
{
return SlugHelper::generate($this->title);
}
/**
* Valide les données de l'article.
*
* @throws \InvalidArgumentException Si le titre est vide ou dépasse 255 caractères
* @throws \InvalidArgumentException Si le contenu est vide ou dépasse 65 535 caractères
*/
private function validate(): void
{
if ($this->title === '') {
throw new \InvalidArgumentException('Le titre ne peut pas être vide');
}
if (mb_strlen($this->title) > 255) {
throw new \InvalidArgumentException('Le titre ne peut pas dépasser 255 caractères');
}
if ($this->content === '') {
throw new \InvalidArgumentException('Le contenu ne peut pas être vide');
}
if (mb_strlen($this->content) > 65535) {
throw new \InvalidArgumentException('Le contenu ne peut pas dépasser 65 535 caractères');
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Post\Domain\Repository;
use App\Post\Domain\ValueObject\PostMediaUsageReference;
/**
* Contrat de lecture et synchronisation des usages médias portés par les articles.
*/
interface PostMediaUsageRepositoryInterface
{
/**
* Retourne le nombre total d'usages d'un média dans les articles.
*/
public function countUsages(int $mediaId): int;
/**
* Retourne le nombre total d'usages pour chaque média demandé.
*
* @param list<int> $mediaIds Identifiants des médias à compter.
* @return array<int, int> Table indexée par identifiant de média.
*/
public function countUsagesByMediaIds(array $mediaIds): array;
/**
* Retourne un échantillon de références de contenus utilisant un média.
*
* @return list<PostMediaUsageReference>
*/
public function findUsages(int $mediaId, int $limit = 5): array;
/**
* Retourne un échantillon de références de contenus pour chaque média demandé.
*
* @param list<int> $mediaIds Identifiants des médias à inspecter.
* @return array<int, list<PostMediaUsageReference>> Table indexée par identifiant de média.
*/
public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array;
/**
* Remplace les références médias associées à un post.
*
* @param list<int> $mediaIds Identifiants de médias extraits du contenu de l'article.
*/
public function syncPostMedia(int $postId, array $mediaIds): void;
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Post\Domain\Repository;
use App\Post\Domain\Entity\Post;
/**
* Contrat de persistance des articles.
*/
interface PostRepositoryInterface
{
/**
* Retourne tous les articles, éventuellement filtrés par catégorie.
*
* @return list<Post>
*/
public function findAll(?int $categoryId = null): array;
/**
* Retourne une page d'articles, éventuellement filtrés par catégorie.
*
* @return list<Post>
*/
public function findPage(int $limit, int $offset, ?int $categoryId = null): array;
/**
* Retourne le nombre total d'articles, éventuellement filtrés par catégorie.
*/
public function countAll(?int $categoryId = null): int;
/**
* Retourne les articles les plus récents.
*
* @return list<Post>
*/
public function findRecent(int $limit): array;
/**
* Retourne les articles d'un auteur, éventuellement filtrés par catégorie.
*
* @return list<Post>
*/
public function findByUserId(int $userId, ?int $categoryId = null): array;
/**
* Retourne une page d'articles d'un auteur.
*
* @return list<Post>
*/
public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array;
/**
* Retourne le nombre total d'articles d'un auteur.
*/
public function countByUserId(int $userId, ?int $categoryId = null): int;
/**
* Retourne un article par slug, ou `null` s'il n'existe pas.
*/
public function findBySlug(string $slug): ?Post;
/**
* Retourne un article par identifiant, ou `null` s'il n'existe pas.
*/
public function findById(int $id): ?Post;
/**
* Persiste un nouvel article et retourne son identifiant.
*/
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int;
/**
* Met à jour un article et retourne le nombre de lignes affectées.
*/
public function update(int $id, Post $post, string $slug, ?int $categoryId): int;
/**
* Supprime un article et retourne le nombre de lignes affectées.
*/
public function delete(int $id): int;
/**
* Recherche des articles selon une requête textuelle et des filtres optionnels.
*
* @return list<Post>
*/
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array;
/**
* Retourne une page de résultats de recherche.
*
* @return list<Post>
*/
public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array;
/**
* Retourne le nombre total de résultats pour une recherche.
*/
public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int;
/**
* Indique si un slug existe déjà, éventuellement hors d'un article donné.
*/
public function slugExists(string $slug, ?int $excludeId = null): bool;
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Post\Domain\Service;
/**
* Extrait les identifiants de médias référencés dans le contenu HTML d'un article.
*/
interface PostMediaReferenceExtractorInterface
{
/**
* Retourne les identifiants de médias présents dans le HTML fourni.
*
* @return list<int>
*/
public function extractMediaIds(string $html): array;
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Post\Domain\Service;
use Netig\Netslim\Kernel\Support\Util\SlugHelper;
/**
* Fournit des helpers de génération de slug pour les articles.
*/
final class PostSlugGenerator
{
public function normalize(string $input): string
{
return SlugHelper::generate($input);
}
public function unique(string $baseSlug, callable $exists): string
{
$slug = $baseSlug;
$counter = 1;
while ($exists($slug)) {
$slug = $baseSlug . '-' . $counter;
++$counter;
}
return $slug;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Post\Domain\ValueObject;
final readonly class PostMediaUsageReference
{
public function __construct(
private int $postId,
private string $postTitle,
private string $postEditPath,
) {}
public function getPostId(): int
{
return $this->postId;
}
public function getPostTitle(): string
{
return $this->postTitle;
}
public function getPostEditPath(): string
{
return $this->postEditPath;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Post\Infrastructure;
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
final class HtmlPostMediaReferenceExtractor implements PostMediaReferenceExtractorInterface
{
/** @return list<int> */
public function extractMediaIds(string $html): array
{
if (trim($html) === '') {
return [];
}
$document = new \DOMDocument('1.0', 'UTF-8');
$root = $this->loadFragment($document, $html);
if ($root === null) {
return [];
}
$xpath = new \DOMXPath($document);
$nodes = $xpath->query('.//*[@data-media-id]', $root);
if ($nodes === false || $nodes->length === 0) {
return [];
}
$mediaIds = [];
foreach ($nodes as $node) {
if (!$node instanceof \DOMElement) {
continue;
}
$mediaId = (int) trim($node->getAttribute('data-media-id'));
if ($mediaId > 0) {
$mediaIds[] = $mediaId;
}
}
$mediaIds = array_values(array_unique($mediaIds));
sort($mediaIds);
return $mediaIds;
}
private function loadFragment(\DOMDocument $document, string $html): ?\DOMElement
{
$previous = libxml_use_internal_errors(true);
try {
$wrapped = '<!DOCTYPE html><html><body><div data-post-media-root="1">' . $html . '</div></body></html>';
$loaded = $document->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_COMPACT);
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
if ($loaded !== true) {
return null;
}
$xpath = new \DOMXPath($document);
$nodes = $xpath->query('//div[@data-post-media-root="1"]');
if ($nodes === false || $nodes->length === 0) {
return null;
}
$node = $nodes->item(0);
return $node instanceof \DOMElement ? $node : null;
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Post\Infrastructure;
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
use App\Post\Domain\ValueObject\PostMediaUsageReference;
use PDO;
/**
* Implémentation PDO des lectures et synchronisations d'usages médias portés par les articles.
*/
final class PdoPostMediaUsageRepository implements PostMediaUsageRepositoryInterface
{
public function __construct(private readonly PDO $db) {}
public function countUsages(int $mediaId): int
{
$stmt = $this->db->prepare('SELECT COUNT(*) FROM post_media WHERE media_id = :media_id');
$stmt->execute([':media_id' => $mediaId]);
return (int) $stmt->fetchColumn();
}
/**
* @param list<int> $mediaIds
* @return array<int, int>
*/
public function countUsagesByMediaIds(array $mediaIds): array
{
$mediaIds = $this->normalizeMediaIds($mediaIds);
if ($mediaIds === []) {
return [];
}
$placeholders = $this->buildPlaceholders($mediaIds);
$stmt = $this->db->prepare(
sprintf(
'SELECT media_id, COUNT(*) AS usage_count
FROM post_media
WHERE media_id IN (%s)
GROUP BY media_id',
$placeholders,
),
);
$stmt->execute($mediaIds);
$countsByMediaId = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$countsByMediaId[(int) $row['media_id']] = (int) $row['usage_count'];
}
return $countsByMediaId;
}
/** @return list<PostMediaUsageReference> */
public function findUsages(int $mediaId, int $limit = 5): array
{
$stmt = $this->db->prepare(
'SELECT posts.id, posts.title
FROM post_media
INNER JOIN posts ON posts.id = post_media.post_id
WHERE post_media.media_id = :media_id
ORDER BY posts.id DESC
LIMIT :limit',
);
$stmt->bindValue(':media_id', $mediaId, PDO::PARAM_INT);
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$stmt->execute();
return array_map(
static fn (array $row): PostMediaUsageReference => new PostMediaUsageReference(
(int) $row['id'],
(string) $row['title'],
'/admin/posts/edit/' . (int) $row['id'],
),
$stmt->fetchAll(PDO::FETCH_ASSOC),
);
}
/**
* @param list<int> $mediaIds
* @return array<int, list<PostMediaUsageReference>>
*/
public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array
{
$mediaIds = $this->normalizeMediaIds($mediaIds);
if ($mediaIds === []) {
return [];
}
$limit = max(1, $limit);
$placeholders = $this->buildPlaceholders($mediaIds);
$stmt = $this->db->prepare(
sprintf(
'SELECT post_media.media_id, posts.id, posts.title
FROM post_media
INNER JOIN posts ON posts.id = post_media.post_id
WHERE post_media.media_id IN (%s)
ORDER BY post_media.media_id ASC, posts.id DESC',
$placeholders,
),
);
$stmt->execute($mediaIds);
$referencesByMediaId = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$mediaId = (int) $row['media_id'];
$referencesByMediaId[$mediaId] ??= [];
if (count($referencesByMediaId[$mediaId]) >= $limit) {
continue;
}
$referencesByMediaId[$mediaId][] = new PostMediaUsageReference(
(int) $row['id'],
(string) $row['title'],
'/admin/posts/edit/' . (int) $row['id'],
);
}
return $referencesByMediaId;
}
/**
* @param list<int> $mediaIds
*/
public function syncPostMedia(int $postId, array $mediaIds): void
{
$stmt = $this->db->prepare('DELETE FROM post_media WHERE post_id = :post_id');
$stmt->execute([':post_id' => $postId]);
$mediaIds = $this->normalizeMediaIds($mediaIds);
if ($mediaIds === []) {
return;
}
$insert = $this->db->prepare(
'INSERT OR IGNORE INTO post_media (post_id, media_id, usage_type)
VALUES (:post_id, :media_id, :usage_type)',
);
foreach ($mediaIds as $mediaId) {
$insert->execute([
':post_id' => $postId,
':media_id' => $mediaId,
':usage_type' => 'embedded',
]);
}
}
/**
* Filtre et déduplique une liste d'identifiants de médias.
*
* @param list<int> $mediaIds
* @return list<int>
*/
private function normalizeMediaIds(array $mediaIds): array
{
return array_values(array_unique(array_filter($mediaIds, static fn (int $mediaId): bool => $mediaId > 0)));
}
/**
* Construit une liste de placeholders positionnels pour une clause `IN`.
*
* @param list<int> $mediaIds
*/
private function buildPlaceholders(array $mediaIds): string
{
return implode(', ', array_fill(0, count($mediaIds), '?'));
}
}

View File

@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace App\Post\Infrastructure;
use App\Post\Domain\Entity\Post;
use App\Post\Domain\Repository\PostRepositoryInterface;
use PDO;
use PDOStatement;
class PdoPostRepository implements PostRepositoryInterface
{
private const SELECT = '
SELECT posts.id, posts.title, posts.content, posts.slug,
posts.author_id, posts.category_id, posts.created_at, posts.updated_at,
users.username AS author_username,
categories.name AS category_name,
categories.slug AS category_slug
FROM posts
LEFT JOIN users ON users.id = posts.author_id
LEFT JOIN categories ON categories.id = posts.category_id
';
public function __construct(private readonly PDO $db) {}
/** @return Post[] */
public function findAll(?int $categoryId = null): array
{
if ($categoryId === null) {
$stmt = $this->db->query(self::SELECT . ' ORDER BY posts.id DESC');
if ($stmt === false) {
throw new \RuntimeException('La requête SELECT sur posts a échoué.');
}
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC');
$stmt->execute([':category_id' => $categoryId]);
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
/** @return Post[] */
public function findPage(int $limit, int $offset, ?int $categoryId = null): array
{
$sql = self::SELECT;
$params = [];
if ($categoryId !== null) {
$sql .= ' WHERE posts.category_id = :category_id';
$params[':category_id'] = $categoryId;
}
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
$stmt = $this->db->prepare($sql);
$this->bindParams($stmt, $params);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
public function countAll(?int $categoryId = null): int
{
if ($categoryId === null) {
$stmt = $this->db->query('SELECT COUNT(*) FROM posts');
if ($stmt === false) {
throw new \RuntimeException('Le comptage des posts a échoué.');
}
return (int) $stmt->fetchColumn();
}
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id');
$stmt->execute([':category_id' => $categoryId]);
return (int) $stmt->fetchColumn();
}
/** @return Post[] */
public function findRecent(int $limit): array
{
$stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.created_at DESC LIMIT :limit');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
/** @return Post[] */
public function findByUserId(int $userId, ?int $categoryId = null): array
{
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
$params = [':author_id' => $userId];
if ($categoryId !== null) {
$sql .= ' AND posts.category_id = :category_id';
$params[':category_id'] = $categoryId;
}
$sql .= ' ORDER BY posts.id DESC';
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
/** @return Post[] */
public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array
{
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
$params = [':author_id' => $userId];
if ($categoryId !== null) {
$sql .= ' AND posts.category_id = :category_id';
$params[':category_id'] = $categoryId;
}
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
$stmt = $this->db->prepare($sql);
$this->bindParams($stmt, $params);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
public function countByUserId(int $userId, ?int $categoryId = null): int
{
$sql = 'SELECT COUNT(*) FROM posts WHERE author_id = :author_id';
$params = [':author_id' => $userId];
if ($categoryId !== null) {
$sql .= ' AND category_id = :category_id';
$params[':category_id'] = $categoryId;
}
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetchColumn();
}
public function findBySlug(string $slug): ?Post
{
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.slug = :slug');
$stmt->execute([':slug' => $slug]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Post::fromArray($row) : null;
}
public function findById(int $id): ?Post
{
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Post::fromArray($row) : null;
}
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int
{
$stmt = $this->db->prepare(
'INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at)
VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at)',
);
$stmt->execute([
':title' => $post->getTitle(),
':content' => $post->getContent(),
':slug' => $slug,
':author_id' => $authorId,
':category_id' => $categoryId,
':created_at' => date('Y-m-d H:i:s'),
':updated_at' => date('Y-m-d H:i:s'),
]);
return (int) $this->db->lastInsertId();
}
public function update(int $id, Post $post, string $slug, ?int $categoryId): int
{
$stmt = $this->db->prepare(
'UPDATE posts
SET title = :title, content = :content, slug = :slug,
category_id = :category_id, updated_at = :updated_at
WHERE id = :id',
);
$stmt->execute([
':title' => $post->getTitle(),
':content' => $post->getContent(),
':slug' => $slug,
':category_id' => $categoryId,
':updated_at' => date('Y-m-d H:i:s'),
':id' => $id,
]);
return $stmt->rowCount();
}
public function delete(int $id): int
{
$stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
$stmt->execute([':id' => $id]);
return $stmt->rowCount();
}
/** @return Post[] */
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array
{
$normalizedQuery = $this->normalizeSearchQuery($query);
if ($normalizedQuery === null) {
return [];
}
$params = [':q' => $normalizedQuery];
$sql = $this->buildSearchSql($params, $categoryId, $authorId);
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
/** @return Post[] */
public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array
{
$normalizedQuery = $this->normalizeSearchQuery($query);
if ($normalizedQuery === null) {
return [];
}
$params = [':q' => $normalizedQuery];
$sql = $this->buildSearchSql($params, $categoryId, $authorId);
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
$stmt = $this->db->prepare($sql);
$this->bindParams($stmt, $params);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int
{
$normalizedQuery = $this->normalizeSearchQuery($query);
if ($normalizedQuery === null) {
return 0;
}
$params = [':q' => $normalizedQuery];
$sql = 'SELECT COUNT(*) FROM posts WHERE id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH :q)';
if ($categoryId !== null) {
$sql .= ' AND category_id = :category_id';
$params[':category_id'] = $categoryId;
}
if ($authorId !== null) {
$sql .= ' AND author_id = :author_id';
$params[':author_id'] = $authorId;
}
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetchColumn();
}
public function slugExists(string $slug, ?int $excludeId = null): bool
{
$stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug LIMIT 1');
$stmt->execute([':slug' => $slug]);
$existingId = $stmt->fetchColumn();
if ($existingId === false) {
return false;
}
if ($excludeId !== null && (int) $existingId === $excludeId) {
return false;
}
return true;
}
/** @param array<string, mixed> $params */
private function buildSearchSql(array &$params, ?int $categoryId = null, ?int $authorId = null): string
{
$sql = self::SELECT . ' WHERE posts.id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH :q)';
if ($categoryId !== null) {
$sql .= ' AND posts.category_id = :category_id';
$params[':category_id'] = $categoryId;
}
if ($authorId !== null) {
$sql .= ' AND posts.author_id = :author_id';
$params[':author_id'] = $authorId;
}
return $sql;
}
private function normalizeSearchQuery(string $query): ?string
{
preg_match_all('/[\p{L}\p{N}_]+/u', $query, $matches);
$terms = array_values(array_filter($matches[0], static fn (string $term): bool => $term !== ''));
if ($terms === []) {
return null;
}
$quotedTerms = array_map(
static fn (string $term): string => '"' . str_replace('"', '""', $term) . '"',
$terms,
);
return implode(' ', $quotedTerms);
}
/** @param array<string, mixed> $params */
private function bindParams(PDOStatement $stmt, array $params): void
{
foreach ($params as $name => $value) {
$stmt->bindValue($name, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
}
/** @param array<int, array<string, mixed>> $rows
* @return Post[] */
private function hydratePosts(array $rows): array
{
return array_map(static fn (array $row): Post => Post::fromArray($row), $rows);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Post\Infrastructure;
use Netig\Netslim\Taxonomy\Contracts\TaxonUsageCheckerInterface;
use PDO;
/**
* Vérifie si un terme de taxonomie est encore référencé par au moins un post.
*/
final readonly class PdoTaxonUsageChecker implements TaxonUsageCheckerInterface
{
public function __construct(private PDO $db) {}
public function isTaxonInUse(int $taxonId): bool
{
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id');
$stmt->execute([':category_id' => $taxonId]);
return (int) $stmt->fetchColumn() > 0;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Post\Infrastructure;
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
use App\Post\Domain\ValueObject\PostMediaUsageReference;
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
use Netig\Netslim\Media\Contracts\MediaUsageReference;
/**
* Adaptateur entre les usages médias exposés par le module Post et le port lu par Media.
*/
final class PostMediaUsageReader implements MediaUsageReaderInterface
{
public function __construct(
private readonly PostMediaUsageRepositoryInterface $postMediaUsageRepository,
) {}
public function countUsages(int $mediaId): int
{
return $this->postMediaUsageRepository->countUsages($mediaId);
}
/**
* @param list<int> $mediaIds
* @return array<int, int>
*/
public function countUsagesByMediaIds(array $mediaIds): array
{
return $this->postMediaUsageRepository->countUsagesByMediaIds($mediaIds);
}
/** @return list<MediaUsageReference> */
public function findUsages(int $mediaId, int $limit = 5): array
{
return $this->mapUsageReferences($this->postMediaUsageRepository->findUsages($mediaId, $limit));
}
/**
* @param list<int> $mediaIds
* @return array<int, list<MediaUsageReference>>
*/
public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array
{
$referencesByMediaId = [];
foreach ($this->postMediaUsageRepository->findUsagesByMediaIds($mediaIds, $limit) as $mediaId => $references) {
$referencesByMediaId[$mediaId] = $this->mapUsageReferences($references);
}
return $referencesByMediaId;
}
/**
* Transforme les références d'usage issues du module Post vers le VO exposé au module Media.
*
* @param list<PostMediaUsageReference> $references
* @return list<MediaUsageReference>
*/
private function mapUsageReferences(array $references): array
{
return array_map(
static fn (PostMediaUsageReference $reference): MediaUsageReference => new MediaUsageReference(
$reference->getPostId(),
$reference->getPostTitle(),
$reference->getPostEditPath(),
),
$references,
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Post\Infrastructure;
use PDO;
/**
* Synchronise l'index FTS5 du module Post avec les articles présents en base.
*/
final class PostSearchIndexer
{
public static function syncFtsIndex(PDO $db): void
{
$db->exec("
INSERT INTO posts_fts(rowid, title, content, author_username)
SELECT p.id,
p.title,
COALESCE(strip_tags(p.content), ''),
COALESCE((SELECT username FROM users WHERE id = p.author_id), '')
FROM posts p
WHERE p.id NOT IN (SELECT rowid FROM posts_fts)
");
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Post\Application\PostApplicationService;
use App\Post\Application\PostServiceInterface;
use App\Post\Application\UseCase\CreatePost;
use App\Post\Application\UseCase\DeletePost;
use App\Post\Application\UseCase\UpdatePost;
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
use App\Post\Domain\Repository\PostRepositoryInterface;
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
use App\Post\Domain\Service\PostSlugGenerator;
use App\Post\Infrastructure\HtmlPostMediaReferenceExtractor;
use App\Post\Infrastructure\PdoPostMediaUsageRepository;
use App\Post\Infrastructure\PdoPostRepository;
use App\Post\Infrastructure\PdoTaxonUsageChecker;
use App\Post\Infrastructure\PostMediaUsageReader;
use App\Post\UI\Http\RssController;
use function DI\autowire;
use function DI\factory;
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
use Netig\Netslim\Taxonomy\Contracts\TaxonUsageCheckerInterface;
return [
PostServiceInterface::class => autowire(PostApplicationService::class),
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
PostMediaUsageRepositoryInterface::class => autowire(PdoPostMediaUsageRepository::class),
PostMediaReferenceExtractorInterface::class => autowire(HtmlPostMediaReferenceExtractor::class),
TaxonUsageCheckerInterface::class => autowire(PdoTaxonUsageChecker::class),
PostSlugGenerator::class => autowire(),
CreatePost::class => autowire(),
UpdatePost::class => autowire(),
DeletePost::class => autowire(),
MediaUsageReaderInterface::class => autowire(PostMediaUsageReader::class),
RssController::class => factory(function (PostServiceInterface $postService): RssController {
return new RssController(
$postService,
rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/'),
$_ENV['APP_NAME'] ?? 'Netslim Blog',
);
}),
];

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL DEFAULT '',
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_posts_author_id ON posts(author_id);
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
title,
content,
author_username,
tokenize = 'unicode61 remove_diacritics 1'
);
CREATE TRIGGER IF NOT EXISTS posts_fts_insert
AFTER INSERT ON posts BEGIN
INSERT INTO posts_fts(rowid, title, content, author_username)
VALUES (
NEW.id,
NEW.title,
COALESCE(strip_tags(NEW.content), ''),
COALESCE((SELECT username FROM users WHERE id = NEW.author_id), '')
);
END;
CREATE TRIGGER IF NOT EXISTS posts_fts_update
AFTER UPDATE ON posts BEGIN
DELETE FROM posts_fts WHERE rowid = OLD.id;
INSERT INTO posts_fts(rowid, title, content, author_username)
VALUES (
NEW.id,
NEW.title,
COALESCE(strip_tags(NEW.content), ''),
COALESCE((SELECT username FROM users WHERE id = NEW.author_id), '')
);
END;
CREATE TRIGGER IF NOT EXISTS posts_fts_delete
AFTER DELETE ON posts BEGIN
DELETE FROM posts_fts WHERE rowid = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS posts_fts_users_update
AFTER UPDATE OF username ON users BEGIN
DELETE FROM posts_fts
WHERE rowid IN (SELECT id FROM posts WHERE author_id = NEW.id);
INSERT INTO posts_fts(rowid, title, content, author_username)
SELECT p.id,
p.title,
COALESCE(strip_tags(p.content), ''),
NEW.username
FROM posts p
WHERE p.author_id = NEW.id;
END;
CREATE TABLE IF NOT EXISTS post_media (
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
usage_type TEXT NOT NULL DEFAULT 'embedded',
PRIMARY KEY (post_id, media_id, usage_type)
);
CREATE INDEX IF NOT EXISTS idx_post_media_media_id ON post_media(media_id);
CREATE INDEX IF NOT EXISTS idx_post_media_post_id ON post_media(post_id);
",
'down' => "
DROP TRIGGER IF EXISTS posts_fts_users_update;
DROP TRIGGER IF EXISTS posts_fts_delete;
DROP TRIGGER IF EXISTS posts_fts_update;
DROP TRIGGER IF EXISTS posts_fts_insert;
DROP TABLE IF EXISTS posts_fts;
DROP INDEX IF EXISTS idx_post_media_post_id;
DROP INDEX IF EXISTS idx_post_media_media_id;
DROP TABLE IF EXISTS post_media;
DROP INDEX IF EXISTS idx_posts_author_id;
DROP TABLE IF EXISTS posts;
",
];

53
src/Post/PostModule.php Normal file
View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Post;
use App\Post\Infrastructure\PostSearchIndexer;
use App\Post\UI\Http\Routes;
use App\Post\UI\Twig\TwigPostExtension;
use Netig\Netslim\Kernel\Runtime\Module\ModuleInterface;
use Netig\Netslim\Kernel\Runtime\Module\ProvidesMigrationMaintenanceInterface;
use Netig\Netslim\Kernel\Runtime\Module\ProvidesSchemaInterface;
use Psr\Container\ContainerInterface;
use Slim\App;
final class PostModule implements ModuleInterface, ProvidesSchemaInterface, ProvidesMigrationMaintenanceInterface
{
public function definitions(): array
{
return require __DIR__ . '/Infrastructure/dependencies.php';
}
/** @param App<ContainerInterface> $app */
public function registerRoutes(App $app): void
{
Routes::register($app);
}
public function templateNamespaces(): array
{
return ['Post' => __DIR__ . '/UI/Templates'];
}
public function twigExtensions(): array
{
return [TwigPostExtension::class];
}
public function migrationDirectories(): array
{
return [__DIR__ . '/Migrations'];
}
public function requiredTables(): array
{
return ['posts', 'post_media', 'posts_fts'];
}
public function afterMigrations(\PDO $db): void
{
PostSearchIndexer::syncFtsIndex($db);
}
}

View File

@@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
namespace App\Post\UI\Http;
use App\Post\Application\PostServiceInterface;
use App\Post\Domain\Entity\Post;
use App\Post\UI\Http\Request\PostFormRequest;
use Netig\Netslim\AuditLog\Contracts\AuditLoggerInterface;
use Netig\Netslim\Identity\Application\AuthorizationServiceInterface;
use Netig\Netslim\Identity\Domain\Policy\Permission;
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
use Netig\Netslim\Kernel\Pagination\Infrastructure\PaginationPresenter;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
use Netig\Netslim\Settings\Contracts\SettingsReaderInterface;
use Netig\Netslim\Taxonomy\Contracts\TaxonomyReaderInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Slim\Exception\HttpNotFoundException;
use Slim\Views\Twig;
/**
* Contrôleur HTTP des pages publiques et administratives liées aux articles.
*/
final class PostController
{
public function __construct(
private readonly Twig $view,
private readonly PostServiceInterface $postService,
private readonly TaxonomyReaderInterface $taxonomyReader,
private readonly SettingsReaderInterface $settings,
private readonly AuthorizationServiceInterface $authorization,
private readonly AuditLoggerInterface $auditLogger,
private readonly FlashServiceInterface $flash,
private readonly SessionManagerInterface $sessionManager,
private readonly ?LoggerInterface $logger = null,
) {}
public function index(Request $req, Response $res): Response
{
$params = $req->getQueryParams();
$page = PaginationPresenter::resolvePage($params);
$searchQuery = trim((string) ($params['q'] ?? ''));
$taxonSlug = (string) ($params['categorie'] ?? '');
$activeTaxon = null;
$taxonId = null;
$perPage = $this->publicPerPage();
if ($taxonSlug !== '') {
$activeTaxon = $this->taxonomyReader->findBySlug($taxonSlug);
$taxonId = $activeTaxon?->id;
}
$paginated = $searchQuery !== ''
? $this->postService->searchPaginated($searchQuery, $page, $perPage, $taxonId)
: $this->postService->findPaginated($page, $perPage, $taxonId);
return $this->view->render($res, '@Post/home.twig', [
'posts' => $paginated->getItems(),
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
'totalPosts' => $paginated->getTotal(),
'categories' => $this->taxonomyReader->findAll(),
'activeCategory' => $activeTaxon,
'searchQuery' => $searchQuery,
]);
}
/**
* Affiche un article public à partir de son slug et retourne une 404 si le contenu est introuvable.
*
* @param array<string, mixed> $args
*/
public function show(Request $req, Response $res, array $args): Response
{
try {
$post = $this->postService->findBySlug((string) ($args['slug'] ?? ''));
} catch (NotFoundException) {
throw new HttpNotFoundException($req);
}
return $this->view->render($res, '@Post/detail.twig', ['post' => $post]);
}
public function admin(Request $req, Response $res): Response
{
$isPrivileged = $this->canManageAllContent();
$userId = $this->sessionManager->getUserId();
$params = $req->getQueryParams();
$page = PaginationPresenter::resolvePage($params);
$searchQuery = trim((string) ($params['q'] ?? ''));
$taxonSlug = (string) ($params['categorie'] ?? '');
$activeTaxon = null;
$taxonId = null;
$perPage = $this->adminPerPage();
if ($taxonSlug !== '') {
$activeTaxon = $this->taxonomyReader->findBySlug($taxonSlug);
$taxonId = $activeTaxon?->id;
}
if ($searchQuery !== '') {
$authorId = $isPrivileged ? null : (int) $userId;
$paginated = $this->postService->searchPaginated(
$searchQuery,
$page,
$perPage,
$taxonId,
$authorId,
);
} else {
$paginated = $isPrivileged
? $this->postService->findPaginated($page, $perPage, $taxonId)
: $this->postService->findByUserIdPaginated((int) $userId, $page, $perPage, $taxonId);
}
return $this->view->render($res, '@Post/admin/index.twig', [
'posts' => $paginated->getItems(),
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
'totalPosts' => $paginated->getTotal(),
'categories' => $this->taxonomyReader->findAll(),
'activeCategory' => $activeTaxon,
'searchQuery' => $searchQuery,
'error' => $this->flash->get('post_error'),
'success' => $this->flash->get('post_success'),
]);
}
/** @param array<string, mixed> $args */
public function form(Request $req, Response $res, array $args): Response
{
$id = (int) ($args['id'] ?? 0);
$post = null;
if ($id > 0) {
try {
$post = $this->postService->findById($id);
} catch (NotFoundException) {
throw new HttpNotFoundException($req);
}
if (!$this->canEditPost($post)) {
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur");
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
}
return $this->view->render($res, '@Post/admin/form.twig', [
'post' => $post,
'categories' => $this->taxonomyReader->findAll(),
'action' => $id > 0 ? "/admin/posts/edit/{$id}" : '/admin/posts/create',
'error' => $this->flash->get('post_error'),
]);
}
/**
* Traite la création d'un article depuis l'administration.
*/
public function create(Request $req, Response $res): Response
{
$postFormRequest = PostFormRequest::fromRequest($req);
try {
$postId = $this->postService->create(
$postFormRequest->title,
$postFormRequest->content,
$this->sessionManager->getUserId() ?? 0,
$postFormRequest->categoryId,
);
$this->auditLogger->record(
'post.created',
'post',
(string) $postId,
$this->sessionManager->getUserId(),
['title' => $postFormRequest->title],
);
$this->flash->set('post_success', 'L\'article a été créé avec succès');
} catch (\InvalidArgumentException $e) {
$this->flash->set('post_error', $e->getMessage());
return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302);
} catch (\Throwable $e) {
$incidentId = $this->logUnexpectedError($req, $e, [
'user_id' => $this->sessionManager->getUserId(),
]);
$this->flash->set('post_error', "Une erreur inattendue s'est produite (réf. {$incidentId})");
return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302);
}
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
/** @param array<string, mixed> $args */
public function update(Request $req, Response $res, array $args): Response
{
$id = (int) $args['id'];
$postFormRequest = PostFormRequest::fromRequest($req);
try {
$post = $this->postService->findById($id);
} catch (NotFoundException) {
throw new HttpNotFoundException($req);
}
if (!$this->canEditPost($post)) {
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur");
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
try {
$this->postService->update(
$id,
$postFormRequest->title,
$postFormRequest->content,
$postFormRequest->slug,
$postFormRequest->categoryId,
);
$this->auditLogger->record(
'post.updated',
'post',
(string) $id,
$this->sessionManager->getUserId(),
['title' => $postFormRequest->title],
);
$this->flash->set('post_success', 'L\'article a été modifié avec succès');
} catch (NotFoundException) {
throw new HttpNotFoundException($req);
} catch (\InvalidArgumentException $e) {
$this->flash->set('post_error', $e->getMessage());
return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302);
} catch (\Throwable $e) {
$incidentId = $this->logUnexpectedError($req, $e, [
'post_id' => $id,
'user_id' => $this->sessionManager->getUserId(),
]);
$this->flash->set('post_error', "Une erreur inattendue s'est produite (réf. {$incidentId})");
return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302);
}
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
/** @param array<string, mixed> $args */
public function delete(Request $req, Response $res, array $args): Response
{
try {
$post = $this->postService->findById((int) $args['id']);
} catch (NotFoundException) {
throw new HttpNotFoundException($req);
}
if (!$this->canEditPost($post)) {
$this->flash->set('post_error', "Vous ne pouvez pas supprimer un article dont vous n'êtes pas l'auteur");
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
try {
$this->postService->delete($post->getId());
$this->auditLogger->record(
'post.deleted',
'post',
(string) $post->getId(),
$this->sessionManager->getUserId(),
['title' => $post->getTitle()],
);
} catch (NotFoundException) {
throw new HttpNotFoundException($req);
} catch (\Throwable $e) {
$incidentId = $this->logUnexpectedError($req, $e, [
'post_id' => $post->getId(),
'user_id' => $this->sessionManager->getUserId(),
]);
$this->flash->set('post_error', "Une erreur inattendue s'est produite (réf. {$incidentId})");
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
$this->flash->set('post_success', "L'article « {$post->getTitle()} » a été supprimé avec succès");
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
private function canEditPost(Post $post): bool
{
if ($this->canManageAllContent()) {
return true;
}
return $post->getAuthorId() === $this->sessionManager->getUserId();
}
private function canManageAllContent(): bool
{
return $this->authorization->canRole($this->currentRole(), Permission::CONTENT_MANAGE);
}
private function currentRole(): string
{
if ($this->sessionManager->isAdmin()) {
return 'admin';
}
if ($this->sessionManager->isEditor()) {
return 'editor';
}
return 'user';
}
private function publicPerPage(): int
{
return max(1, min(24, $this->settings->getInt('blog.public_posts_per_page', 6)));
}
private function adminPerPage(): int
{
return max(1, min(50, $this->settings->getInt('blog.admin_posts_per_page', 12)));
}
/**
* @param array<string, mixed> $context
*/
private function logUnexpectedError(Request $req, \Throwable $e, array $context = []): string
{
$incidentId = bin2hex(random_bytes(8));
$this->logger?->error('Post administration action failed', $context + [
'incident_id' => $incidentId,
'route' => (string) $req->getUri()->getPath(),
'method' => $req->getMethod(),
'exception_class' => $e::class,
'exception_message' => $e->getMessage(),
'exception' => $e,
]);
return $incidentId;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Post\UI\Http\Request;
use Psr\Http\Message\ServerRequestInterface;
/**
* Normalise les données issues du formulaire de création ou d'édition d'article.
*/
final readonly class PostFormRequest
{
public function __construct(
public string $title,
public string $content,
public string $slug,
public ?int $categoryId,
) {}
public static function fromRequest(ServerRequestInterface $request): self
{
/** @var array<string, mixed> $data */
$data = (array) $request->getParsedBody();
return new self(
title: trim((string) ($data['title'] ?? '')),
content: (string) ($data['content'] ?? ''),
slug: trim((string) ($data['slug'] ?? '')),
categoryId: isset($data['category_id']) && $data['category_id'] !== '' ? (int) $data['category_id'] : null,
);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Post\UI\Http;
use Netig\Netslim\Identity\UI\Http\Middleware\AuthMiddleware;
use Psr\Container\ContainerInterface;
use Slim\App;
/**
* Enregistre les routes HTTP du domaine Post.
*/
final class Routes
{
/** @param App<ContainerInterface> $app */
public static function register(App $app): void
{
$app->get('/', [PostController::class, 'index']);
$app->get('/article/{slug}', [PostController::class, 'show']);
$app->get('/rss.xml', [RssController::class, 'feed']);
$app->group('/admin', function ($group): void {
$group->get('/posts', [PostController::class, 'admin']);
$group->get('/posts/edit/{id}', [PostController::class, 'form']);
$group->post('/posts/create', [PostController::class, 'create']);
$group->post('/posts/edit/{id}', [PostController::class, 'update']);
$group->post('/posts/delete/{id}', [PostController::class, 'delete']);
})->add(AuthMiddleware::class);
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Post\UI\Http;
use App\Post\Application\PostServiceInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Produit le flux RSS public à partir des derniers articles publiés.
*/
class RssController
{
private const FEED_LIMIT = 20;
public function __construct(
private readonly PostServiceInterface $postService,
private readonly string $appUrl,
private readonly string $appName,
) {}
public function feed(Request $req, Response $res): Response
{
$posts = $this->postService->findRecent(self::FEED_LIMIT);
$baseUrl = $this->appUrl;
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"></rss>');
$channel = $xml->addChild('channel');
$channel->addChild('title', htmlspecialchars($this->appName));
$channel->addChild('link', $baseUrl . '/');
$channel->addChild('description', htmlspecialchars($this->appName . ' — flux RSS'));
$channel->addChild('language', 'fr-FR');
$channel->addChild('lastBuildDate', (new \DateTime())->format(\DateTime::RSS));
foreach ($posts as $post) {
$item = $channel->addChild('item');
$item->addChild('title', htmlspecialchars($post->getTitle()));
$postUrl = $baseUrl . '/article/' . $post->getStoredSlug();
$item->addChild('link', $postUrl);
$item->addChild('guid', $postUrl);
$excerpt = strip_tags($post->getContent());
$excerpt = mb_strlen($excerpt) > 300 ? mb_substr($excerpt, 0, 300) . '…' : $excerpt;
$item->addChild('description', htmlspecialchars($excerpt));
$item->addChild('pubDate', $post->getCreatedAt()->format(\DateTime::RSS));
if ($post->getAuthorUsername() !== null) {
$item->addChild('author', htmlspecialchars($post->getAuthorUsername()));
}
if ($post->getCategoryName() !== null) {
$item->addChild('category', htmlspecialchars($post->getCategoryName()));
}
}
$body = $xml->asXML();
$res->getBody()->write($body !== false ? $body : '');
return $res->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
}
}

View File

@@ -0,0 +1,114 @@
{% extends "@Kernel/layout.twig" %}
{% block title %}
{% if post is defined and post is not null and post.id > 0 %}Éditer l'article{% else %}Créer un article{% endif %}
{% endblock %}
{% block styles %}
<link rel="stylesheet" href="/assets/vendor/trumbowyg/ui/trumbowyg.min.css">
{% endblock %}
{% block content %}
<div class="form-container">
{% set pageTitle = post is defined and post is not null and post.id > 0 ? "Éditer l'article" : 'Créer un article' %}
{% include '@Kernel/partials/_admin_page_header.twig' with {
title: pageTitle,
secondary_action_href: '/admin/posts',
secondary_action_label: 'Retour à la liste'
} %}
<div class="form-container__panel">
{% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %}
<form method="post" action="{{ action }}" class="form-container__form">
{% include '@Kernel/partials/_csrf_fields.twig' %}
{% if post is defined and post is not null %}
<p class="form-container__field">
<label class="form-container__label">
<span>Auteur</span>
<input type="text" value="{{ post.authorUsername ?? 'inconnu' }}" disabled
class="form-container__input form-container__input--disabled">
</label>
</p>
{% endif %}
<p class="form-container__field">
<label for="title" class="form-container__label">
<span>Titre</span>
<input type="text" id="title" name="title" value="{{ post.title|default('') }}" required maxlength="255"
class="form-container__input">
</label>
</p>
{% if post is defined and post is not null and post.id > 0 %}
<p class="form-container__field">
<label for="slug" class="form-container__label">
<span>Slug URL</span>
<input type="text" id="slug" name="slug" value="{{ post.storedSlug }}" pattern="[a-z0-9]+(-[a-z0-9]+)*"
maxlength="255" title="Lettres minuscules, chiffres et tirets uniquement"
class="form-container__input">
</label>
<small class="form-container__hint">(URL actuelle : <a href="/article/{{ post.storedSlug }}" target="_blank">/article/{{ post.storedSlug }}</a>)</small>
</p>
{% endif %}
<p class="form-container__field">
<label for="category_id" class="form-container__label">
<span>Catégorie</span>
<select id="category_id" name="category_id" class="form-container__select">
<option value="">— Sans catégorie —</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if post is not null and post.categoryId==category.id %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
</label>
</p>
<p class="form-container__field form-container__field--editor">
<label for="editor" class="form-container__label">
<span>Contenu</span>
</label>
<textarea id="editor" name="content" required class="form-container__textarea">{{ post.content|default('') }}</textarea>
</p>
<div id="media-picker-modal" class="media-picker-modal" aria-hidden="true" hidden>
<div class="media-picker-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="media-picker-title">
<div class="media-picker-modal__header">
<h2 id="media-picker-title" class="media-picker-modal__title">Médiathèque</h2>
<button id="media-picker-close" type="button" class="btn btn--secondary btn--sm">Fermer</button>
</div>
<div class="media-picker-modal__body">
<iframe id="media-picker-frame" class="media-picker-modal__frame" title="Sélecteur de médias" loading="lazy" data-picker-src="/admin/media/picker"></iframe>
</div>
</div>
</div>
{% include '@Kernel/partials/_admin_form_actions.twig' with {
primary_label: post is defined and post is not null and post.id > 0 ? 'Mettre à jour' : 'Enregistrer',
secondary_href: '/admin/posts',
secondary_label: 'Annuler'
} %}
</form>
{% if post is defined and post is not null and post.id > 0 %}
<div class="form-container__footer">
<small>
Créé le : {{ post.createdAt|date("d/m/Y à H:i") }}<br>
Modifié le : {{ post.updatedAt|date("d/m/Y à H:i") }}
</small>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/assets/vendor/jquery.min.js"></script>
<script src="/assets/vendor/trumbowyg/trumbowyg.min.js"></script>
<script src="/assets/vendor/trumbowyg/langs/fr.min.js"></script>
<script src="/assets/js/post-editor-media-picker.js"></script>
{% endblock %}

View File

@@ -0,0 +1,83 @@
{% extends "@Kernel/layout.twig" %}
{% block title %}Tableau de bord Articles{% endblock %}
{% block content %}
{% include '@Kernel/partials/_admin_page_header.twig' with {
title: 'Gestion des articles',
primary_action_href: '/admin/posts/edit/0',
primary_action_label: '+ Ajouter un article'
} %}
{% include '@Post/partials/_search_form.twig' with {
action: '/admin/posts',
activeCategory: activeCategory,
searchQuery: searchQuery,
totalPosts: totalPosts,
resetHref: '/admin/posts' ~ (activeCategory ? '?categorie=' ~ activeCategory.slug : '')
} %}
{% include '@Post/partials/_category_filter.twig' with {
categories: categories,
activeCategory: activeCategory,
allHref: '/admin/posts',
itemHrefPrefix: '/admin/posts?categorie='
} %}
{% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %}
{% if posts is not empty %}
<table class="admin-table">
<thead>
<tr>
<th>Titre</th>
<th>Catégorie</th>
<th>Auteur</th>
<th>Créé le</th>
<th>Modifié le</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for post in posts %}
<tr>
<td data-label="Titre"><strong>{{ post.title }}</strong></td>
<td data-label="Catégorie">
{% if post.categoryName %}
{% include '@Kernel/partials/_badge.twig' with {
label: post.categoryName,
modifier: 'category',
href: '/admin/posts?categorie=' ~ post.categorySlug
} %}
{% else %}
<span class="admin-table__muted">—</span>
{% endif %}
</td>
<td data-label="Auteur">{{ post.authorUsername ?? 'inconnu' }}</td>
<td data-label="Créé le">{{ post.createdAt|date("d/m/Y H:i") }}</td>
<td data-label="Modifié le">{{ post.updatedAt|date("d/m/Y H:i") }}</td>
<td data-label="Actions">
<div class="admin-actions">
<a href="/admin/posts/edit/{{ post.id }}" class="btn btn--sm btn--secondary">Éditer</a>
{% include '@Kernel/partials/_admin_delete_form.twig' with {
action: '/admin/posts/delete/' ~ post.id,
confirm: 'Supprimer cet article ?'
} %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include '@Kernel/partials/_pagination.twig' with { pagination: pagination } %}
{% else %}
{% include '@Kernel/partials/_empty_state.twig' with {
title: 'Aucun article à afficher',
message: searchQuery ? 'Aucun résultat pour « ' ~ searchQuery ~ ' ».' : 'Aucun article à gérer.',
action_href: '/admin/posts/edit/0',
action_label: 'Créer un article'
} %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends "@Kernel/layout.twig" %}
{% block title %}{{ post.title }} {{ site.title }}{% endblock %}
{% block meta %}
{% set excerpt = post_excerpt(post, 160) %}
{% set thumb = post_thumbnail(post) %}
<meta name="description" content="{{ excerpt }}">
<meta property="og:type" content="article">
<meta property="og:title" content="{{ post.title }} {{ site.title }}">
<meta property="og:description" content="{{ excerpt }}">
<meta property="og:url" content="{{ app_url }}{{ post_url(post) }}">
{% if thumb %}
<meta property="og:image" content="{{ app_url }}{{ thumb }}">
{% endif %}
{% endblock %}
{% block content %}
<article class="post">
<h1 class="post__title">{{ post.title }}</h1>
<div class="post__meta">
<small>
Publié le {{ post.createdAt|date("d/m/Y à H:i") }}
par <strong>{{ post.authorUsername ?? 'inconnu' }}</strong>
</small>
{% if post.categoryName %}
{% include '@Kernel/partials/_badge.twig' with {
label: post.categoryName,
modifier: 'category',
href: '/?categorie=' ~ post.categorySlug
} %}
{% endif %}
</div>
{% if post.updatedAt != post.createdAt %}
<div class="post__updated">
<small><em>Mis à jour le {{ post.updatedAt|date("d/m/Y à H:i") }}</em></small>
</div>
{% endif %}
<div class="post__content rich-text">
{{ post.content|raw }}
</div>
<hr>
<p class="post__back">
<a href="/" class="post__back-link">← Retour aux articles</a>
</p>
</article>
{% endblock %}

View File

@@ -0,0 +1,85 @@
{% extends "@Kernel/layout.twig" %}
{% block title %}{{ site.title }}{% endblock %}
{% block meta %}
<meta name="description" content="{% if activeCategory %}Contenus de la catégorie {{ activeCategory.name }}{% endif %}{{ site.metaDescription }}">
<meta property="og:type" content="website">
<meta property="og:title" content="{{ site.title }}">
<meta property="og:description" content="{{ site.metaDescription }}">
<meta property="og:url" content="{{ app_url }}/">
{% endblock %}
{% block content %}
<section class="hero">
<h2>{{ site.title }}</h2>
{% if site.homeIntro %}<p>{{ site.homeIntro }}</p>{% endif %}
</section>
{% include '@Post/partials/_search_form.twig' with {
action: '/',
activeCategory: activeCategory,
searchQuery: searchQuery,
totalPosts: totalPosts,
resetHref: '/' ~ (activeCategory ? '?categorie=' ~ activeCategory.slug : '')
} %}
{% include '@Post/partials/_category_filter.twig' with {
categories: categories,
activeCategory: activeCategory,
allHref: '/',
itemHrefPrefix: '/?categorie='
} %}
<div class="card-list card-list--contained">
{% for post in posts %}
{% set thumb = post_thumbnail(post) %}
<article class="card">
<a href="{{ post_url(post) }}" class="card__thumb-link" tabindex="-1" aria-hidden="true">
{% if thumb %}
<img class="card__thumb" src="{{ thumb }}" alt="">
{% else %}
<span class="card__initials" aria-hidden="true">{{ post_initials(post) }}</span>
{% endif %}
</a>
<div class="card__content">
<div class="card__body">
<h2 class="card__title">
<a href="{{ post_url(post) }}" class="card__title-link">{{ post.title }}</a>
</h2>
<div class="card__meta">
<small>
Publié le {{ post.createdAt|date("d/m/Y à H:i") }}
par <strong>{{ post.authorUsername ?? 'inconnu' }}</strong>
</small>
{% if post.categoryName %}
{% include '@Kernel/partials/_badge.twig' with {
label: post.categoryName,
modifier: 'category',
href: '/?categorie=' ~ post.categorySlug
} %}
{% endif %}
</div>
<p class="card__excerpt">{{ post_excerpt(post) }}</p>
</div>
<div class="card__actions">
<a href="{{ post_url(post) }}" class="card__actions-link">Lire la suite →</a>
</div>
</div>
</article>
{% else %}
{% include '@Kernel/partials/_empty_state.twig' with {
title: 'Aucun article trouvé',
message: 'Aucun article publié' ~ (searchQuery ? ' pour « ' ~ searchQuery ~ ' »' : (activeCategory ? ' dans cette catégorie' : '')) ~ '.',
action_href: '/',
action_label: 'Réinitialiser les filtres'
} %}
{% endfor %}
</div>
{% include '@Kernel/partials/_pagination.twig' with { pagination: pagination } %}
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% if categories is not empty %}
<nav class="category-filter" aria-label="Filtrer par catégorie">
<a href="{{ allHref }}"
class="category-filter__item{% if activeCategory is null %} category-filter__item--active{% endif %}">
Tous
</a>
{% for category in categories %}
<a href="{{ itemHrefPrefix }}{{ category.slug }}"
class="category-filter__item{% if activeCategory and activeCategory.id == category.id %} category-filter__item--active{% endif %}">
{{ category.name }}
</a>
{% endfor %}
</nav>
{% endif %}

View File

@@ -0,0 +1,23 @@
<form method="get" action="{{ action }}" class="search-bar">
{% if activeCategory %}
<input type="hidden" name="categorie" value="{{ activeCategory.slug }}">
{% endif %}
<input type="search" name="q" value="{{ searchQuery }}"
placeholder="Rechercher un article…" class="search-bar__input" aria-label="Recherche">
<button type="submit" class="search-bar__btn">Rechercher</button>
{% if searchQuery %}
<a href="{{ resetHref }}" class="search-bar__reset">✕</a>
{% endif %}
</form>
{% if searchQuery %}
<p class="search-bar__info">
{% if totalPosts > 0 %}
{{ totalPosts }} résultat{{ totalPosts > 1 ? 's' : '' }} pour « {{ searchQuery }} »
{% else %}
Aucun résultat pour « {{ searchQuery }} »
{% endif %}
</p>
{% endif %}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Post\UI\Twig;
use App\Post\Domain\Entity\Post;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class TwigPostExtension extends AbstractExtension
{
/** @return TwigFunction[] */
public function getFunctions(): array
{
return [
new TwigFunction('post_excerpt', fn (Post $post, int $length = 400) => self::excerpt($post, $length), ['is_safe' => ['html']]),
new TwigFunction('post_url', fn (Post $post) => '/article/' . $post->getStoredSlug()),
new TwigFunction('post_thumbnail', fn (Post $post) => self::thumbnail($post)),
new TwigFunction('post_initials', fn (Post $post) => self::initials($post)),
];
}
private static function excerpt(Post $post, int $length): string
{
$allowed = '<ul><ol><li><strong><em><b><i>';
$html = strip_tags($post->getContent(), $allowed);
if (mb_strlen(strip_tags($html)) <= $length) {
return $html;
}
$truncated = '';
$count = 0;
$inTag = false;
for ($i = 0, $len = mb_strlen($html); $i < $len && $count < $length; $i++) {
$char = mb_substr($html, $i, 1);
if ($char === '<') {
$inTag = true;
}
$truncated .= $char;
if ($inTag) {
if ($char === '>') {
$inTag = false;
}
} else {
$count++;
}
}
foreach (['li', 'ul', 'ol', 'em', 'strong', 'b', 'i'] as $tag) {
$opens = substr_count($truncated, "<{$tag}>") + substr_count($truncated, "<{$tag} ");
$closes = substr_count($truncated, "</{$tag}>");
for ($j = $closes; $j < $opens; $j++) {
$truncated .= "</{$tag}>";
}
}
return $truncated . '…';
}
private static function thumbnail(Post $post): ?string
{
if (preg_match('/<img[^>]+src=["\']([^"\']+)["\']/', $post->getContent(), $matches)) {
return $matches[1];
}
return null;
}
private static function initials(Post $post): string
{
$stopWords = ['a', 'au', 'aux', 'd', 'de', 'des', 'du', 'en', 'et', 'l', 'la', 'le', 'les', 'of', 'the', 'un', 'une'];
$words = array_filter(
preg_split('/\s+/', trim($post->getTitle())) ?: [],
static function (string $w) use ($stopWords): bool {
$normalized = mb_strtolower(trim($w, " \t\n\r\0\x0B'\"`.-_"));
return $normalized !== '' && mb_strlen($normalized) > 1 && !in_array($normalized, $stopWords, true);
},
);
if (empty($words)) {
$first = mb_substr(trim($post->getTitle()), 0, 1);
return $first !== '' ? mb_strtoupper($first) : '?';
}
$words = array_values($words);
$initials = mb_strtoupper(mb_substr($words[0], 0, 1));
if (isset($words[1])) {
$initials .= mb_strtoupper(mb_substr($words[1], 0, 1));
}
return $initials;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Site\Infrastructure;
use PDO;
/**
* Dépose les réglages initiaux du blog lors du provisionnement.
*/
final class DefaultSiteSettingsProvisioner
{
public static function provision(PDO $db): void
{
$defaults = [
'site.title' => $_ENV['APP_NAME'] ?? 'Netslim Blog',
'site.tagline' => 'Un blog éditorial construit sur netslim-core.',
'site.meta_description' => 'Application blog construite sur netslim-core.',
'blog.home_intro' => 'Bienvenue sur le blog. Cette application démontre lutilisation de Settings, Authorization, AuditLog et Notifications au-dessus de netslim-core.',
'blog.public_posts_per_page' => 6,
'blog.admin_posts_per_page' => 12,
'notifications.demo_recipient' => $_ENV['MAIL_FROM'] ?? '',
];
$statement = $db->prepare(
'INSERT OR IGNORE INTO settings (setting_key, setting_value, value_type, updated_at)
VALUES (:key, :value, :type, CURRENT_TIMESTAMP)',
);
foreach ($defaults as $key => $value) {
[$storedValue, $type] = self::encode($value);
$statement->execute([
':key' => $key,
':value' => $storedValue,
':type' => $type,
]);
}
}
/**
* @return array{0:string|null,1:string}
*/
private static function encode(mixed $value): array
{
return match (true) {
$value === null => [null, 'null'],
is_bool($value) => [$value ? '1' : '0', 'bool'],
is_int($value) => [(string) $value, 'int'],
is_float($value) => [(string) $value, 'float'],
is_string($value) => [$value, 'string'],
is_array($value) => [json_encode($value, JSON_THROW_ON_ERROR), 'json'],
default => throw new \InvalidArgumentException('Type de réglage par défaut non supporté'),
};
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use App\Site\UI\Http\SiteController;
use App\Site\UI\Twig\SiteSettingsExtension;
use function DI\autowire;
return [
SiteController::class => autowire(),
SiteSettingsExtension::class => autowire(),
];

50
src/Site/SiteModule.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Site;
use App\Site\Infrastructure\DefaultSiteSettingsProvisioner;
use App\Site\UI\Http\Routes;
use App\Site\UI\Twig\SiteSettingsExtension;
use Netig\Netslim\Kernel\Runtime\Module\ModuleInterface;
use Netig\Netslim\Kernel\Runtime\Module\ProvidesProvisioningInterface;
use PDO;
use Psr\Container\ContainerInterface;
use Slim\App;
/**
* Module applicatif du blog.
*
* Il regroupe les intégrations propres au projet : pages d'administration
* transverses, réglages éditoriaux, démonstration des notifications et
* exposition des réglages Twig utilisés par les layouts publics.
*/
final class SiteModule implements ModuleInterface, ProvidesProvisioningInterface
{
public function definitions(): array
{
return require __DIR__ . '/Infrastructure/dependencies.php';
}
/** @param App<ContainerInterface> $app */
public function registerRoutes(App $app): void
{
Routes::register($app);
}
public function templateNamespaces(): array
{
return ['Site' => __DIR__ . '/UI/Templates'];
}
public function twigExtensions(): array
{
return [SiteSettingsExtension::class];
}
public function provision(PDO $db): void
{
DefaultSiteSettingsProvisioner::provision($db);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Site\UI\Http;
use Netig\Netslim\Identity\UI\Http\Middleware\AuthMiddleware;
use Psr\Container\ContainerInterface;
use Slim\App;
/**
* Enregistre les routes applicatives transverses du blog.
*/
final class Routes
{
/** @param App<ContainerInterface> $app */
public static function register(App $app): void
{
$app->group('/admin', function ($group): void {
$group->get('', [SiteController::class, 'dashboard']);
$group->get('/settings', [SiteController::class, 'settings']);
$group->post('/settings', [SiteController::class, 'saveSettings']);
$group->get('/audit-log', [SiteController::class, 'auditLog']);
$group->get('/notifications', [SiteController::class, 'notifications']);
$group->post('/notifications/send', [SiteController::class, 'sendNotification']);
})->add(AuthMiddleware::class);
}
}

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace App\Site\UI\Http;
use Netig\Netslim\AuditLog\Contracts\AuditLoggerInterface;
use Netig\Netslim\AuditLog\Contracts\AuditLogReaderInterface;
use Netig\Netslim\Identity\Application\AuthorizationServiceInterface;
use Netig\Netslim\Identity\Domain\Policy\Permission;
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
use Netig\Netslim\Notifications\Application\NotificationServiceInterface;
use Netig\Netslim\Settings\Application\SettingsServiceInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Slim\Views\Twig;
/**
* Contrôleur applicatif des pages d'administration transverses du blog.
*/
final class SiteController
{
public function __construct(
private readonly Twig $view,
private readonly SettingsServiceInterface $settings,
private readonly AuditLoggerInterface $auditLogger,
private readonly AuditLogReaderInterface $auditLogReader,
private readonly NotificationServiceInterface $notifications,
private readonly AuthorizationServiceInterface $authorization,
private readonly SessionManagerInterface $sessionManager,
private readonly FlashServiceInterface $flash,
private readonly ?LoggerInterface $logger = null,
) {}
public function dashboard(Request $request, Response $response): Response
{
return $this->view->render($response, '@Site/admin/dashboard.twig', [
'settingsPreview' => [
'title' => $this->settings->getString('site.title', $_ENV['APP_NAME'] ?? 'Netslim Blog'),
'tagline' => $this->settings->getString('site.tagline', ''),
],
'recentAuditEntries' => $this->can(Permission::AUDIT_LOG_VIEW)
? $this->auditLogReader->listRecent(5)
: [],
'recentNotifications' => $this->can(Permission::NOTIFICATIONS_SEND)
? $this->notifications->recent(5)
: [],
'flashError' => $this->flash->get('site_error'),
'flashSuccess' => $this->flash->get('site_success'),
'permissions' => [
'settings' => $this->can(Permission::SETTINGS_MANAGE),
'auditLog' => $this->can(Permission::AUDIT_LOG_VIEW),
'notifications' => $this->can(Permission::NOTIFICATIONS_SEND),
'content' => $this->can(Permission::CONTENT_MANAGE),
],
]);
}
public function settings(Request $request, Response $response): Response
{
if (!$this->can(Permission::SETTINGS_MANAGE)) {
return $this->deny($response, 'Vous ne pouvez pas modifier les réglages du site.');
}
return $this->view->render($response, '@Site/admin/settings.twig', [
'values' => [
'site_title' => $this->settings->getString('site.title', $_ENV['APP_NAME'] ?? 'Netslim Blog'),
'site_tagline' => $this->settings->getString('site.tagline', ''),
'site_meta_description' => $this->settings->getString('site.meta_description', ''),
'home_intro' => $this->settings->getString('blog.home_intro', ''),
'public_posts_per_page' => $this->settings->getInt('blog.public_posts_per_page', 6),
'admin_posts_per_page' => $this->settings->getInt('blog.admin_posts_per_page', 12),
'demo_recipient' => $this->settings->getString('notifications.demo_recipient', ''),
],
'error' => $this->flash->get('site_error'),
'success' => $this->flash->get('site_success'),
]);
}
public function saveSettings(Request $request, Response $response): Response
{
if (!$this->can(Permission::SETTINGS_MANAGE)) {
return $this->deny($response, 'Vous ne pouvez pas modifier les réglages du site.');
}
$data = (array) $request->getParsedBody();
$publicPerPage = max(1, min(24, (int) ($data['public_posts_per_page'] ?? 6)));
$adminPerPage = max(1, min(50, (int) ($data['admin_posts_per_page'] ?? 12)));
$changes = [
'site.title' => trim((string) ($data['site_title'] ?? '')),
'site.tagline' => trim((string) ($data['site_tagline'] ?? '')),
'site.meta_description' => trim((string) ($data['site_meta_description'] ?? '')),
'blog.home_intro' => trim((string) ($data['home_intro'] ?? '')),
'blog.public_posts_per_page' => $publicPerPage,
'blog.admin_posts_per_page' => $adminPerPage,
'notifications.demo_recipient' => trim((string) ($data['demo_recipient'] ?? '')),
];
if ($changes['site.title'] === '') {
$this->flash->set('site_error', 'Le titre du site ne peut pas être vide.');
return $response->withHeader('Location', '/admin/settings')->withStatus(302);
}
foreach ($changes as $key => $value) {
$this->settings->set($key, $value);
}
$this->auditLogger->record(
action: 'settings.updated',
resourceType: 'settings',
resourceId: 'blog',
actorUserId: $this->sessionManager->getUserId(),
context: ['keys' => array_keys($changes)],
);
$this->flash->set('site_success', 'Les réglages du blog ont été enregistrés.');
return $response->withHeader('Location', '/admin/settings')->withStatus(302);
}
public function auditLog(Request $request, Response $response): Response
{
if (!$this->can(Permission::AUDIT_LOG_VIEW)) {
return $this->deny($response, 'Vous ne pouvez pas consulter le journal d\'audit.');
}
return $this->view->render($response, '@Site/admin/audit-log.twig', [
'entries' => $this->auditLogReader->listRecent(100),
]);
}
public function notifications(Request $request, Response $response): Response
{
if (!$this->can(Permission::NOTIFICATIONS_SEND)) {
return $this->deny($response, 'Vous ne pouvez pas envoyer de notifications.');
}
return $this->view->render($response, '@Site/admin/notifications.twig', [
'defaultRecipient' => $this->settings->getString('notifications.demo_recipient', ''),
'dispatches' => $this->notifications->recent(25),
'error' => $this->flash->get('site_error'),
'success' => $this->flash->get('site_success'),
]);
}
public function sendNotification(Request $request, Response $response): Response
{
if (!$this->can(Permission::NOTIFICATIONS_SEND)) {
return $this->deny($response, 'Vous ne pouvez pas envoyer de notifications.');
}
$data = (array) $request->getParsedBody();
$recipient = trim((string) ($data['recipient'] ?? ''));
$subject = trim((string) ($data['subject'] ?? 'Notification Netslim Blog'));
if ($recipient === '' || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
$this->flash->set('site_error', 'Veuillez fournir une adresse email valide.');
return $response->withHeader('Location', '/admin/notifications')->withStatus(302);
}
try {
$this->notifications->sendTemplate(
to: $recipient,
subject: $subject !== '' ? $subject : 'Notification Netslim Blog',
template: '@Site/emails/demo-notification.twig',
context: [
'siteTitle' => $this->settings->getString('site.title', $_ENV['APP_NAME'] ?? 'Netslim Blog'),
'siteTagline' => $this->settings->getString('site.tagline', ''),
'sentAt' => (new \DateTimeImmutable())->format('d/m/Y H:i'),
'appUrl' => rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/'),
],
notificationKey: 'site.demo-notification',
);
$this->auditLogger->record(
action: 'notification.sent',
resourceType: 'notification',
resourceId: $recipient,
actorUserId: $this->sessionManager->getUserId(),
context: ['subject' => $subject],
);
$this->flash->set('site_success', 'La notification de démonstration a été envoyée.');
} catch (\Throwable $exception) {
$this->logger?->error('Demo notification failed', [
'recipient' => $recipient,
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
]);
$this->flash->set('site_error', 'L\'envoi de la notification a échoué. Vérifiez la configuration mail.');
}
return $response->withHeader('Location', '/admin/notifications')->withStatus(302);
}
private function can(string $permission): bool
{
return $this->authorization->canRole($this->currentRole(), $permission);
}
private function currentRole(): string
{
if ($this->sessionManager->isAdmin()) {
return 'admin';
}
if ($this->sessionManager->isEditor()) {
return 'editor';
}
return 'user';
}
private function deny(Response $response, string $message): Response
{
$this->flash->set('site_error', $message);
return $response->withHeader('Location', '/admin')->withStatus(302);
}
}

View File

@@ -0,0 +1,43 @@
{% extends "@Kernel/layout.twig" %}
{% block title %}Journal daudit {{ site.title }}{% endblock %}
{% block content %}
{% include '@Kernel/partials/_admin_page_header.twig' with { title: 'Journal daudit' } %}
{% if entries is not empty %}
<table class="admin-table">
<thead>
<tr>
<th>Action</th>
<th>Ressource</th>
<th>Auteur</th>
<th>Contexte</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td data-label="Action"><strong>{{ entry.action }}</strong></td>
<td data-label="Ressource">{{ entry.resourceType }} / {{ entry.resourceId }}</td>
<td data-label="Auteur">{{ entry.actorUserId ?? '—' }}</td>
<td data-label="Contexte">
{% if entry.context %}
<code class="admin-table__code">{{ entry.context|json_encode(constant('JSON_UNESCAPED_UNICODE')) }}</code>
{% else %}
<span class="admin-table__muted">—</span>
{% endif %}
</td>
<td data-label="Date">{{ entry.createdAt|date('d/m/Y H:i') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{% include '@Kernel/partials/_empty_state.twig' with {
title: 'Aucune entrée enregistrée',
message: 'Le journal daudit se remplira après les premières actions dadministration.'
} %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% extends "@Kernel/layout.twig" %}
{% block title %}Administration {{ site.title }}{% endblock %}
{% block content %}
{% include '@Kernel/partials/_admin_page_header.twig' with {
title: 'Administration du blog',
intro: 'Ce tableau de bord démontre lutilisation des modules Settings, Authorization, AuditLog et Notifications de netslim-core.'
} %}
{% include '@Kernel/partials/_flash_messages.twig' with { error: flashError|default(null), success: flashSuccess|default(null) } %}
<div class="card-list card-list--contained">
<article class="card">
<div class="card__content">
<div class="card__body">
<h2 class="card__title">Réglages du site</h2>
<p class="card__excerpt">Titre : <strong>{{ settingsPreview.title }}</strong><br>Baseline : {{ settingsPreview.tagline ?: '—' }}</p>
</div>
{% if permissions.settings %}
<div class="card__actions"><a class="card__actions-link" href="/admin/settings">Modifier les réglages →</a></div>
{% endif %}
</div>
</article>
<article class="card">
<div class="card__content">
<div class="card__body">
<h2 class="card__title">Permissions</h2>
<p class="card__excerpt">
Contenu : <strong>{{ permissions.content ? 'autorisé' : 'interdit' }}</strong><br>
Réglages : <strong>{{ permissions.settings ? 'autorisé' : 'interdit' }}</strong><br>
Audit : <strong>{{ permissions.auditLog ? 'autorisé' : 'interdit' }}</strong><br>
Notifications : <strong>{{ permissions.notifications ? 'autorisé' : 'interdit' }}</strong>
</p>
</div>
</div>
</article>
</div>
{% if permissions.auditLog %}
<section class="section">
<h2>Activité récente</h2>
{% if recentAuditEntries is not empty %}
<table class="admin-table">
<thead><tr><th>Action</th><th>Ressource</th><th>Auteur</th><th>Date</th></tr></thead>
<tbody>
{% for entry in recentAuditEntries %}
<tr>
<td data-label="Action"><strong>{{ entry.action }}</strong></td>
<td data-label="Ressource">{{ entry.resourceType }} / {{ entry.resourceId }}</td>
<td data-label="Auteur">{{ entry.actorUserId ?? '—' }}</td>
<td data-label="Date">{{ entry.createdAt|date('d/m/Y H:i') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{% include '@Kernel/partials/_empty_state.twig' with { title: 'Aucune entrée d\'audit', message: 'Le journal d\'audit se remplira au fil des actions dadministration.' } %}
{% endif %}
</section>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% extends "@Kernel/layout.twig" %}
{% block title %}Notifications {{ site.title }}{% endblock %}
{% block content %}
{% include '@Kernel/partials/_admin_page_header.twig' with {
title: 'Notifications transactionnelles',
intro: 'Cette page démontre le module Notifications du core avec un envoi manuel et lhistorique des dispatches.'
} %}
{% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %}
<form method="post" action="/admin/notifications/send" class="form-container">
{% include '@Kernel/partials/_csrf_fields.twig' %}
<div class="form-grid form-grid--stacked">
<label class="form-container__label">
Destinataire
<input class="form-container__input" type="email" name="recipient" value="{{ defaultRecipient }}" required>
</label>
<label class="form-container__label">
Sujet
<input class="form-container__input" type="text" name="subject" value="Notification de démonstration {{ site.title }}" required>
</label>
</div>
{% include '@Kernel/partials/_admin_form_actions.twig' with {
primary_label: 'Envoyer la notification',
secondary_href: '/admin',
secondary_label: 'Retour au tableau de bord'
} %}
</form>
{% if dispatches is not empty %}
<table class="admin-table">
<thead>
<tr>
<th>Destinataire</th>
<th>Sujet</th>
<th>Template</th>
<th>Statut</th>
<th>Créé le</th>
</tr>
</thead>
<tbody>
{% for dispatch in dispatches %}
<tr>
<td data-label="Destinataire">{{ dispatch.recipient }}</td>
<td data-label="Sujet"><strong>{{ dispatch.subject }}</strong></td>
<td data-label="Template"><code class="admin-table__code">{{ dispatch.template }}</code></td>
<td data-label="Statut">
<strong>{{ dispatch.status }}</strong>
{% if dispatch.errorMessage %}<br><span class="admin-table__muted">{{ dispatch.errorMessage }}</span>{% endif %}
</td>
<td data-label="Créé le">{{ dispatch.createdAt|date('d/m/Y H:i') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{% include '@Kernel/partials/_empty_state.twig' with {
title: 'Aucune notification envoyée',
message: 'Envoyez un premier email de démonstration pour remplir lhistorique.'
} %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends "@Kernel/layout.twig" %}
{% block title %}Réglages {{ site.title }}{% endblock %}
{% block content %}
{% include '@Kernel/partials/_admin_page_header.twig' with { title: 'Réglages du blog' } %}
{% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %}
<form method="post" action="/admin/settings" class="form-container">
{% include '@Kernel/partials/_csrf_fields.twig' %}
<div class="form-grid form-grid--stacked">
<label class="form-container__label">
Titre du site
<input class="form-container__input" type="text" name="site_title" value="{{ values.site_title }}" required>
</label>
<label class="form-container__label">
Baseline
<input class="form-container__input" type="text" name="site_tagline" value="{{ values.site_tagline }}">
</label>
<label class="form-container__label">
Description meta
<textarea class="form-container__textarea" name="site_meta_description" rows="3">{{ values.site_meta_description }}</textarea>
</label>
<label class="form-container__label">
Introduction de la page daccueil
<textarea class="form-container__textarea" name="home_intro" rows="4">{{ values.home_intro }}</textarea>
</label>
<label class="form-container__label">
Articles par page (public)
<input class="form-container__input" type="number" min="1" max="24" name="public_posts_per_page" value="{{ values.public_posts_per_page }}">
</label>
<label class="form-container__label">
Articles par page (admin)
<input class="form-container__input" type="number" min="1" max="50" name="admin_posts_per_page" value="{{ values.admin_posts_per_page }}">
</label>
<label class="form-container__label">
Destinataire par défaut des notifications de démonstration
<input class="form-container__input" type="email" name="demo_recipient" value="{{ values.demo_recipient }}">
</label>
</div>
{% include '@Kernel/partials/_admin_form_actions.twig' with {
primary_label: 'Enregistrer les réglages',
secondary_href: '/admin',
secondary_label: 'Retour au tableau de bord'
} %}
</form>
{% endblock %}

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>{{ siteTitle }}</title>
</head>
<body>
<h1>{{ siteTitle }}</h1>
{% if siteTagline %}
<p><em>{{ siteTagline }}</em></p>
{% endif %}
<p>Ceci est un email transactionnel de démonstration envoyé depuis le back-office du blog.</p>
<p>Envoi déclenché le {{ sentAt }}.</p>
<p><a href="{{ appUrl }}">Ouvrir le site</a></p>
</body>
</html>

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Site\UI\Twig;
use Netig\Netslim\Settings\Contracts\SettingsReaderInterface;
use Twig\Extension\AbstractExtension;
use Twig\Extension\GlobalsInterface;
/**
* Expose les réglages courants du site aux templates Twig.
*/
final class SiteSettingsExtension extends AbstractExtension implements GlobalsInterface
{
public function __construct(private readonly SettingsReaderInterface $settings) {}
/**
* @return array{site: array{title:string, tagline:string, metaDescription:string, homeIntro:string}}
*/
public function getGlobals(): array
{
try {
return [
'site' => [
'title' => $this->settings->getString('site.title', $_ENV['APP_NAME'] ?? 'Netslim Blog'),
'tagline' => $this->settings->getString('site.tagline', 'Un blog éditorial construit sur netslim-core.'),
'metaDescription' => $this->settings->getString('site.meta_description', 'Application blog construite sur netslim-core.'),
'homeIntro' => $this->settings->getString('blog.home_intro', 'Bienvenue sur le blog.'),
],
];
} catch (\Throwable) {
return [
'site' => [
'title' => $_ENV['APP_NAME'] ?? 'Netslim Blog',
'tagline' => 'Un blog éditorial construit sur netslim-core.',
'metaDescription' => 'Application blog construite sur netslim-core.',
'homeIntro' => 'Bienvenue sur le blog.',
],
];
}
}
}