1
0

12 Commits 16f9778746 ... e63f90dd8a

Autor SHA1 Mensagem Data
  Sangfroid e63f90dd8a On remonte les tags dans le aside há 10 meses atrás
  Sangfroid 7b50a2cf19 Ajout de la suppression des tags orphelins après suppression d'un article há 10 meses atrás
  Sangfroid c5ade57cc7 Suppression du dump qui foutais en l'air le formulaire há 10 meses atrás
  Sangfroid a570e2690f correction event form há 11 meses atrás
  Sangfroid a8c7424e02 Suppression des tags orphelins há 11 meses atrás
  Sangfroid 98cbc8fb99 Empecher tag vide há 11 meses atrás
  Sangfroid f5992b549e Tags en minuscule há 11 meses atrás
  Sangfroid fc8a066a0a Correction d'une coquille de paramètre há 11 meses atrás
  Sangfroid d8bd5e65b8 Ajout des tags dans feeds há 11 meses atrás
  Sangfroid 68e43b4eb0 Ajout de la liste des tags há 11 meses atrás
  Sangfroid ac943e0dbe Ajout du controller de recherche par tag há 11 meses atrás
  Sangfroid 9e8cb060d2 tags qui marchent há 11 meses atrás

+ 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 }}