From c687b903bf814b7b131726eb84e99952dfaae92e Mon Sep 17 00:00:00 2001 From: anthodev Date: Wed, 3 Sep 2025 18:13:40 +0200 Subject: [PATCH] :sparkles: (report): Added upvote system --- .env | 2 +- assets/controllers/upvote-manager.js | 86 ++++++++++++++++++ assets/icons/ph/arrow-fat-up-duotone.svg | 1 + assets/styles/app.css | 12 ++- importmap.php | 50 ++++++----- src/DataFixtures/GameFixtures.php | 13 ++- src/DataFixtures/PublisherFixtures.php | 2 +- src/DataFixtures/ReportFixtures.php | 79 ++++++++++++++++ .../Report/ReportRepositoryInterface.php | 2 + .../Report/DoctrineReportRepository.php | 14 +++ ...troller.php => ReportCreateController.php} | 2 +- .../ReportIncreaseUpvoteCountController.php | 51 +++++++++++ .../reports/partials/_reports_list.html.twig | 21 ++++- .../reports/reports_for_game.html.twig | 5 ++ .../turbo/reports/increase_upvote.html.twig | 15 ++++ ...eportIncreaseUpvoteCountControllerTest.php | 90 +++++++++++++++++++ 16 files changed, 411 insertions(+), 34 deletions(-) create mode 100644 assets/controllers/upvote-manager.js create mode 100644 assets/icons/ph/arrow-fat-up-duotone.svg create mode 100644 src/DataFixtures/ReportFixtures.php rename src/Presentation/Controller/{CreateReportController.php => ReportCreateController.php} (95%) create mode 100644 src/Presentation/Controller/ReportIncreaseUpvoteCountController.php create mode 100644 src/Presentation/Template/turbo/reports/increase_upvote.html.twig create mode 100644 tests/Functional/Infrastructure/Controller/ReportIncreaseUpvoteCountControllerTest.php diff --git a/.env b/.env index 9cce603..9c00373 100644 --- a/.env +++ b/.env @@ -1,6 +1,6 @@ IMAGES_PREFIX=ns2-ubcr COMPOSE_PROJECT_NAME=ns2-ubcr -SYMFONY_VERSION=7.2.* +SYMFONY_VERSION=7.3.* PHP_VERSION=8.4 USER_ID=1000 diff --git a/assets/controllers/upvote-manager.js b/assets/controllers/upvote-manager.js new file mode 100644 index 0000000..0dd3b7e --- /dev/null +++ b/assets/controllers/upvote-manager.js @@ -0,0 +1,86 @@ +class UpvoteManager { + constructor() { + this.storageKey = 'ns2ubcr_upvoted_reports'; + this.init(); + } + + init() { + this.markAlreadyUpvoted(); + this.attachEventListeners(); + } + + getUpvotedReports() { + const stored = localStorage.getItem(this.storageKey); + return stored ? JSON.parse(stored) : []; + } + + addUpvotedReport(reportId) { + const upvoted = this.getUpvotedReports(); + if (!upvoted.includes(reportId)) { + upvoted.push(reportId); + localStorage.setItem(this.storageKey, JSON.stringify(upvoted)); + } + } + + hasUpvoted(reportId) { + return this.getUpvotedReports().includes(reportId); + } + + markAlreadyUpvoted() { + document.querySelectorAll('[data-report-upvote]').forEach(element => { + const reportId = element.getAttribute('data-report-id'); + if (this.hasUpvoted(reportId)) { + this.disableUpvoteElement(element, reportId); + } + }); + } + + disableUpvoteElement(element, reportId) { + const link = element.querySelector('a'); + if (link) { + const span = document.createElement('span'); + span.innerHTML = link.innerHTML; + span.className = link.className.replace('group-hover:text-green-700', 'text-gray-400'); + span.classList.remove('cursor-pointer', 'group') + span.classList.add('cursor-not-allowed'); + + link.parentNode.replaceChild(span, link); + + const icon = span.querySelector('div:last-child'); + if (icon) { + icon.remove(); + } + } + } + + attachEventListeners() { + document.addEventListener('turbo:stream-connected', (event) => { + setTimeout(() => this.markAlreadyUpvoted(), 100); + }); + + document.addEventListener('click', (event) => { + const upvoteLink = event.target.closest('[data-report-upvote] a'); + if (upvoteLink) { + const container = upvoteLink.closest('[data-report-upvote]'); + const reportId = container.getAttribute('data-report-id'); + + if (this.hasUpvoted(reportId)) { + event.preventDefault(); + event.stopPropagation(); + return false; + } + + this.addUpvotedReport(reportId); + this.disableUpvoteElement(container, reportId); + } + }); + } +} + +document.addEventListener('DOMContentLoaded', () => { + new UpvoteManager(); +}); + +document.addEventListener('turbo:load', () => { + new UpvoteManager(); +}); diff --git a/assets/icons/ph/arrow-fat-up-duotone.svg b/assets/icons/ph/arrow-fat-up-duotone.svg new file mode 100644 index 0000000..ff3f374 --- /dev/null +++ b/assets/icons/ph/arrow-fat-up-duotone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/styles/app.css b/assets/styles/app.css index 9549e42..9ab5a45 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -13,6 +13,14 @@ body { scroll-behavior: smooth; } +[data-report-upvote].upvoted .text-green-200 { + @apply text-gray-400; +} + +[data-report-upvote].upvoted .group-hover\:text-green-700:hover { + @apply text-gray-400; +} + /* Optimize animations for better performance */ @media (prefers-reduced-motion: reduce) { *, @@ -90,9 +98,9 @@ body { } -.line-clamp-3 { +.line-clamp-5 { display: -webkit-box; - -webkit-line-clamp: 3; + -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; } diff --git a/importmap.php b/importmap.php index 48055cb..7961a82 100644 --- a/importmap.php +++ b/importmap.php @@ -12,37 +12,41 @@ * The "importmap:require" command can be used to add new entries to this file. */ return [ - "app" => [ - "path" => "./assets/app.js", - "entrypoint" => true, + 'app' => [ + 'path' => './assets/app.js', + 'entrypoint' => true, ], - "@symfony/stimulus-bundle" => [ - "path" => "./vendor/symfony/stimulus-bundle/assets/dist/loader.js", + 'upvote-manager' => [ + 'path' => './assets/controllers/upvote-manager.js', + 'entrypoint' => true, ], - "@hotwired/stimulus" => [ - "version" => "3.2.2", + '@symfony/stimulus-bundle' => [ + 'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', ], - "daisyui" => [ - "version" => "5.0.50", + '@hotwired/stimulus' => [ + 'version' => '3.2.2', ], - "daisyui/daisyui.min.css" => [ - "version" => "5.0.50", - "type" => "css", + 'daisyui' => [ + 'version' => '5.1.6', ], - "@orchidjs/sifter" => [ - "version" => "1.1.0", + 'daisyui/daisyui.min.css' => [ + 'version' => '5.1.6', + 'type' => 'css', ], - "@orchidjs/unicode-variants" => [ - "version" => "1.1.2", + '@orchidjs/sifter' => [ + 'version' => '1.1.0', ], - "tom-select" => [ - "version" => "2.4.3", + '@orchidjs/unicode-variants' => [ + 'version' => '1.1.2', ], - "tom-select/dist/css/tom-select.default.min.css" => [ - "version" => "2.4.3", - "type" => "css", + 'tom-select' => [ + 'version' => '2.4.3', ], - "@hotwired/turbo" => [ - "version" => "7.3.0", + 'tom-select/dist/css/tom-select.default.min.css' => [ + 'version' => '2.4.3', + 'type' => 'css', + ], + '@hotwired/turbo' => [ + 'version' => '8.0.13', ], ]; diff --git a/src/DataFixtures/GameFixtures.php b/src/DataFixtures/GameFixtures.php index 0c24e2a..314ec87 100644 --- a/src/DataFixtures/GameFixtures.php +++ b/src/DataFixtures/GameFixtures.php @@ -13,6 +13,9 @@ class GameFixtures extends Fixture implements DependentFixtureInterface { + public const string FIRST_GAME_SLUG = 'splinter-turtle'; + public const string RANDOM_GAME_SLUG = 'random-game-'; + /** * @throws \DateMalformedStringException */ @@ -28,7 +31,7 @@ public function load(ObjectManager $manager): void $definedGame = GameFactory::create( name: 'Splinter Turtle', - slug: 'splinter-turtle', + slug: self::FIRST_GAME_SLUG, description: $faker->text, releaseDate: (string) new \DateTime()->getTimestamp(), imageCover: $faker->imageUrl(), @@ -49,9 +52,13 @@ public function load(ObjectManager $manager): void ); $manager->persist($game); + + $this->addReference(self::RANDOM_GAME_SLUG.$i, $game); } $manager->flush(); + + $this->addReference(self::FIRST_GAME_SLUG, $definedGame); } /** @@ -59,6 +66,8 @@ public function load(ObjectManager $manager): void */ public function getDependencies(): array { - return [PublisherFixtures::class]; + return [ + PublisherFixtures::class, + ]; } } diff --git a/src/DataFixtures/PublisherFixtures.php b/src/DataFixtures/PublisherFixtures.php index f705bc7..35a22a2 100644 --- a/src/DataFixtures/PublisherFixtures.php +++ b/src/DataFixtures/PublisherFixtures.php @@ -11,7 +11,7 @@ class PublisherFixtures extends Fixture { - public const PUBLISHER_TEST = 'publisher_test'; + public const string PUBLISHER_TEST = 'publisher_test'; public function load(ObjectManager $manager): void { diff --git a/src/DataFixtures/ReportFixtures.php b/src/DataFixtures/ReportFixtures.php new file mode 100644 index 0000000..62d9c0a --- /dev/null +++ b/src/DataFixtures/ReportFixtures.php @@ -0,0 +1,79 @@ +getReference(GameFixtures::FIRST_GAME_SLUG, Game::class); + + $testReport = ReportFactory::create( + game: $game, + isSwitch2Edition: $faker->boolean, + is60FpsPortable: $faker->boolean, + hasStableFrameratePortable: $faker->boolean, + hasResolutionImprovedPortable: $faker->boolean, + isNativeResolutionPortable: $faker->boolean, + is60FpsDocked: $faker->boolean, + hasStableFramerateDocked: $faker->boolean, + hasResolutionImprovedDocked: $faker->boolean, + hasImprovedLoadingTimes: $faker->boolean, + gameStatus: ReportGameStatusEnum::GREAT, + upvoteCount: $faker->numberBetween(0, 100), + ); + + $manager->persist($testReport); + + for ($i = 0; $i < 10; ++$i) { + $randomGame = $this->getReference( + GameFixtures::RANDOM_GAME_SLUG.$faker->numberBetween(0, 2), + Game::class, + ); + + /** @var ReportGameStatusEnum $randomStatus */ + $randomStatus = $faker->randomElement(ReportGameStatusEnum::cases()); + + $report = ReportFactory::create( + game: $randomGame, + isSwitch2Edition: $faker->boolean, + is60FpsPortable: $faker->boolean, + hasStableFrameratePortable: $faker->boolean, + hasResolutionImprovedPortable: $faker->boolean, + isNativeResolutionPortable: $faker->boolean, + is60FpsDocked: $faker->boolean, + hasStableFramerateDocked: $faker->boolean, + hasResolutionImprovedDocked: $faker->boolean, + hasImprovedLoadingTimes: $faker->boolean, + gameStatus: $randomStatus, + upvoteCount: $faker->numberBetween(0, 100), + ); + + $manager->persist($report); + } + + $manager->flush(); + } + + /** + * @return string[] + */ + public function getDependencies(): array + { + return [ + GameFixtures::class, + ]; + } +} diff --git a/src/Domain/Repository/Report/ReportRepositoryInterface.php b/src/Domain/Repository/Report/ReportRepositoryInterface.php index 8c58cdb..ccc38c0 100644 --- a/src/Domain/Repository/Report/ReportRepositoryInterface.php +++ b/src/Domain/Repository/Report/ReportRepositoryInterface.php @@ -23,4 +23,6 @@ interface ReportRepositoryInterface * @return Report[] */ public function getAllVisibleReportsForGame(string $gameId): array; + + public function findMostUpvotedReportForGame(string $gameId): ?Report; } diff --git a/src/Infrastructure/Persistence/Doctrine/Report/DoctrineReportRepository.php b/src/Infrastructure/Persistence/Doctrine/Report/DoctrineReportRepository.php index ac1e942..a871a08 100644 --- a/src/Infrastructure/Persistence/Doctrine/Report/DoctrineReportRepository.php +++ b/src/Infrastructure/Persistence/Doctrine/Report/DoctrineReportRepository.php @@ -32,4 +32,18 @@ public function getAllVisibleReportsForGame(string $gameId): array /** @var Report[] */ return $qb->getResult(); } + + public function findMostUpvotedReportForGame(string $gameId): ?Report + { + $qb = $this->createQueryBuilder('r') + ->where('r.isVisible = true') + ->andWhere('r.game = :game') + ->setParameter('game', $gameId) + ->orderBy('r.upvoteCount', 'DESC') + ->addOrderBy('r.createdAt', 'DESC') + ->setMaxResults(1); + + /** @var Report|null */ + return $qb->getQuery()->getOneOrNullResult(); + } } diff --git a/src/Presentation/Controller/CreateReportController.php b/src/Presentation/Controller/ReportCreateController.php similarity index 95% rename from src/Presentation/Controller/CreateReportController.php rename to src/Presentation/Controller/ReportCreateController.php index 3514de2..834b11a 100644 --- a/src/Presentation/Controller/CreateReportController.php +++ b/src/Presentation/Controller/ReportCreateController.php @@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -class CreateReportController extends AbstractController +class ReportCreateController extends AbstractController { #[Route(path: '/reports/new', name: 'create_report', methods: [Request::METHOD_POST])] public function __invoke( diff --git a/src/Presentation/Controller/ReportIncreaseUpvoteCountController.php b/src/Presentation/Controller/ReportIncreaseUpvoteCountController.php new file mode 100644 index 0000000..a5d9f07 --- /dev/null +++ b/src/Presentation/Controller/ReportIncreaseUpvoteCountController.php @@ -0,0 +1,51 @@ + Requirement::UUID], + methods: [Request::METHOD_PATCH], + ), + ] + public function __invoke( + Report $report, + Request $request, + ReportRepositoryInterface $reportRepository, + ): Response { + $report->increaseUpvoteCount(); + $reportRepository->save($report); + + if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + + /** @var string $currentReportGameId */ + $currentReportGameId = $report->getGame()->getId(); + $mostUpvotedReport = $reportRepository->findMostUpvotedReportForGame($currentReportGameId); + + /** @var string $mostUpvotedReportId */ + $mostUpvotedReportId = $mostUpvotedReport?->getId(); + + return $this->render('@turbo/reports/increase_upvote.html.twig', [ + 'reportId' => $report->getId(), + 'reportUpvoteCount' => $report->getUpvoteCount(), + 'mostUpvotedReportId' => $mostUpvotedReportId, + ]); + } + + return $this->json(['message' => 'Upvote count increased']); + } +} diff --git a/src/Presentation/Template/reports/partials/_reports_list.html.twig b/src/Presentation/Template/reports/partials/_reports_list.html.twig index d2f9250..d42e402 100644 --- a/src/Presentation/Template/reports/partials/_reports_list.html.twig +++ b/src/Presentation/Template/reports/partials/_reports_list.html.twig @@ -90,10 +90,23 @@ -
{{ report.upvoteCount }}
- {% if loop.first %} -
{{ ux_icon('ph:star-fill') }}
- {% endif %} + +
+ +
{{ report.upvoteCount }}
+
+ {{ ux_icon('ph:arrow-fat-up-duotone') }} +
+
+
+ {% if loop.first %} +
{{ ux_icon('ph:star-fill') }}
+ {% endif %} +
{% endfor %} diff --git a/src/Presentation/Template/reports/reports_for_game.html.twig b/src/Presentation/Template/reports/reports_for_game.html.twig index 43f7cd8..4e73a71 100644 --- a/src/Presentation/Template/reports/reports_for_game.html.twig +++ b/src/Presentation/Template/reports/reports_for_game.html.twig @@ -8,3 +8,8 @@ {% include '@reports/partials/_reports_list_legends.html.twig' %} {% include '@reports/partials/_reports_list.html.twig' %} {% endblock %} + +{% block javascripts %} + {{ parent() }} + {{ importmap('upvote-manager') }} +{% endblock %} diff --git a/src/Presentation/Template/turbo/reports/increase_upvote.html.twig b/src/Presentation/Template/turbo/reports/increase_upvote.html.twig new file mode 100644 index 0000000..bec1734 --- /dev/null +++ b/src/Presentation/Template/turbo/reports/increase_upvote.html.twig @@ -0,0 +1,15 @@ + + + diff --git a/tests/Functional/Infrastructure/Controller/ReportIncreaseUpvoteCountControllerTest.php b/tests/Functional/Infrastructure/Controller/ReportIncreaseUpvoteCountControllerTest.php new file mode 100644 index 0000000..695e24e --- /dev/null +++ b/tests/Functional/Infrastructure/Controller/ReportIncreaseUpvoteCountControllerTest.php @@ -0,0 +1,90 @@ +reportRepository = static::$client->getContainer()->get(ReportRepositoryInterface::class); + /** @var GameRepositoryInterface */ + $this->gameRepository = static::$client->getContainer()->get(GameRepositoryInterface::class); +}); + +it('can increase upvote count successfully', function () { + // Given + $game = $this->gameRepository->getOneBySlugEnabledGame(GameFixtures::FIRST_GAME_SLUG); + $report = $game->getReports()->first(); + + // When + static::$client->request( + Request::METHOD_PATCH, + "/reports/{$report->getId()}/increase_upvote" + ); + + // Then + $response = static::$client->getResponse(); + expect($response->getStatusCode())->toBe(Response::HTTP_OK); + + $content = json_decode($response->getContent(), true); + expect($content)->toBe(['message' => 'Upvote count increased']); +}); + +it('handles turbo stream format correctly', function () { + // Given + $game = $this->gameRepository->getOneBySlugEnabledGame(GameFixtures::FIRST_GAME_SLUG); + $report = $game->getReports()->first(); + + // When + static::$client->request( + Request::METHOD_PATCH, + "/reports/{$report->getId()}/increase_upvote", + [], + [], + [ + 'HTTP_ACCEPT' => 'text/vnd.turbo-stream.html', + ] + ); + + // Then + $response = static::$client->getResponse(); + expect($response->getStatusCode()) + ->toBe(Response::HTTP_OK) + ->and($response->getContent())->toContain('turbo-stream'); +}); + +it('returns 404 for invalid uuid', function () { + // Given + $invalidReportId = 'invalid-uuid'; + + // When + static::$client->request( + Request::METHOD_PATCH, + "/reports/{$invalidReportId}/increase_upvote" + ); + + // Then + $response = static::$client->getResponse(); + expect($response->getStatusCode())->toBe(Response::HTTP_NOT_FOUND); +}); + +it('returns 405 for wrong http method', function () { + // Given + $reportId = '123e4567-e89b-12d3-a456-426614174000'; + + // When + static::$client->request( + Request::METHOD_GET, + "/reports/{$reportId}/increase_upvote" + ); + + // Then + $response = static::$client->getResponse(); + expect($response->getStatusCode())->toBe(Response::HTTP_METHOD_NOT_ALLOWED); +});