first commit
This commit is contained in:
18
src/Post/Application/Command/CreatePostCommand.php
Normal file
18
src/Post/Application/Command/CreatePostCommand.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
19
src/Post/Application/Command/UpdatePostCommand.php
Normal file
19
src/Post/Application/Command/UpdatePostCommand.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
131
src/Post/Application/PostApplicationService.php
Normal file
131
src/Post/Application/PostApplicationService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
97
src/Post/Application/PostServiceInterface.php
Normal file
97
src/Post/Application/PostServiceInterface.php
Normal 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;
|
||||
}
|
||||
55
src/Post/Application/UseCase/CreatePost.php
Normal file
55
src/Post/Application/UseCase/CreatePost.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/Post/Application/UseCase/DeletePost.php
Normal file
25
src/Post/Application/UseCase/DeletePost.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/Post/Application/UseCase/UpdatePost.php
Normal file
68
src/Post/Application/UseCase/UpdatePost.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
253
src/Post/Domain/Entity/Post.php
Normal file
253
src/Post/Domain/Entity/Post.php
Normal 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 (1–255 caractères)
|
||||
* @param string $content Contenu HTML de l'article (1–65 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
107
src/Post/Domain/Repository/PostRepositoryInterface.php
Normal file
107
src/Post/Domain/Repository/PostRepositoryInterface.php
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
31
src/Post/Domain/Service/PostSlugGenerator.php
Normal file
31
src/Post/Domain/Service/PostSlugGenerator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
29
src/Post/Domain/ValueObject/PostMediaUsageReference.php
Normal file
29
src/Post/Domain/ValueObject/PostMediaUsageReference.php
Normal 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;
|
||||
}
|
||||
}
|
||||
74
src/Post/Infrastructure/HtmlPostMediaReferenceExtractor.php
Normal file
74
src/Post/Infrastructure/HtmlPostMediaReferenceExtractor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
173
src/Post/Infrastructure/PdoPostMediaUsageRepository.php
Normal file
173
src/Post/Infrastructure/PdoPostMediaUsageRepository.php
Normal 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), '?'));
|
||||
}
|
||||
}
|
||||
347
src/Post/Infrastructure/PdoPostRepository.php
Normal file
347
src/Post/Infrastructure/PdoPostRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
24
src/Post/Infrastructure/PdoTaxonUsageChecker.php
Normal file
24
src/Post/Infrastructure/PdoTaxonUsageChecker.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
src/Post/Infrastructure/PostMediaUsageReader.php
Normal file
73
src/Post/Infrastructure/PostMediaUsageReader.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
26
src/Post/Infrastructure/PostSearchIndexer.php
Normal file
26
src/Post/Infrastructure/PostSearchIndexer.php
Normal 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)
|
||||
");
|
||||
}
|
||||
}
|
||||
45
src/Post/Infrastructure/dependencies.php
Normal file
45
src/Post/Infrastructure/dependencies.php
Normal 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',
|
||||
);
|
||||
}),
|
||||
];
|
||||
89
src/Post/Migrations/400_post_schema.php
Normal file
89
src/Post/Migrations/400_post_schema.php
Normal 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
53
src/Post/PostModule.php
Normal 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);
|
||||
}
|
||||
}
|
||||
346
src/Post/UI/Http/PostController.php
Normal file
346
src/Post/UI/Http/PostController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
33
src/Post/UI/Http/Request/PostFormRequest.php
Normal file
33
src/Post/UI/Http/Request/PostFormRequest.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/Post/UI/Http/Routes.php
Normal file
31
src/Post/UI/Http/Routes.php
Normal 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);
|
||||
}
|
||||
}
|
||||
60
src/Post/UI/Http/RssController.php
Normal file
60
src/Post/UI/Http/RssController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
114
src/Post/UI/Templates/admin/form.twig
Normal file
114
src/Post/UI/Templates/admin/form.twig
Normal 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 %}
|
||||
83
src/Post/UI/Templates/admin/index.twig
Normal file
83
src/Post/UI/Templates/admin/index.twig
Normal 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 %}
|
||||
51
src/Post/UI/Templates/detail.twig
Normal file
51
src/Post/UI/Templates/detail.twig
Normal 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 %}
|
||||
85
src/Post/UI/Templates/home.twig
Normal file
85
src/Post/UI/Templates/home.twig
Normal 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 %}
|
||||
14
src/Post/UI/Templates/partials/_category_filter.twig
Normal file
14
src/Post/UI/Templates/partials/_category_filter.twig
Normal 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 %}
|
||||
23
src/Post/UI/Templates/partials/_search_form.twig
Normal file
23
src/Post/UI/Templates/partials/_search_form.twig
Normal 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 %}
|
||||
99
src/Post/UI/Twig/TwigPostExtension.php
Normal file
99
src/Post/UI/Twig/TwigPostExtension.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user