diff --git a/assets/app.js b/assets/app.js index e69de29..d2b348e 100644 --- a/assets/app.js +++ b/assets/app.js @@ -0,0 +1 @@ +import './bootstrap.js'; diff --git a/assets/bootstrap.js b/assets/bootstrap.js new file mode 100644 index 0000000..d4e50c9 --- /dev/null +++ b/assets/bootstrap.js @@ -0,0 +1,5 @@ +import { startStimulusApp } from '@symfony/stimulus-bundle'; + +const app = startStimulusApp(); +// register any custom, 3rd party controllers here +// app.register('some_controller_name', SomeImportedController); diff --git a/src/Controller/PostController.php b/src/Controller/PostController.php index d0f604d..af3d395 100644 --- a/src/Controller/PostController.php +++ b/src/Controller/PostController.php @@ -2,7 +2,10 @@ namespace App\Controller; +use App\Entity\Comment; use App\Entity\Post; +use App\Entity\User; +use App\Form\CommentType; use App\Form\PostType; use App\Repository\PostRepository; use Doctrine\ORM\EntityManagerInterface; @@ -10,7 +13,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\UX\Turbo\TurboBundle; class PostController extends AbstractController { @@ -62,8 +67,12 @@ class PostController extends AbstractController #[Route('/post/{id}', name: 'app_post_show', methods: ['GET'])] public function show(Post $post): Response { + $form = $this->createForm(CommentType::class, new Comment(), [ + 'action' => $this->generateUrl('app_post_comment', ['id' => $post->getId()]), + ]); return $this->render('post/show.html.twig', [ 'post' => $post, + 'form' => $form, ]); } @@ -97,4 +106,71 @@ class PostController extends AbstractController return $this->redirectToRoute('app_posts', [], Response::HTTP_SEE_OTHER); } + + #[Route('/post/{id}/comment', name: 'app_post_comment', methods: ['POST'])] + public function publishComment(Request $request, Post $post, EntityManagerInterface $entityManager, #[CurrentUser] User $user): Response + { + $comment = new Comment(); + $form = $this->createForm(CommentType::class, $comment); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $comment->setRelatedPost($post) + ->setAuthor($user); + $entityManager->persist($comment); + $entityManager->flush(); + + if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + return $this->renderBlock('comment/new.stream.html.twig', 'success_stream', ['comment' => $comment]); + } + } + return $this->redirectToRoute('app_post_show', ['id' => $post->getId()], Response::HTTP_SEE_OTHER); + } + + #[Route('/comment/{id}/edit', name: 'app_post_comment_edit', methods: ['GET', 'POST'])] + public function editComment(Request $request, Comment $comment, EntityManagerInterface $entityManager): Response + { + $form = $this->createForm(CommentType::class, $comment, [ + 'action' => $this->generateUrl('app_post_comment_edit', ['id' => $comment->getId()]), + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->flush(); + + if ($comment->getRelatedPost() === null) { + return $this->redirectToRoute('app_posts', [], Response::HTTP_SEE_OTHER); + } + return $this->redirectToRoute('app_post_show', [ + 'id' => $comment->getRelatedPost()->getId() + ], Response::HTTP_SEE_OTHER); + } + + return $this->render('comment/edit.html.twig', [ + 'comment' => $comment, + 'form' => $form, + ]); + } + + #[Route('/comment/{id}', name: 'app_post_comment_delete', methods: ['POST'])] + #[IsGranted('COMMENT_EDIT', subject: 'comment')] + public function deleteComment(Request $request, Comment $comment, EntityManagerInterface $entityManager): Response + { + if ($this->isCsrfTokenValid('delete'.$comment->getId(), (string) $request->getPayload()->get('_token'))) { + $id = $comment->getId(); + $entityManager->remove($comment); + $entityManager->flush(); + + if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + return $this->renderBlock('comment/deleted.stream.html.twig', 'success_stream', ['comment' => $id]); + } + } + + if ($comment->getRelatedPost() === null) { + return $this->redirectToRoute('app_posts'); + } + return $this->redirectToRoute('app_post_show', ['id' => $comment->getRelatedPost()->getId()]); + } } diff --git a/src/Entity/Comment.php b/src/Entity/Comment.php new file mode 100644 index 0000000..42dd726 --- /dev/null +++ b/src/Entity/Comment.php @@ -0,0 +1,119 @@ +id; + } + + public function getAuthor(): ?User + { + return $this->author; + } + + public function setAuthor(?User $author): static + { + $this->author = $author; + + return $this; + } + + public function getRelatedPost(): ?Post + { + return $this->related_post; + } + + public function setRelatedPost(?Post $related_post): static + { + $this->related_post = $related_post; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->created_at; + } + + public function setCreatedAt(\DateTimeImmutable $created_at): static + { + $this->created_at = $created_at; + + return $this; + } + + public function getEditedAt(): ?\DateTimeImmutable + { + return $this->edited_at; + } + + public function setEditedAt(\DateTimeImmutable $edited_at): static + { + $this->edited_at = $edited_at; + + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(string $content): static + { + $this->content = $content; + + return $this; + } + + #[ORM\PrePersist] + public function setCreatedAtDate(): void + { + if ($this->created_at === null) { + $this->created_at = new \DateTimeImmutable(); + $this->edited_at = $this->created_at; + } + } + + #[ORM\PreUpdate] + public function setEditedAtDate(): void + { + $this->edited_at = new \DateTimeImmutable(); + } +} diff --git a/src/Entity/Post.php b/src/Entity/Post.php index 22e346a..4d0b54f 100644 --- a/src/Entity/Post.php +++ b/src/Entity/Post.php @@ -8,6 +8,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use App\Repository\PostRepository; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; @@ -61,6 +63,17 @@ class Post #[Groups(['post:collection:read'])] private ?Species $species = null; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'related_post', fetch: 'EXTRA_LAZY')] + private Collection $comments; + + public function __construct() + { + $this->comments = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; @@ -158,4 +171,34 @@ class Post $this->publicationDate = new \DateTimeImmutable(); } } + + /** + * @return Collection + */ + public function getComments(): Collection + { + return $this->comments; + } + + public function addComment(Comment $comment): static + { + if (!$this->comments->contains($comment)) { + $this->comments->add($comment); + $comment->setRelatedPost($this); + } + + return $this; + } + + public function removeComment(Comment $comment): static + { + if ($this->comments->removeElement($comment)) { + // set the owning side to null (unless already changed) + if ($comment->getRelatedPost() === $this) { + $comment->setRelatedPost(null); + } + } + + return $this; + } } diff --git a/src/Entity/User.php b/src/Entity/User.php index e924817..8519d9d 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -10,6 +10,8 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Put; use App\Repository\UserRepository; use App\State\UserPasswordHasher; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; @@ -60,6 +62,17 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[Groups(['user:create', 'user:update'])] private ?string $plainPassword = null; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'author')] + private Collection $comments; + + public function __construct() + { + $this->comments = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; @@ -146,4 +159,34 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface // If you store any temporary, sensitive data on the user, clear it here // $this->plainPassword = null; } + + /** + * @return Collection + */ + public function getComments(): Collection + { + return $this->comments; + } + + public function addComment(Comment $comment): static + { + if (!$this->comments->contains($comment)) { + $this->comments->add($comment); + $comment->setAuthor($this); + } + + return $this; + } + + public function removeComment(Comment $comment): static + { + if ($this->comments->removeElement($comment)) { + // set the owning side to null (unless already changed) + if ($comment->getAuthor() === $this) { + $comment->setAuthor(null); + } + } + + return $this; + } } diff --git a/src/Form/CommentType.php b/src/Form/CommentType.php new file mode 100644 index 0000000..e41bdd8 --- /dev/null +++ b/src/Form/CommentType.php @@ -0,0 +1,29 @@ +add('content', TextareaType::class) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Comment::class, + ]); + } +} diff --git a/src/Repository/CommentRepository.php b/src/Repository/CommentRepository.php new file mode 100644 index 0000000..47ea76e --- /dev/null +++ b/src/Repository/CommentRepository.php @@ -0,0 +1,43 @@ + + */ +class CommentRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Comment::class); + } + + // /** + // * @return Comment[] Returns an array of Comment objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('c') + // ->andWhere('c.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('c.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Comment + // { + // return $this->createQueryBuilder('c') + // ->andWhere('c.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/PostRepository.php b/src/Repository/PostRepository.php index 2082982..64328a5 100644 --- a/src/Repository/PostRepository.php +++ b/src/Repository/PostRepository.php @@ -25,6 +25,8 @@ class PostRepository extends ServiceEntityRepository public function findPaginatedPosts(int $page, int $limit): Paginator { $query = $this->createQueryBuilder('p') + ->addSelect('species') + ->leftJoin('p.species', 'species') ->setFirstResult(($page - 1) * $limit) ->setMaxResults($limit); diff --git a/src/Security/Voter/CommentVoter.php b/src/Security/Voter/CommentVoter.php new file mode 100644 index 0000000..8761145 --- /dev/null +++ b/src/Security/Voter/CommentVoter.php @@ -0,0 +1,36 @@ +getUser(); + + // if the user is anonymous, do not grant access + if (!$user instanceof UserInterface) { + return false; + } + + if ($subject->getAuthor() === $user) { + return true; + } + + return in_array('ROLE_ADMIN', $user->getRoles(), true); + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index 3aac3b0..1988983 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -12,7 +12,7 @@ {% block importmap %}{{ importmap('app') }}{% endblock %} {% endblock %} - +