diff --git a/db/migrations/20260820120000_add_image_to_articles.php b/db/migrations/20260820120000_add_image_to_articles.php new file mode 100644 index 000000000..160bdd0d3 --- /dev/null +++ b/db/migrations/20260820120000_add_image_to_articles.php @@ -0,0 +1,22 @@ +table('afup_site_article') + ->addColumn('image', 'string', [ + 'limit' => 255, + 'null' => true, + 'default' => null, + 'after' => 'contenu', + ]) + ->save() + ; + } +} diff --git a/sources/AppBundle/Controller/Admin/Site/Article/AddArticleAction.php b/sources/AppBundle/Controller/Admin/Site/Article/AddArticleAction.php index 035d4203b..f8b85423e 100644 --- a/sources/AppBundle/Controller/Admin/Site/Article/AddArticleAction.php +++ b/sources/AppBundle/Controller/Admin/Site/Article/AddArticleAction.php @@ -5,10 +5,12 @@ namespace AppBundle\Controller\Admin\Site\Article; use AppBundle\AuditLog\Audit; +use AppBundle\Site\ArticleImageStorage; use AppBundle\Site\Entity\Article; use AppBundle\Site\Entity\Repository\ArticleRepository; use AppBundle\Site\Form\ArticleType; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\String\Slugger\AsciiSlugger; @@ -18,6 +20,7 @@ final class AddArticleAction extends AbstractController public function __construct( private readonly ArticleRepository $articleRepository, private readonly Audit $audit, + private readonly ArticleImageStorage $articleImageStorage, ) {} public function __invoke(Request $request): Response @@ -32,6 +35,11 @@ public function __invoke(Request $request): Response $article->raccourci = (new AsciiSlugger())->slug($article->titre)->lower()->toString(); } + $uploadedImage = $form->get('image')->getData(); + if ($uploadedImage instanceof UploadedFile) { + $article->image = $this->articleImageStorage->store($uploadedImage, $article); + } + $this->articleRepository->save($article); $this->audit->log('Ajout de l\'article ' . $article->titre); $this->addFlash('notice', 'L\'article ' . $article->titre . ' a été ajouté'); @@ -43,6 +51,7 @@ public function __invoke(Request $request): Response 'formTitle' => 'Ajouter un article', 'submitLabel' => 'Ajouter', 'article' => $article, + 'imageUrl' => null, ]); } } diff --git a/sources/AppBundle/Controller/Admin/Site/Article/DeleteArticleAction.php b/sources/AppBundle/Controller/Admin/Site/Article/DeleteArticleAction.php index a7824aee8..b44ee6769 100644 --- a/sources/AppBundle/Controller/Admin/Site/Article/DeleteArticleAction.php +++ b/sources/AppBundle/Controller/Admin/Site/Article/DeleteArticleAction.php @@ -5,6 +5,7 @@ namespace AppBundle\Controller\Admin\Site\Article; use AppBundle\AuditLog\Audit; +use AppBundle\Site\ArticleImageStorage; use AppBundle\Site\Entity\Repository\ArticleRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -17,6 +18,7 @@ public function __construct( private readonly ArticleRepository $articleRepository, private readonly CsrfTokenManagerInterface $csrfTokenManager, private readonly Audit $audit, + private readonly ArticleImageStorage $articleImageStorage, ) {} public function __invoke(int $id, string $token): RedirectResponse @@ -31,6 +33,7 @@ public function __invoke(int $id, string $token): RedirectResponse throw $this->createNotFoundException(); } + $this->articleImageStorage->remove($article); $this->articleRepository->delete($article); $this->audit->log('Suppression de l\'article ' . $article->titre); $this->addFlash('notice', 'L\'article ' . $article->titre . ' a été supprimé'); diff --git a/sources/AppBundle/Controller/Admin/Site/Article/EditArticleAction.php b/sources/AppBundle/Controller/Admin/Site/Article/EditArticleAction.php index 39574516e..bea39e15e 100644 --- a/sources/AppBundle/Controller/Admin/Site/Article/EditArticleAction.php +++ b/sources/AppBundle/Controller/Admin/Site/Article/EditArticleAction.php @@ -5,9 +5,11 @@ namespace AppBundle\Controller\Admin\Site\Article; use AppBundle\AuditLog\Audit; +use AppBundle\Site\ArticleImageStorage; use AppBundle\Site\Entity\Repository\ArticleRepository; use AppBundle\Site\Form\ArticleType; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -16,6 +18,7 @@ final class EditArticleAction extends AbstractController public function __construct( private readonly ArticleRepository $articleRepository, private readonly Audit $audit, + private readonly ArticleImageStorage $articleImageStorage, ) {} public function __invoke(int $id, Request $request): Response @@ -25,9 +28,17 @@ public function __invoke(int $id, Request $request): Response throw $this->createNotFoundException(); } - $form = $this->createForm(ArticleType::class, $article, ['is_new' => false]); + $form = $this->createForm(ArticleType::class, $article); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + $uploadedImage = $form->get('image')->getData(); + if ($uploadedImage instanceof UploadedFile) { + $article->image = $this->articleImageStorage->store($uploadedImage, $article); + } elseif ($article->image && $form->get('supprimerImage')->getData() === true) { + $this->articleImageStorage->remove($article); + $article->image = null; + } + $this->articleRepository->save($article); $this->audit->log('Modification de l\'article ' . $article->titre); $this->addFlash('notice', 'L\'article ' . $article->titre . ' a été modifié'); @@ -39,6 +50,7 @@ public function __invoke(int $id, Request $request): Response 'article' => $article, 'formTitle' => 'Modifier un article', 'submitLabel' => 'Modifier', + 'imageUrl' => $this->articleImageStorage->getUrl($article), ]); } } diff --git a/sources/AppBundle/Controller/Website/News/DisplayAction.php b/sources/AppBundle/Controller/Website/News/DisplayAction.php index 4f45dc076..dd80126a0 100644 --- a/sources/AppBundle/Controller/Website/News/DisplayAction.php +++ b/sources/AppBundle/Controller/Website/News/DisplayAction.php @@ -6,6 +6,7 @@ use AppBundle\Event\Model\Event; use AppBundle\Event\Model\Repository\EventRepository; +use AppBundle\Site\ArticleImageStorage; use AppBundle\Site\Entity\Article; use AppBundle\Site\Entity\Repository\ArticleRepository; use AppBundle\Site\Enum\ArticleEtat; @@ -21,6 +22,7 @@ public function __construct( private readonly AuthorizationCheckerInterface $authorizationChecker, private readonly EventRepository $eventRepository, private readonly ArticleRepository $articleRepository, + private readonly ArticleImageStorage $articleImageStorage, ) {} public function __invoke(string $code): Response @@ -39,6 +41,7 @@ public function __invoke(string $code): Response 'previous' => $this->articleRepository->findPrevious($article), 'next' => $this->articleRepository->findNext($article), 'related_event' => $this->getRelatedEvent($article), + 'image_url' => $this->articleImageStorage->getUrl($article), ]); } diff --git a/sources/AppBundle/Site/ArticleImageStorage.php b/sources/AppBundle/Site/ArticleImageStorage.php new file mode 100644 index 000000000..084f4de15 --- /dev/null +++ b/sources/AppBundle/Site/ArticleImageStorage.php @@ -0,0 +1,89 @@ +filesystem = new Filesystem(); + } + + public function store(UploadedFile $file, Article $article): string + { + // On supprime d'abord l'image précédente si elle existe + $this->remove($article); + + $this->createDirectory(); + + $fileName = $this->generateFileName($file, $article); + $file->move($this->basePath, $fileName); + + return $fileName; + } + + public function remove(Article $article): void + { + if ($article->image === null) { + return; + } + + $this->filesystem->remove($this->basePath . '/' . $article->image); + } + + public function getUrl(Article $article): ?string + { + if ($article->image === null) { + return null; + } + + if (!$this->filesystem->exists($this->basePath . '/' . $article->image)) { + return null; + } + + return self::PUBLIC_PATH . $article->image; + } + + private function generateFileName(UploadedFile $file, Article $article): string + { + $slug = (new AsciiSlugger())->slug((string) $article->titre) + ->lower() + ->truncate(60) + ->trim('-') + ->toString(); + + if ($slug === '') { + $slug = 'article'; + } + + // Pour faire sauter le cache quand l'image est modifiée + $randomString = bin2hex(random_bytes(4)); + + return sprintf('%s-%s.%s', $slug, $randomString, $file->guessExtension() ?? 'jpg',); + } + + private function createDirectory(): void + { + try { + $this->filesystem->mkdir($this->basePath, 0755); + } catch (IOException $exception) { + throw new FileException('Could not create directory for storage', 0, $exception); + } + } +} diff --git a/sources/AppBundle/Site/Entity/Article.php b/sources/AppBundle/Site/Entity/Article.php index cdb475fd5..5be755fa2 100644 --- a/sources/AppBundle/Site/Entity/Article.php +++ b/sources/AppBundle/Site/Entity/Article.php @@ -34,6 +34,9 @@ class Article #[ORM\Column(type: 'text', nullable: true)] public ?string $contenu = null; + #[ORM\Column(length: 255, nullable: true)] + public ?string $image = null; + #[ORM\Column(nullable: true, enumType: ArticleTheme::class)] public ?ArticleTheme $theme = null; diff --git a/sources/AppBundle/Site/Form/ArticleType.php b/sources/AppBundle/Site/Form/ArticleType.php index 5e0bfdd7d..7738815e2 100644 --- a/sources/AppBundle/Site/Form/ArticleType.php +++ b/sources/AppBundle/Site/Form/ArticleType.php @@ -5,19 +5,21 @@ namespace AppBundle\Site\Form; use AppBundle\Event\Model\Repository\EventRepository; +use AppBundle\Site\Entity\Article; use AppBundle\Site\Entity\Rubrique; use AppBundle\Site\Enum\ArticleEtat; use AppBundle\Site\Enum\ArticleTheme; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; +use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints as Assert; class ArticleType extends AbstractType @@ -28,6 +30,11 @@ public function __construct(private readonly EventRepository $eventRepository) { public function buildForm(FormBuilderInterface $builder, array $options): void { + $article = $builder->getData(); + if (!$article instanceof Article) { + throw new \LogicException(); + } + $positions = []; for ($i = self::POSITIONS_RUBRIQUES; $i >= -(self::POSITIONS_RUBRIQUES); $i--) { $positions[$i] = $i; @@ -79,6 +86,22 @@ public function buildForm(FormBuilderInterface $builder, array $options): void new Assert\Type('string'), ], ]) + ->add('image', FileType::class, [ + 'label' => 'Image de couverture', + 'required' => false, + 'mapped' => false, + 'data_class' => null, + 'help' => 'JPEG, PNG, WebP ou GIF — 2 Mo maximum', + 'attr' => [ + 'accept' => 'image/jpeg,image/png,image/webp,image/gif', + ], + 'constraints' => [ + new Assert\Image( + maxSize: '2M', + mimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], + ), + ], + ]) ->add('rubrique', EntityType::class, [ 'required' => true, 'label' => 'Rubrique', @@ -128,12 +151,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'constraints' => [ new Assert\Type("integer"), ], - ]) - ; + ]); $builder->get('datePublication')->addModelTransformer(new DateTimeToTimestampTransformer()); $raccourciOption = [ - 'required' => $options['is_new'] === false, + 'required' => $article->id !== null, 'label' => 'Raccourci', 'attr' => [ 'maxlength' => 255, @@ -146,19 +168,20 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ], ]; - if ($options['is_new'] === true) { + if ($article->id === null) { $raccourciOption['help'] = 'Si ce champ est vide, la valeur sera générée automatiquement'; } else { $raccourciOption['constraints'][] = new Assert\NotBlank(); } $builder->add('raccourci', TextType::class, $raccourciOption); - } - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'is_new' => true, - ]); + if ($article->image !== null) { + $builder->add('supprimerImage', CheckboxType::class, [ + 'label' => 'Supprimer l\'image de couverture', + 'required' => false, + 'mapped' => false, + ]); + } } } diff --git a/templates/admin/site/article_form.html.twig b/templates/admin/site/article_form.html.twig index d35de261f..e71529419 100644 --- a/templates/admin/site/article_form.html.twig +++ b/templates/admin/site/article_form.html.twig @@ -28,6 +28,20 @@ {{ form_row(form.titre) }} {{ form_row(form.chapeau) }} {{ form_row(form.contenu) }} + {{ form_row(form.image) }} +