12 Commits 16f9778746 ... e63f90dd8a

Tác giả SHA1 Thông báo Ngày
  Sangfroid e63f90dd8a On remonte les tags dans le aside 3 tuần trước cách đây
  Sangfroid 7b50a2cf19 Ajout de la suppression des tags orphelins après suppression d'un article 3 tuần trước cách đây
  Sangfroid c5ade57cc7 Suppression du dump qui foutais en l'air le formulaire 3 tuần trước cách đây
  Sangfroid a570e2690f correction event form 4 tuần trước cách đây
  Sangfroid a8c7424e02 Suppression des tags orphelins 4 tuần trước cách đây
  Sangfroid 98cbc8fb99 Empecher tag vide 4 tuần trước cách đây
  Sangfroid f5992b549e Tags en minuscule 4 tuần trước cách đây
  Sangfroid fc8a066a0a Correction d'une coquille de paramètre 4 tuần trước cách đây
  Sangfroid d8bd5e65b8 Ajout des tags dans feeds 4 tuần trước cách đây
  Sangfroid 68e43b4eb0 Ajout de la liste des tags 1 tháng trước cách đây
  Sangfroid ac943e0dbe Ajout du controller de recherche par tag 1 tháng trước cách đây
  Sangfroid 9e8cb060d2 tags qui marchent 1 tháng trước cách đây

+ 11 - 0
assets/styles/badges.css

@@ -10,6 +10,17 @@
   margin-right: 5px;
 }
 
+.tag {
+  display: inline-block;
+  padding: 3px 7px;
+  font-size: 12px;
+  font-weight: bold;
+  border-radius: 7px;
+  color: #fff;
+  margin-right: 2px;
+  background-color: #17a2b8;
+}
+
 /* Badge pour l'état "Publié" */
 .badge-published {
   background-color: #28a745; /* Vert */

+ 17 - 3
src/Controller/ArticleController.php

@@ -5,6 +5,7 @@ namespace App\Controller;
 use App\Entity\Article;
 use App\Form\ArticleType;
 use App\Repository\ArticleRepository;
+use App\Service\TagService;
 use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\DependencyInjection\Attribute\Target;
@@ -31,7 +32,8 @@ final class ArticleController extends AbstractController
         Request $request,
         EntityManagerInterface $entityManager,
         #[Target('blog_publishing')]
-        WorkflowInterface $workflow
+        WorkflowInterface $workflow,
+        TagService $tagService
     ): Response
     {
         $article = new Article($this->getUser());
@@ -58,6 +60,8 @@ final class ArticleController extends AbstractController
             $entityManager->persist($article);
             $entityManager->flush();
 
+            $tagService->clearOrphansTags();
+
             return $this->redirectToRoute('app_article_show', ['id' => $article->getId()], Response::HTTP_SEE_OTHER);
         }
 
@@ -83,7 +87,8 @@ final class ArticleController extends AbstractController
         Article $article,
         EntityManagerInterface $entityManager,
         #[Target('blog_publishing')]
-        WorkflowInterface $workflow
+        WorkflowInterface $workflow,
+        TagService $tagService
     ): Response
     {
         $transitions = $workflow->getEnabledTransitions($article);
@@ -105,6 +110,8 @@ final class ArticleController extends AbstractController
             }
 
             $entityManager->flush();
+            
+            $tagService->clearOrphansTags();
 
             return $this->redirectToRoute('app_article_show', ['id' => $article->getId()], Response::HTTP_SEE_OTHER);
         }
@@ -117,11 +124,18 @@ final class ArticleController extends AbstractController
 
     #[Route('/{id}', name: 'app_article_delete', methods: ['POST'])]
     #[IsGranted('edit', 'article')]
-    public function delete(Request $request, Article $article, EntityManagerInterface $entityManager): Response
+    public function delete(
+        Request $request,
+        Article $article,
+        EntityManagerInterface $entityManager,
+        TagService $tagService
+
+    ): Response
     {
         if ($this->isCsrfTokenValid('delete'.$article->getId(), $request->getPayload()->getString('_token'))) {
             $entityManager->remove($article);
             $entityManager->flush();
+            $tagService->clearOrphansTags();
         }
 
         return $this->redirectToRoute('app_article_index', [], Response::HTTP_SEE_OTHER);

+ 1 - 1
src/Controller/FeedController.php

@@ -25,7 +25,7 @@ class FeedController extends AbstractController
     {
         $articles = $this->articleRepository->findBy(criteria: ['state' => 'published'], orderBy: ['publicationDate' => 'DESC']);
 
-        $feedContent = $this->feedService->createFeed($articles, $type, $this->articleRepository->findLastPublicationDate());
+        $feedContent = $this->feedService->createFeed($articles, $this->articleRepository->findLastPublicationDate(), $type);
 
         $contentType = $type === FeedService::RSS ? 'application/rss+xml' : 'application/atom+xml';
 

+ 24 - 0
src/Controller/SearchController.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Controller;
+
+use App\Repository\ArticleRepository;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+
+class SearchController extends AbstractController
+{
+    #[Route('/tag/{tag}', name: 'app_search')]
+    public function index(
+        string $tag,
+        ArticleRepository $articleRepository
+    ): Response
+    {
+        $articles = $articleRepository->findByTag($tag);
+        return $this->render('index/index.html.twig', [
+            'tag' => $tag,
+            'articles' => $articles
+        ]);
+    }
+}

+ 12 - 2
src/Entity/Article.php

@@ -45,7 +45,7 @@ class Article
     /**
      * @var Collection<int, Tag>
      */
-    #[ORM\ManyToMany(targetEntity: Tag::class)]
+    #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')]
     private Collection $tags;
 
     public function __construct(User $author)
@@ -144,6 +144,7 @@ class Article
     {
         if (!$this->tags->contains($tag)) {
             $this->tags->add($tag);
+            $tag->addArticle($this);
         }
 
         return $this;
@@ -151,7 +152,16 @@ class Article
 
     public function removeTag(Tag $tag): static
     {
-        $this->tags->removeElement($tag);
+        if ($this->tags->removeElement($tag)) {
+            $tag->removeArticle($this);
+        }
+
+        return $this;
+    }
+
+    public function clearTags(): static
+    {
+        $this->tags = new ArrayCollection();
 
         return $this;
     }

+ 43 - 1
src/Entity/Tag.php

@@ -3,10 +3,12 @@
 namespace App\Entity;
 
 use App\Repository\TagRepository;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
 
 #[ORM\Entity(repositoryClass: TagRepository::class)]
-class Tag
+class Tag implements \Stringable
 {
     #[ORM\Id]
     #[ORM\GeneratedValue]
@@ -16,6 +18,22 @@ class Tag
     #[ORM\Column(length: 50)]
     private ?string $name = null;
 
+    /**
+     * @var Collection<int, Article>
+     */
+    #[ORM\ManyToMany(targetEntity: Article::class, mappedBy: 'tags')]
+    private Collection $articles;
+
+    public function __construct()
+    {
+        $this->articles = new ArrayCollection();
+    }
+
+    public function __toString(): string
+    {
+        return $this->getName();
+    }
+
     public function getId(): ?int
     {
         return $this->id;
@@ -32,4 +50,28 @@ class Tag
 
         return $this;
     }
+
+    /**
+     * @return Collection<int, Article>
+     */
+    public function getArticles(): Collection
+    {
+        return $this->articles;
+    }
+
+    public function addArticle(Article $article): static
+    {
+        if (!$this->articles->contains($article)) {
+            $this->articles->add($article);
+        }
+
+        return $this;
+    }
+
+    public function removeArticle(Article $article): static
+    {
+        $this->articles->removeElement($article);
+
+        return $this;
+    }
 }

+ 61 - 1
src/Form/ArticleType.php

@@ -3,19 +3,31 @@
 namespace App\Form;
 
 use App\Entity\Article;
+use App\Entity\Tag;
 use App\Entity\User;
+use App\Repository\TagRepository;
 use App\Service\BaseUrl;
+use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bridge\Doctrine\Form\Type\EntityType;
 use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Event\PostSetDataEvent;
+use Symfony\Component\Form\Event\SubmitEvent;
 use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
 use Symfony\Component\Form\Extension\Core\Type\TextareaType;
 use Symfony\Component\Form\Extension\Core\Type\TextType;
 use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Form\FormEvent;
+use Symfony\Component\Form\FormEvents;
 use Symfony\Component\OptionsResolver\OptionsResolver;
 
 class ArticleType extends AbstractType
 {
-    public function __construct(protected BaseUrl $baseUrl)
+    public function __construct(
+        protected readonly BaseUrl $baseUrl,
+        protected readonly EntityManagerInterface $em,
+        protected readonly TagRepository $tagRepository
+    
+    )
     {
         
     }
@@ -35,6 +47,13 @@ class ArticleType extends AbstractType
                     'data-url' => $this->baseUrl->getBasePath()
                 ],
             ])
+            ->add('tags', TextType::class, [
+                'mapped' => false,
+                'required' => false,
+                'attr'  => [
+                    'placeholder' => 'Entrez des tag séparés par des virgules'
+                ]
+            ])
             ->add('content', TextareaType::class, [
                 'required'  => false,
                 'attr'  =>  [
@@ -56,6 +75,47 @@ class ArticleType extends AbstractType
                 'choice_value'  => null
             ])
         ;
+
+        // Listener pour pré-remplir le champ texte lors de l'édition
+        $builder->addEventListener(FormEvents::POST_SET_DATA, function (PostSetDataEvent $event): void {
+            /** @var Article $article */
+            $article = $event->getData();
+            $form = $event->getForm();
+            if ($article && $article->getTags()) {
+                $tags = $article->getTags()->map(fn($tag) => $tag->getName())->toArray();
+                if ($form->has('tags')) {
+                    $form->get('tags')->setData(implode(', ', $tags));
+                }
+            }
+        });
+
+        // Écouteur d'événement pour permettre la création de nouveaux tags
+        $builder->addEventListener(FormEvents::SUBMIT, function (SubmitEvent $event): void {
+            $article = $event->getData();
+            $form = $event->getForm();
+
+            $article->clearTags();
+
+            $tagsInput = $form->get('tags')->getData();
+            if ($tagsInput) {
+                $tags = array_map('trim', explode(',', $tagsInput));
+                
+                foreach ($tags as $tagName) {
+                    if ($tagName === '') {
+                        continue;
+                    }
+                    $tag = $this->tagRepository->findOneBy(['name' => $tagName]);
+                    
+                    if (!$tag) {
+                        $tag = new Tag();
+                        $tag->setName($tagName);
+                        $this->em->persist($tag);
+                    }
+
+                    $article->addTag($tag);
+                }
+            }
+        });
     }
 
     public function configureOptions(OptionsResolver $resolver): void

+ 20 - 0
src/Repository/ArticleRepository.php

@@ -51,4 +51,24 @@ class ArticleRepository extends ServiceEntityRepository
             ->getOneOrNullResult()['publicationDate'] ?? null
         ;
     }
+
+    public function findByTag(string $tag, bool $onlyPublished = true): array
+    {
+        $qb = $this->createQueryBuilder('a');
+
+        if ($onlyPublished) {
+            $qb->andWhere('a.state = :published')
+                ->setParameter('published', 'published')
+            ;
+        }
+
+        return $qb
+            ->innerJoin('a.tags', 't')
+            ->andWhere($qb->expr()->like('t.name', ':tag'))
+            ->setParameter('tag', '%'.$tag.'%')
+            ->orderBy('a.publicationDate', 'DESC')
+            ->getQuery()
+            ->getResult()
+        ;
+    }
 }

+ 10 - 0
src/Repository/TagRepository.php

@@ -40,4 +40,14 @@ class TagRepository extends ServiceEntityRepository
     //            ->getOneOrNullResult()
     //        ;
     //    }
+
+    public function findOrphanTags(): array
+    {
+        return $this->createQueryBuilder('t')
+            ->leftJoin('t.articles', 'a')
+            ->where('a.id IS NULL')
+            ->getQuery()
+            ->getResult()
+        ;
+    }
 }

+ 9 - 1
src/Service/FeedService.php

@@ -25,7 +25,7 @@ class FeedService
 
     }
 
-    public function createFeed(array $articles, string $type = self::RSS, \DateTimeImmutable $updatedAt): string
+    public function createFeed(array $articles, \DateTimeImmutable $updatedAt, string $type = self::RSS): string
     {
         $feed = new Feed();
         $feed->setTitle($this->parameterBagInterface->get('title'));
@@ -53,6 +53,14 @@ class FeedService
                 ->setDateModified($item->getDateCreated())
                 ->addAuthor(['name' => (string) $article->getAuthor()])
             ;
+            $tags = $article->getTags();
+            foreach($tags as $tag) {
+                $item->addCategory([
+                    'term' => (string) $tag->getName(),
+                    'scheme' => $this->router->generate('app_search', ['tag' => $tag->getName()], RouterInterface::ABSOLUTE_URL)
+                ]);
+            }
+
             # Fix pour supprimer le allowfullscreen récupéré par l'extension embed pour youtube
             $content = $this->markdownParser->convertToHtml($article->getContent());
             $content = str_replace('allowfullscreen', 'allowfullscreen="true"', $content);

+ 26 - 0
src/Service/TagService.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace App\Service;
+
+use App\Repository\TagRepository;
+use Doctrine\ORM\EntityManagerInterface;
+
+class TagService
+{
+    public function __construct(
+        protected readonly TagRepository $tagRepository,
+        protected EntityManagerInterface $em
+    )
+    {
+        
+    }
+
+    public function clearOrphansTags(): void
+    {
+        $tags = $this->tagRepository->findOrphanTags();
+        foreach($tags as $tag) {
+            $this->em->remove($tag);
+        }
+        $this->em->flush();
+    }
+}

+ 16 - 0
src/Twig/ListeTags.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Twig;
+
+use Twig\Extension\AbstractExtension;
+use Twig\TwigFunction;
+
+class ListeTags extends AbstractExtension
+{
+    public function getFunctions(): array
+    {
+        return [
+            new TwigFunction('liste_tags', [ListeTagsRuntimeExtension::class, 'getListeTags'])
+        ];
+    }
+}

+ 21 - 0
src/Twig/ListeTagsRuntimeExtension.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Twig;
+
+use App\Repository\TagRepository;
+use Twig\Extension\RuntimeExtensionInterface;
+
+class ListeTagsRuntimeExtension implements RuntimeExtensionInterface
+{
+    public function __construct(
+        protected readonly TagRepository $tagRepository
+    )
+    {
+        
+    }
+
+    public function getListeTags(): array
+    {
+        return $this->tagRepository->findAll();
+    }
+}

+ 12 - 1
templates/_aside.html.twig

@@ -10,6 +10,17 @@
         </ul>
     </section>
 
+    <!-- Etiquettes -->
+    {% set tags = liste_tags() %}
+    <section>
+        <h2>Etiquettes</h2>
+        <p>
+        {% for tag in tags %}
+            <a href="{{ path('app_search', {'tag': tag.name}) }}"><span class="tag">{{ tag }}</span></a>
+        {% endfor %}
+        </p>
+    </section>
+
     <!-- Section Flux -->
     <section class="feeds">
         <h2>Flux</h2>
@@ -18,7 +29,7 @@
             <li><a href="{{ path('app_feed', {'type': 'rss' }) }}">RSS</a></li>
         </ul>
     </section>
-    
+
     <!-- Section Connexion -->
     <section class="connexion">
         {% if is_granted('IS_AUTHENTICATED_REMEMBERED') %}

+ 1 - 1
templates/article/_form.html.twig

@@ -17,7 +17,7 @@
             <div class="div-bouton-slug"><button type="button" class="bouton-slug" data-slugger-target="bouton" data-action="click->slugger#open">Générer un slug</butt></div>
             {{ form_help(form.slug) }}
         </div>
-        
+        {{ form_row(form.tags ) }}
         {{ form_row(form.content) }}
         {{ form_row(form.publicationDate) }}
         {{ form_row(form.author) }}

+ 9 - 0
templates/article/show.html.twig

@@ -19,6 +19,15 @@
                 <th>Permalien</th>
                 <td>{{ url('app_view', {slug: article.slug}) }}</td>
             </tr>
+            <tr>
+                <th>Tags</th>
+                <td>
+                    {% for tag in article.tags %}
+                        <span class="tag">{{ tag }}</span>
+                        {% if not loop.last %} {% endif %}
+                    {% endfor %}
+                </td>
+            </tr>
             <tr>
                 <th>Date de publication</th>
                 <td>{{ article.publicationDate ? article.publicationDate|date('Y-m-d H:i:s') : '' }}</td>

+ 12 - 0
templates/index/index.html.twig

@@ -4,6 +4,12 @@
 
 {% block body %}
 
+{% if tag is defined %}
+<section>
+    <h1>Etiquette : {{ tag }}</h1>
+</section>
+{% endif %}
+
 <div>
     <section id="section-articles">
         {% for article in articles %}
@@ -11,6 +17,12 @@
                 <header class="titre-article">
                     <h1><a href="{{ path('app_view', {'slug': article.slug}) }}">{{ article.title}}</a></h1>
                     <p class="article-by">{{ article.publicationDate | format_date('long') }} - {{ article.author }}</p>
+                    <p class="article-by">
+                        {% for tag in article.tags %}
+                            <a href="{{ path('app_search', {'tag': tag.name}) }}"><span class="tag">{{ tag }}</span></a>
+                            {% if not loop.last %} {% endif %}
+                        {% endfor %}
+                    </p>
                 </header>
                 <section class="contenu">
                     {{ article.content | markdown }}

+ 6 - 0
templates/view/index.html.twig

@@ -16,6 +16,12 @@
         <header class="titre-article">
             <h1>{{ article.title}}</h1>
             <p class="article-by">{{ article.publicationDate | format_date('long')}} - {{ article.author }}</p>
+            <p class="article-by">
+                {% for tag in article.tags %}
+                    <a href="{{ path('app_search', {'tag': tag.name}) }}"><span class="tag">{{ tag }}</span></a>
+                    {% if not loop.last %} {% endif %}
+                {% endfor %}
+            </p>
         </header>
         <section class="contenu">
             {{ article.content | markdown }}