Compare commits
6 Commits
feature/ge
...
main
Author | SHA1 | Date | |
---|---|---|---|
11bcd9a83d | |||
71ddec420c | |||
4f19bac707 | |||
ba82630d8a | |||
![]() |
b0507a44ea | ||
![]() |
beca5f92da |
@@ -30,7 +30,3 @@ services:
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
when@prod:
|
||||
services:
|
||||
App\Service\ImageSafetyServiceInterface: '@App\Service\SightEngineImageSafetyService'
|
||||
|
24
public/css/app.css
Normal file
24
public/css/app.css
Normal file
@@ -0,0 +1,24 @@
|
||||
.no-style {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.no-style:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.grid-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
27
public/js/like_toggle.js
Normal file
27
public/js/like_toggle.js
Normal file
@@ -0,0 +1,27 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.like-toggle').forEach(button => {
|
||||
button.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
let isLiked = this.classList.contains('liked');
|
||||
let url = isLiked ? this.dataset.unlikeUrl : this.dataset.likeUrl;
|
||||
|
||||
fetch(url, { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
let likesCountElement = this.parentElement.querySelector('.likes-count');
|
||||
likesCountElement.textContent = data.likesCount;
|
||||
this.classList.toggle('liked');
|
||||
this.classList.toggle('not-liked');
|
||||
this.innerHTML = isLiked ? '♡' : '❤️';
|
||||
} else {
|
||||
console.error('Erreur lors du traitement du like/unlike.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Erreur lors de la requête fetch:', error);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -16,9 +16,16 @@ 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;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
|
||||
/**
|
||||
* CRUD on posts and comments.
|
||||
*/
|
||||
class PostController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* The number of item on a page for the pagination.
|
||||
*/
|
||||
private const POSTS_PER_PAGE = 10;
|
||||
|
||||
#[Route('/', name: 'app_posts')]
|
||||
@@ -35,7 +42,7 @@ class PostController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/posts', name: 'app_post_index', methods: ['GET'])]
|
||||
public function table(PostRepository $repository): Response
|
||||
public function table(): Response
|
||||
{
|
||||
return $this->redirectToRoute('app_posts', [], Response::HTTP_SEE_OTHER);
|
||||
}
|
||||
@@ -170,4 +177,29 @@ class PostController extends AbstractController
|
||||
}
|
||||
return $this->redirectToRoute('app_post_show', ['id' => $comment->getRelatedPost()->getId()]);
|
||||
}
|
||||
|
||||
#[Route('/post/{id}/like', name: 'app_posts_like', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function addLike(#[CurrentUser] User $user, Post $post, EntityManagerInterface $entityManager): JsonResponse
|
||||
{
|
||||
$user->addLikedPost($post);
|
||||
$entityManager->flush();
|
||||
|
||||
$likesCount = $post->getLikes()->count();
|
||||
|
||||
return new JsonResponse(['success' => true, 'likesCount' => $likesCount]);
|
||||
}
|
||||
|
||||
#[Route('/post/{id}/unlike', name: 'app_posts_unlike', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function deleteLike(#[CurrentUser] User $user, Post $post, EntityManagerInterface $entityManager): JsonResponse
|
||||
{
|
||||
$user->removeLikedPost($post);
|
||||
$entityManager->flush();
|
||||
|
||||
$likesCount = $post->getLikes()->count();
|
||||
|
||||
return new JsonResponse(['success' => true, 'likesCount' => $likesCount]);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -10,6 +10,9 @@ use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Creates fake data for testing purposes.
|
||||
*/
|
||||
class AppFixtures extends Fixture
|
||||
{
|
||||
public function __construct(
|
||||
@@ -20,10 +23,12 @@ class AppFixtures extends Fixture
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Dummy user
|
||||
$user = (new User())->setEmail('test@test.fr');
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, 'password'));
|
||||
$manager->persist($user);
|
||||
|
||||
// Posts and their species
|
||||
$faker = \Faker\Factory::create();
|
||||
for ($i = 0; $i < 20; ++$i) {
|
||||
$name = $faker->name();
|
||||
|
@@ -84,9 +84,16 @@ class Post
|
||||
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'related_post', fetch: 'EXTRA_LAZY')]
|
||||
private Collection $comments;
|
||||
|
||||
/**
|
||||
* @var Collection<int, User>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: User::class, inversedBy: 'liked_post')]
|
||||
private Collection $likes;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->comments = new ArrayCollection();
|
||||
$this->likes = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -256,4 +263,28 @@ class Post
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, User>
|
||||
*/
|
||||
public function getLikes(): Collection
|
||||
{
|
||||
return $this->likes;
|
||||
}
|
||||
|
||||
public function addLike(User $user): static
|
||||
{
|
||||
if (!$this->likes->contains($user)) {
|
||||
$this->likes->add($user);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeLike(User $user): static
|
||||
{
|
||||
$this->likes->removeElement($user);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
@@ -68,9 +68,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'author')]
|
||||
private Collection $comments;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Post>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Post::class, mappedBy: 'likes')]
|
||||
private Collection $liked_post;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->comments = new ArrayCollection();
|
||||
$this->liked_post = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -189,4 +196,31 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Post>
|
||||
*/
|
||||
public function getLikedPost(): Collection
|
||||
{
|
||||
return $this->liked_post;
|
||||
}
|
||||
|
||||
public function addLikedPost(Post $likedPost): static
|
||||
{
|
||||
if (!$this->liked_post->contains($likedPost)) {
|
||||
$this->liked_post->add($likedPost);
|
||||
$likedPost->addLike($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeLikedPost(Post $likedPost): static
|
||||
{
|
||||
if ($this->liked_post->removeElement($likedPost)) {
|
||||
$likedPost->removeLike($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
@@ -7,6 +7,9 @@ use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Translation\LocaleSwitcher;
|
||||
|
||||
/**
|
||||
* Reads the locale from the user session and change it for every request.
|
||||
*/
|
||||
final readonly class LocaleListener
|
||||
{
|
||||
public function __construct(private LocaleSwitcher $localeSwitcher)
|
||||
|
@@ -6,6 +6,9 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Only allow admins or comment owners to edit their comments.
|
||||
*/
|
||||
class CommentVoter extends Voter
|
||||
{
|
||||
public const EDIT = 'COMMENT_EDIT';
|
||||
|
@@ -4,6 +4,9 @@ namespace App\Service;
|
||||
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
|
||||
/**
|
||||
* Ensures that an image is safe.
|
||||
*/
|
||||
interface ImageSafetyServiceInterface
|
||||
{
|
||||
public function isValid(File $file): bool;
|
||||
|
@@ -8,6 +8,8 @@ use App\Entity\User;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Hashes plain text password in the API.
|
||||
*
|
||||
* @implements ProcessorInterface<User, User>
|
||||
*/
|
||||
final readonly class UserPasswordHasher implements ProcessorInterface
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{% set route = app.request.attributes.get('_route') %}
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
<ul class="pagination justify-content-center mt-4">
|
||||
<li class="page-item {{ page < 2 ? 'disabled' }}">
|
||||
<a class="page-link" href="{{ path(route, {'page': page - 1}) }}">{{ 'previous'|trans }}</a>
|
||||
</li>
|
||||
|
@@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{% block stylesheets %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
@@ -37,9 +38,9 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="navbar-nav ms-auto">
|
||||
<a class="nav-link" href="{{ path('app_setting_locale', { 'locale': 'en' }) }}">English</a>
|
||||
<a class="nav-link" href="{{ path('app_setting_locale', { 'locale': 'fr' }) }}">Français</a>
|
||||
<div class="d-flex">
|
||||
<a class="nav-link me-2" href="{{ path('app_setting_locale', { 'locale': 'en' }) }}">English</a>
|
||||
<a class="nav-link me-2" href="{{ path('app_setting_locale', { 'locale': 'fr' }) }}">Français</a>
|
||||
{% if app.user %}
|
||||
<a class="nav-link" href="{{ path('app_logout') }}">{{ app.user.email }} - {{ 'log_out'|trans }}</a>
|
||||
{% else %}
|
||||
|
@@ -2,9 +2,9 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
{{ comment.author.email }} le {{ comment.createdAt | date }}
|
||||
{{ 'commented_at'|trans({ email: comment.author.email, date: comment.createdAt|date }) }}
|
||||
{% if comment.createdAt != comment.editedAt %}
|
||||
(modifié le {{ comment.editedAt | date }})
|
||||
{{ 'edited_at'|trans({ date: comment.editedAt|date }) }}
|
||||
{% endif %}
|
||||
</h5>
|
||||
<p class="card-text">{{ comment.content }}</p>
|
||||
|
@@ -3,19 +3,44 @@
|
||||
{% block title %}{{ 'posts'|trans }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% if app.user %}
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end mb-2">
|
||||
<a href="{{ path('app_post_new') }}" class="btn btn-primary">{{ 'create_new_post'|trans }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="grid-4">
|
||||
{% for post in posts.iterator %}
|
||||
<div class="card">
|
||||
{% if post.image %}
|
||||
<img src="{{ app.request.baseUrl }}{{ vich_uploader_asset(post, 'imageFile') }}" class="card-img-top" alt="">
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><a href="{{ path('app_post_show', {id: post.id}) }}">{{ post.species ? post.species.vernacularName : 'post_undefined'|trans }}</a></h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">{{ post.foundDate | date("d/m/Y \\à H \\h") }}</h6>
|
||||
<p class="card-subtitle mb-2 text-muted">{{ post.latitude }}, {{ post.longitude }}, {{ post.altitude }}m</p>
|
||||
<p class="card-subtitle mb-2 text-muted">{{ post.latitude ?? 0 }}, {{ post.longitude ?? 0 }}, {{ post.altitude ?? 0 }}m</p>
|
||||
<p class="card-text">{{ post.commentary }}</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
28 ❤️
|
||||
<span class="likes-count">{{ post.likes.count() }}</span>
|
||||
{% if app.user %}
|
||||
<button class="like-toggle btn no-style {% if post.likes.contains(app.user) %}liked{% else %}not-liked{% endif %}"
|
||||
data-post-id="{{ post.id }}"
|
||||
data-like-url="{{ path('app_posts_like', {id: post.id}) }}"
|
||||
data-unlike-url="{{ path('app_posts_unlike', {id: post.id}) }}">
|
||||
{% if post.likes.contains(app.user) %}❤️{% else %}♡{% endif %}
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="like-toggle no-style not-liked">♡</span>
|
||||
{% endif %}
|
||||
{{ post.comments.count() }} 💬
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% include '_pagination.html.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{{ parent() }}
|
||||
<script src="{{ asset('js/like_toggle.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
@@ -34,7 +34,8 @@
|
||||
{% if post.image %}
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<td><img src="{{ vich_uploader_asset(post, 'imageFile') }}" class="img-thumbnail" width="200" alt=""></td>
|
||||
{# Vich doesn't prefix the path, as asset() would. #}
|
||||
<td><img src="{{ app.request.baseUrl }}{{ vich_uploader_asset(post, 'imageFile') }}" class="img-thumbnail" width="200" alt=""></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
|
@@ -161,6 +161,14 @@
|
||||
<source>save</source>
|
||||
<target>Save</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="zdhzvdha" resname="commented_at">
|
||||
<source>commented_at</source>
|
||||
<target>email on date</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="zdhzvdht" resname="edited_at">
|
||||
<source>edited_at</source>
|
||||
<target>edited on date</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
@@ -161,6 +161,14 @@
|
||||
<source>save</source>
|
||||
<target>Sauvegarder</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="zdhzvdha" resname="commented_at">
|
||||
<source>commented_at</source>
|
||||
<target>email le date</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="zdhzvdht" resname="edited_at">
|
||||
<source>edited_at</source>
|
||||
<target>édité le date</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
Reference in New Issue
Block a user