Compare commits

..

6 Commits

Author SHA1 Message Date
Clément FRÉVILLE 11bcd9a83d Add some comments
continuous-integration/drone/push Build is passing Details
11 months ago
Clément FRÉVILLE 71ddec420c Tweak style
continuous-integration/drone/push Build is passing Details
11 months ago
Clément FRÉVILLE 4f19bac707 Fix upload base URL behind a subdirectory
11 months ago
Clément FRÉVILLE ba82630d8a Disable service implementation
continuous-integration/drone/push Build is passing Details
11 months ago
Matis MAZINGUE b0507a44ea like (#19)
continuous-integration/drone/push Build is passing Details
11 months ago
Bastien OLLIER beca5f92da Merge pull request 'Geolocalize the current user position' (#18) from feature/geolocalize into main
continuous-integration/drone/push Build is passing Details
11 months ago

@ -30,7 +30,3 @@ services:
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones
when@prod:
services:
App\Service\ImageSafetyServiceInterface: '@App\Service\SightEngineImageSafetyService'

@ -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;
}
}

@ -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\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\UX\Turbo\TurboBundle; use Symfony\UX\Turbo\TurboBundle;
use Symfony\Component\HttpFoundation\JsonResponse;
/**
* CRUD on posts and comments.
*/
class PostController extends AbstractController class PostController extends AbstractController
{ {
/**
* The number of item on a page for the pagination.
*/
private const POSTS_PER_PAGE = 10; private const POSTS_PER_PAGE = 10;
#[Route('/', name: 'app_posts')] #[Route('/', name: 'app_posts')]
@ -35,7 +42,7 @@ class PostController extends AbstractController
} }
#[Route('/posts', name: 'app_post_index', methods: ['GET'])] #[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); 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()]); 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 Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Creates fake data for testing purposes.
*/
class AppFixtures extends Fixture class AppFixtures extends Fixture
{ {
public function __construct( public function __construct(
@ -20,10 +23,12 @@ class AppFixtures extends Fixture
public function load(ObjectManager $manager): void public function load(ObjectManager $manager): void
{ {
// Dummy user
$user = (new User())->setEmail('test@test.fr'); $user = (new User())->setEmail('test@test.fr');
$user->setPassword($this->passwordHasher->hashPassword($user, 'password')); $user->setPassword($this->passwordHasher->hashPassword($user, 'password'));
$manager->persist($user); $manager->persist($user);
// Posts and their species
$faker = \Faker\Factory::create(); $faker = \Faker\Factory::create();
for ($i = 0; $i < 20; ++$i) { for ($i = 0; $i < 20; ++$i) {
$name = $faker->name(); $name = $faker->name();

@ -84,9 +84,16 @@ class Post
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'related_post', fetch: 'EXTRA_LAZY')] #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'related_post', fetch: 'EXTRA_LAZY')]
private Collection $comments; private Collection $comments;
/**
* @var Collection<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class, inversedBy: 'liked_post')]
private Collection $likes;
public function __construct() public function __construct()
{ {
$this->comments = new ArrayCollection(); $this->comments = new ArrayCollection();
$this->likes = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@ -256,4 +263,28 @@ class Post
return $this; 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')] #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'author')]
private Collection $comments; private Collection $comments;
/**
* @var Collection<int, Post>
*/
#[ORM\ManyToMany(targetEntity: Post::class, mappedBy: 'likes')]
private Collection $liked_post;
public function __construct() public function __construct()
{ {
$this->comments = new ArrayCollection(); $this->comments = new ArrayCollection();
$this->liked_post = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@ -189,4 +196,31 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; 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\HttpKernel\KernelEvents;
use Symfony\Component\Translation\LocaleSwitcher; use Symfony\Component\Translation\LocaleSwitcher;
/**
* Reads the locale from the user session and change it for every request.
*/
final readonly class LocaleListener final readonly class LocaleListener
{ {
public function __construct(private LocaleSwitcher $localeSwitcher) 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\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
/**
* Only allow admins or comment owners to edit their comments.
*/
class CommentVoter extends Voter class CommentVoter extends Voter
{ {
public const EDIT = 'COMMENT_EDIT'; public const EDIT = 'COMMENT_EDIT';

@ -4,6 +4,9 @@ namespace App\Service;
use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\File;
/**
* Ensures that an image is safe.
*/
interface ImageSafetyServiceInterface interface ImageSafetyServiceInterface
{ {
public function isValid(File $file): bool; public function isValid(File $file): bool;

@ -8,6 +8,8 @@ use App\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/** /**
* Hashes plain text password in the API.
*
* @implements ProcessorInterface<User, User> * @implements ProcessorInterface<User, User>
*/ */
final readonly class UserPasswordHasher implements ProcessorInterface final readonly class UserPasswordHasher implements ProcessorInterface

@ -1,6 +1,6 @@
{% set route = app.request.attributes.get('_route') %} {% set route = app.request.attributes.get('_route') %}
<nav> <nav>
<ul class="pagination"> <ul class="pagination justify-content-center mt-4">
<li class="page-item {{ page < 2 ? 'disabled' }}"> <li class="page-item {{ page < 2 ? 'disabled' }}">
<a class="page-link" href="{{ path(route, {'page': page - 1}) }}">{{ 'previous'|trans }}</a> <a class="page-link" href="{{ path(route, {'page': page - 1}) }}">{{ 'previous'|trans }}</a>
</li> </li>

@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
{% block stylesheets %} {% 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 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 %} {% endblock %}
{% block javascripts %} {% block javascripts %}
@ -37,9 +38,9 @@
</ul> </ul>
</div> </div>
<div class="navbar-nav ms-auto"> <div class="d-flex">
<a class="nav-link" href="{{ path('app_setting_locale', { 'locale': 'en' }) }}">English</a> <a class="nav-link me-2" href="{{ path('app_setting_locale', { 'locale': 'en' }) }}">English</a>
<a class="nav-link" href="{{ path('app_setting_locale', { 'locale': 'fr' }) }}">Français</a> <a class="nav-link me-2" href="{{ path('app_setting_locale', { 'locale': 'fr' }) }}">Français</a>
{% if app.user %} {% if app.user %}
<a class="nav-link" href="{{ path('app_logout') }}">{{ app.user.email }} - {{ 'log_out'|trans }}</a> <a class="nav-link" href="{{ path('app_logout') }}">{{ app.user.email }} - {{ 'log_out'|trans }}</a>
{% else %} {% else %}

@ -2,9 +2,9 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title"> <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 %} {% if comment.createdAt != comment.editedAt %}
(modifié le {{ comment.editedAt | date }}) {{ 'edited_at'|trans({ date: comment.editedAt|date }) }}
{% endif %} {% endif %}
</h5> </h5>
<p class="card-text">{{ comment.content }}</p> <p class="card-text">{{ comment.content }}</p>

@ -3,19 +3,44 @@
{% block title %}{{ 'posts'|trans }}{% endblock %} {% block title %}{{ 'posts'|trans }}{% endblock %}
{% block body %} {% 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 %} {% for post in posts.iterator %}
<div class="card"> <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"> <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> <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> <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> <p class="card-text">{{ post.commentary }}</p>
</div> </div>
<div class="card-footer"> <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() }} 💬 {{ post.comments.count() }} 💬
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div>
{% include '_pagination.html.twig' %} {% include '_pagination.html.twig' %}
{% endblock %} {% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="{{ asset('js/like_toggle.js') }}"></script>
{% endblock %}

@ -34,7 +34,8 @@
{% if post.image %} {% if post.image %}
<tr> <tr>
<th>Image</th> <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> </tr>
{% endif %} {% endif %}
<tr> <tr>

@ -161,6 +161,14 @@
<source>save</source> <source>save</source>
<target>Save</target> <target>Save</target>
</trans-unit> </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> </body>
</file> </file>
</xliff> </xliff>

@ -161,6 +161,14 @@
<source>save</source> <source>save</source>
<target>Sauvegarder</target> <target>Sauvegarder</target>
</trans-unit> </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> </body>
</file> </file>
</xliff> </xliff>

Loading…
Cancel
Save