12 İşlemeler 16f9778746 ... e63f90dd8a

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

+ 11 - 0
assets/styles/badges.css

@@ -10,6 +10,17 @@
   margin-right: 5px;
   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 pour l'état "Publié" */
 .badge-published {
 .badge-published {
   background-color: #28a745; /* Vert */
   background-color: #28a745; /* Vert */

+ 17 - 3
src/Controller/ArticleController.php

@@ -5,6 +5,7 @@ namespace App\Controller;
 use App\Entity\Article;
 use App\Entity\Article;
 use App\Form\ArticleType;
 use App\Form\ArticleType;
 use App\Repository\ArticleRepository;
 use App\Repository\ArticleRepository;
+use App\Service\TagService;
 use Doctrine\ORM\EntityManagerInterface;
 use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\DependencyInjection\Attribute\Target;
 use Symfony\Component\DependencyInjection\Attribute\Target;
@@ -31,7 +32,8 @@ final class ArticleController extends AbstractController
         Request $request,
         Request $request,
         EntityManagerInterface $entityManager,
         EntityManagerInterface $entityManager,
         #[Target('blog_publishing')]
         #[Target('blog_publishing')]
-        WorkflowInterface $workflow
+        WorkflowInterface $workflow,
+        TagService $tagService
     ): Response
     ): Response
     {
     {
         $article = new Article($this->getUser());
         $article = new Article($this->getUser());
@@ -58,6 +60,8 @@ final class ArticleController extends AbstractController
             $entityManager->persist($article);
             $entityManager->persist($article);
             $entityManager->flush();
             $entityManager->flush();
 
 
+            $tagService->clearOrphansTags();
+
             return $this->redirectToRoute('app_article_show', ['id' => $article->getId()], Response::HTTP_SEE_OTHER);
             return $this->redirectToRoute('app_article_show', ['id' => $article->getId()], Response::HTTP_SEE_OTHER);
         }
         }
 
 
@@ -83,7 +87,8 @@ final class ArticleController extends AbstractController
         Article $article,
         Article $article,
         EntityManagerInterface $entityManager,
         EntityManagerInterface $entityManager,
         #[Target('blog_publishing')]
         #[Target('blog_publishing')]
-        WorkflowInterface $workflow
+        WorkflowInterface $workflow,
+        TagService $tagService
     ): Response
     ): Response
     {
     {
         $transitions = $workflow->getEnabledTransitions($article);
         $transitions = $workflow->getEnabledTransitions($article);
@@ -105,6 +110,8 @@ final class ArticleController extends AbstractController
             }
             }
 
 
             $entityManager->flush();
             $entityManager->flush();
+            
+            $tagService->clearOrphansTags();
 
 
             return $this->redirectToRoute('app_article_show', ['id' => $article->getId()], Response::HTTP_SEE_OTHER);
             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'])]
     #[Route('/{id}', name: 'app_article_delete', methods: ['POST'])]
     #[IsGranted('edit', 'article')]
     #[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'))) {
         if ($this->isCsrfTokenValid('delete'.$article->getId(), $request->getPayload()->getString('_token'))) {
             $entityManager->remove($article);
             $entityManager->remove($article);
             $entityManager->flush();
             $entityManager->flush();
+            $tagService->clearOrphansTags();
         }
         }
 
 
         return $this->redirectToRoute('app_article_index', [], Response::HTTP_SEE_OTHER);
         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']);
         $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';
         $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>
      * @var Collection<int, Tag>
      */
      */
-    #[ORM\ManyToMany(targetEntity: Tag::class)]
+    #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')]
     private Collection $tags;
     private Collection $tags;
 
 
     public function __construct(User $author)
     public function __construct(User $author)
@@ -144,6 +144,7 @@ class Article
     {
     {
         if (!$this->tags->contains($tag)) {
         if (!$this->tags->contains($tag)) {
             $this->tags->add($tag);
             $this->tags->add($tag);
+            $tag->addArticle($this);
         }
         }
 
 
         return $this;
         return $this;
@@ -151,7 +152,16 @@ class Article
 
 
     public function removeTag(Tag $tag): static
     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;
         return $this;
     }
     }

+ 43 - 1
src/Entity/Tag.php

@@ -3,10 +3,12 @@
 namespace App\Entity;
 namespace App\Entity;
 
 
 use App\Repository\TagRepository;
 use App\Repository\TagRepository;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
 use Doctrine\ORM\Mapping as ORM;
 
 
 #[ORM\Entity(repositoryClass: TagRepository::class)]
 #[ORM\Entity(repositoryClass: TagRepository::class)]
-class Tag
+class Tag implements \Stringable
 {
 {
     #[ORM\Id]
     #[ORM\Id]
     #[ORM\GeneratedValue]
     #[ORM\GeneratedValue]
@@ -16,6 +18,22 @@ class Tag
     #[ORM\Column(length: 50)]
     #[ORM\Column(length: 50)]
     private ?string $name = null;
     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
     public function getId(): ?int
     {
     {
         return $this->id;
         return $this->id;
@@ -32,4 +50,28 @@ class Tag
 
 
         return $this;
         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;
 namespace App\Form;
 
 
 use App\Entity\Article;
 use App\Entity\Article;
+use App\Entity\Tag;
 use App\Entity\User;
 use App\Entity\User;
+use App\Repository\TagRepository;
 use App\Service\BaseUrl;
 use App\Service\BaseUrl;
+use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bridge\Doctrine\Form\Type\EntityType;
 use Symfony\Bridge\Doctrine\Form\Type\EntityType;
 use Symfony\Component\Form\AbstractType;
 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\ChoiceType;
 use Symfony\Component\Form\Extension\Core\Type\TextareaType;
 use Symfony\Component\Form\Extension\Core\Type\TextareaType;
 use Symfony\Component\Form\Extension\Core\Type\TextType;
 use Symfony\Component\Form\Extension\Core\Type\TextType;
 use Symfony\Component\Form\FormBuilderInterface;
 use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Form\FormEvent;
+use Symfony\Component\Form\FormEvents;
 use Symfony\Component\OptionsResolver\OptionsResolver;
 use Symfony\Component\OptionsResolver\OptionsResolver;
 
 
 class ArticleType extends AbstractType
 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()
                     '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, [
             ->add('content', TextareaType::class, [
                 'required'  => false,
                 'required'  => false,
                 'attr'  =>  [
                 'attr'  =>  [
@@ -56,6 +75,47 @@ class ArticleType extends AbstractType
                 'choice_value'  => null
                 '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
     public function configureOptions(OptionsResolver $resolver): void

+ 20 - 0
src/Repository/ArticleRepository.php

@@ -51,4 +51,24 @@ class ArticleRepository extends ServiceEntityRepository
             ->getOneOrNullResult()['publicationDate'] ?? null
             ->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()
     //            ->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 = new Feed();
         $feed->setTitle($this->parameterBagInterface->get('title'));
         $feed->setTitle($this->parameterBagInterface->get('title'));
@@ -53,6 +53,14 @@ class FeedService
                 ->setDateModified($item->getDateCreated())
                 ->setDateModified($item->getDateCreated())
                 ->addAuthor(['name' => (string) $article->getAuthor()])
                 ->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
             # Fix pour supprimer le allowfullscreen récupéré par l'extension embed pour youtube
             $content = $this->markdownParser->convertToHtml($article->getContent());
             $content = $this->markdownParser->convertToHtml($article->getContent());
             $content = str_replace('allowfullscreen', 'allowfullscreen="true"', $content);
             $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>
         </ul>
     </section>
     </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 Flux -->
     <section class="feeds">
     <section class="feeds">
         <h2>Flux</h2>
         <h2>Flux</h2>
@@ -18,7 +29,7 @@
             <li><a href="{{ path('app_feed', {'type': 'rss' }) }}">RSS</a></li>
             <li><a href="{{ path('app_feed', {'type': 'rss' }) }}">RSS</a></li>
         </ul>
         </ul>
     </section>
     </section>
-    
+
     <!-- Section Connexion -->
     <!-- Section Connexion -->
     <section class="connexion">
     <section class="connexion">
         {% if is_granted('IS_AUTHENTICATED_REMEMBERED') %}
         {% 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>
             <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) }}
             {{ form_help(form.slug) }}
         </div>
         </div>
-        
+        {{ form_row(form.tags ) }}
         {{ form_row(form.content) }}
         {{ form_row(form.content) }}
         {{ form_row(form.publicationDate) }}
         {{ form_row(form.publicationDate) }}
         {{ form_row(form.author) }}
         {{ form_row(form.author) }}

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

@@ -19,6 +19,15 @@
                 <th>Permalien</th>
                 <th>Permalien</th>
                 <td>{{ url('app_view', {slug: article.slug}) }}</td>
                 <td>{{ url('app_view', {slug: article.slug}) }}</td>
             </tr>
             </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>
             <tr>
                 <th>Date de publication</th>
                 <th>Date de publication</th>
                 <td>{{ article.publicationDate ? article.publicationDate|date('Y-m-d H:i:s') : '' }}</td>
                 <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 %}
 {% block body %}
 
 
+{% if tag is defined %}
+<section>
+    <h1>Etiquette : {{ tag }}</h1>
+</section>
+{% endif %}
+
 <div>
 <div>
     <section id="section-articles">
     <section id="section-articles">
         {% for article in articles %}
         {% for article in articles %}
@@ -11,6 +17,12 @@
                 <header class="titre-article">
                 <header class="titre-article">
                     <h1><a href="{{ path('app_view', {'slug': article.slug}) }}">{{ article.title}}</a></h1>
                     <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">{{ 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>
                 </header>
                 <section class="contenu">
                 <section class="contenu">
                     {{ article.content | markdown }}
                     {{ article.content | markdown }}

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

@@ -16,6 +16,12 @@
         <header class="titre-article">
         <header class="titre-article">
             <h1>{{ article.title}}</h1>
             <h1>{{ article.title}}</h1>
             <p class="article-by">{{ article.publicationDate | format_date('long')}} - {{ article.author }}</p>
             <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>
         </header>
         <section class="contenu">
         <section class="contenu">
             {{ article.content | markdown }}
             {{ article.content | markdown }}