From af83758e1e185b68fe6ab59db2da2ba4320da653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Sat, 3 Jan 2026 21:15:19 +0100 Subject: [PATCH] Puzzles: Component reworked --- .../controllers/puzzle_search_controller.js | 74 ----- .../controllers/tomselect_sync_controller.js | 38 +++ src/Component/PuzzleSearch.php | 270 ++++++++++++++++++ src/Controller/PuzzlesController.php | 130 +-------- src/FormData/SearchPuzzleFormData.php | 50 ---- src/FormType/SearchPuzzleFormType.php | 104 ------- templates/_puzzle_load_more.html.twig | 25 -- templates/_puzzle_search_results.html.twig | 90 ------ .../_puzzle_search_results.stream.html.twig | 15 - templates/components/PuzzleSearch.html.twig | 193 +++++++++++++ templates/puzzles.html.twig | 63 +--- 11 files changed, 507 insertions(+), 545 deletions(-) delete mode 100644 assets/controllers/puzzle_search_controller.js create mode 100644 assets/controllers/tomselect_sync_controller.js create mode 100644 src/Component/PuzzleSearch.php delete mode 100644 src/FormData/SearchPuzzleFormData.php delete mode 100644 src/FormType/SearchPuzzleFormType.php delete mode 100644 templates/_puzzle_load_more.html.twig delete mode 100644 templates/_puzzle_search_results.html.twig delete mode 100644 templates/_puzzle_search_results.stream.html.twig create mode 100644 templates/components/PuzzleSearch.html.twig diff --git a/assets/controllers/puzzle_search_controller.js b/assets/controllers/puzzle_search_controller.js deleted file mode 100644 index 32f6ab42..00000000 --- a/assets/controllers/puzzle_search_controller.js +++ /dev/null @@ -1,74 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; - -export default class extends Controller { - static targets = ["form", "spinner"]; - - initialize() { - this._onConnect = this._onConnect.bind(this); - } - - connect() { - this.addEventListeners(); - this.element.addEventListener('autocomplete:connect', this._onConnect); - } - - disconnect() { - this.element.removeEventListener('autocomplete:connect', this._onConnect); - } - - _onConnect(event) { - event.detail.tomSelect.on('change', () => this.submitForm()); - } - - addEventListeners() { - document.addEventListener('turbo:frame-load', () => this.hideSpinner()) - - document.addEventListener('turbo:frame-load', (event) => { - const frame = event.target; - const form = this.formTarget; - - // Check if the frame load event is a result of the form submission - if (frame.id === 'search-results') { - const newUrl = new URL(form.action); - const formData = new FormData(form); - formData.forEach((value, key) => newUrl.searchParams.append(key, value)); - } - }); - - // Add listeners for general input and change events - this.formTarget.addEventListener('input', event => { - if (!event.target.matches('[role="combobox"], input[type="radio"], input[type="checkbox"], .tomselected')) { - this.debounceSubmitForm(); - } - }); - - // Immediate submission for radio buttons - this.formTarget.querySelectorAll('input[type=radio], input[type=checkbox]').forEach(input => { - input.addEventListener('change', () => this.submitForm()); - }); - } - - debounceSubmitForm() { - if (this.timeout) { - clearTimeout(this.timeout); - } - - this.timeout = setTimeout(() => { - this.showSpinner(); - this.formTarget.requestSubmit(); - }, 250); - } - - submitForm() { - this.showSpinner(); - this.formTarget.requestSubmit(); - } - - showSpinner() { - this.spinnerTarget.classList.remove('invisible'); - } - - hideSpinner() { - this.spinnerTarget.classList.add('invisible'); - } -} diff --git a/assets/controllers/tomselect_sync_controller.js b/assets/controllers/tomselect_sync_controller.js new file mode 100644 index 00000000..0a40d893 --- /dev/null +++ b/assets/controllers/tomselect_sync_controller.js @@ -0,0 +1,38 @@ +import { Controller } from '@hotwired/stimulus'; +import TomSelect from 'tom-select'; + +/** + * Bridges Tom Select with LiveComponent. + * + * Usage: + *
+ * + * + *
+ */ +export default class extends Controller { + static targets = ['hidden', 'select']; + + tomSelect = null; + + connect() { + this.tomSelect = new TomSelect(this.selectTarget, { + create: false, + sortField: { field: 'text', direction: 'asc' }, + plugins: ['dropdown_input'], + }); + + this.tomSelect.on('change', (value) => { + this.hiddenTarget.value = value || ''; + this.hiddenTarget.dispatchEvent(new Event('input', { bubbles: true })); + }); + } + + disconnect() { + if (this.tomSelect) { + this.tomSelect.destroy(); + } + } +} diff --git a/src/Component/PuzzleSearch.php b/src/Component/PuzzleSearch.php new file mode 100644 index 00000000..5b000893 --- /dev/null +++ b/src/Component/PuzzleSearch.php @@ -0,0 +1,270 @@ + */ + public array $puzzles = []; + + public int $totalCount = 0; + + private UserPuzzleStatuses $puzzleStatuses; + + /** @var array */ + private array $userRanking = []; + + /** @var array> */ + private array $tags = []; + + /** @var array */ + private array $manufacturers = []; + + /** @var array */ + private array $allTags = []; + + public function __construct( + private readonly SearchPuzzle $searchPuzzle, + private readonly GetUserPuzzleStatuses $getUserPuzzleStatuses, + private readonly GetRanking $getRanking, + private readonly RetrieveLoggedUserProfile $retrieveLoggedUserProfile, + private readonly GetTags $getTags, + private readonly GetManufacturers $getManufacturers, + private readonly CacheInterface $cache, + ) { + $this->puzzleStatuses = UserPuzzleStatuses::empty(); + } + + #[LiveAction] + public function changeSortBy(#[LiveArg] string $sort): void + { + $validSorts = ['most-solved', 'least-solved', 'a-z', 'z-a']; + if (in_array($sort, $validSorts, true)) { + $this->sortBy = $sort; + $this->displayLimit = self::LIMIT; + } + } + + #[LiveAction] + public function loadMore(): void + { + $this->displayLimit += self::LIMIT; + } + + #[LiveAction] + public function resetFilters(): void + { + $this->brandId = null; + $this->search = null; + $this->pieces = null; + $this->tagId = null; + $this->sortBy = 'most-solved'; + $this->displayLimit = self::LIMIT; + } + + public function onFilterUpdated(): void + { + $this->displayLimit = self::LIMIT; + } + + #[PostMount] + #[PreReRender] + public function loadData(): void + { + $this->loadPuzzles(); + $this->loadUserData(); + $this->loadFilterOptions(); + } + + private function loadPuzzles(): void + { + $piecesFilter = PiecesFilter::fromUserInput($this->pieces); + + if ($this->isDefaultSearch()) { + $cached = $this->getInitialPuzzlesFromCache(); + $this->puzzles = $cached['puzzles']; + $this->totalCount = $cached['count']; + + return; + } + + $this->totalCount = $this->searchPuzzle->countByUserInput( + $this->brandId, + $this->search, + $piecesFilter, + $this->tagId, + ); + + $this->puzzles = $this->searchPuzzle->byUserInput( + $this->brandId, + $this->search, + $piecesFilter, + $this->tagId, + $this->sortBy, + offset: 0, + limit: $this->displayLimit, + ); + } + + private function loadUserData(): void + { + $playerProfile = $this->retrieveLoggedUserProfile->getProfile(); + + $this->puzzleStatuses = $this->getUserPuzzleStatuses->byPlayerId($playerProfile?->playerId); + + if ($playerProfile !== null) { + $this->userRanking = $this->getRanking->allForPlayer($playerProfile->playerId); + } else { + $this->userRanking = []; + } + } + + private function loadFilterOptions(): void + { + $this->tags = $this->getTags->allGroupedPerPuzzle(); + $this->allTags = $this->getTags->all(); + $this->manufacturers = $this->getManufacturers->onlyApprovedOrAddedByPlayer(); + } + + /** + * @return array + */ + public function getPuzzles(): array + { + return $this->puzzles; + } + + public function getTotalCount(): int + { + return $this->totalCount; + } + + public function getRemainingCount(): int + { + return max(0, $this->totalCount - $this->displayLimit); + } + + public function hasMore(): bool + { + return $this->displayLimit < $this->totalCount; + } + + public function getPuzzleStatuses(): UserPuzzleStatuses + { + return $this->puzzleStatuses; + } + + /** + * @return array + */ + public function getUserRanking(): array + { + return $this->userRanking; + } + + /** + * @return array> + */ + public function getTags(): array + { + return $this->tags; + } + + /** + * @return array + */ + public function getManufacturers(): array + { + return $this->manufacturers; + } + + /** + * @return array + */ + public function getAllTags(): array + { + return $this->allTags; + } + + public function isUsingFilters(): bool + { + return $this->brandId !== null + || ($this->search !== null && $this->search !== '') + || $this->pieces !== null + || $this->tagId !== null; + } + + private function isDefaultSearch(): bool + { + return $this->brandId === null + && ($this->search === null || $this->search === '') + && $this->pieces === null + && $this->tagId === null + && $this->sortBy === 'most-solved' + && $this->displayLimit === self::LIMIT; + } + + /** + * @return array{puzzles: list, count: int} + */ + private function getInitialPuzzlesFromCache(): array + { + return $this->cache->get('initial_puzzles_v1', function (ItemInterface $item): array { + $item->expiresAfter(3600); + $pieces = PiecesFilter::fromUserInput(null); + + return [ + 'puzzles' => $this->searchPuzzle->byUserInput(null, null, $pieces, null, 'most-solved', 0), + 'count' => $this->searchPuzzle->countByUserInput(null, null, $pieces, null), + ]; + }); + } +} diff --git a/src/Controller/PuzzlesController.php b/src/Controller/PuzzlesController.php index 6d526ecf..6c6aa5c8 100644 --- a/src/Controller/PuzzlesController.php +++ b/src/Controller/PuzzlesController.php @@ -4,37 +4,12 @@ namespace SpeedPuzzling\Web\Controller; -use SpeedPuzzling\Web\FormData\SearchPuzzleFormData; -use SpeedPuzzling\Web\FormType\SearchPuzzleFormType; -use SpeedPuzzling\Web\Query\GetRanking; -use SpeedPuzzling\Web\Query\GetTags; -use SpeedPuzzling\Web\Query\GetUserPuzzleStatuses; -use SpeedPuzzling\Web\Query\SearchPuzzle; -use SpeedPuzzling\Web\Results\PiecesFilter; -use SpeedPuzzling\Web\Results\PuzzleOverview; -use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile; 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\Core\User\UserInterface; -use Symfony\Component\Security\Http\Attribute\CurrentUser; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; -use Symfony\UX\Turbo\TurboBundle; final class PuzzlesController extends AbstractController { - public function __construct( - readonly private SearchPuzzle $searchPuzzle, - readonly private GetUserPuzzleStatuses $getUserPuzzleStatuses, - readonly private GetRanking $getRanking, - readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile, - readonly private GetTags $getTags, - readonly private CacheInterface $cache, - ) { - } - #[Route( path: [ 'cs' => '/puzzle', @@ -46,109 +21,8 @@ public function __construct( ], name: 'puzzles', )] - public function __invoke(Request $request, #[CurrentUser] null|UserInterface $user): Response - { - $searchData = SearchPuzzleFormData::fromRequest($request); - - $searchForm = $this->createForm(SearchPuzzleFormType::class, $searchData); - $searchForm->handleRequest($request); - - $playerProfile = $this->retrieveLoggedUserProfile->getProfile(); - - $puzzleStatuses = $this->getUserPuzzleStatuses->byPlayerId($playerProfile?->playerId); - - $userRanking = []; - if ($playerProfile !== null) { - $userRanking = $this->getRanking->allForPlayer($playerProfile->playerId); - } - - $rawOffset = $request->query->get('offset'); - $offset = 0; - - if (is_numeric($rawOffset)) { - $offset = max(0, (int) $rawOffset); - } - - if ($this->isDefaultSearch($searchData, $offset)) { - $cached = $this->getInitialPuzzlesFromCache(); - $foundPuzzle = $cached['puzzles']; - $totalPuzzlesCount = $cached['count']; - } else { - $totalPuzzlesCount = $this->searchPuzzle->countByUserInput( - $searchData->brand, - $searchData->search, - PiecesFilter::fromUserInput($searchData->pieces), - $searchData->tag, - ); - - $offset = min($offset, $totalPuzzlesCount); - - $foundPuzzle = $this->searchPuzzle->byUserInput( - $searchData->brand, - $searchData->search, - PiecesFilter::fromUserInput($searchData->pieces), - $searchData->tag, - $searchData->sortBy, - $offset, - ); - } - - $templateName = 'puzzles.html.twig'; - - $search = $request->query->get('search'); - - if ((is_string($search) || $offset !== 0) && $request->headers->has('x-turbo-request-id')) { - $templateName = '_puzzle_search_results.html.twig'; - - if ($offset !== 0) { - $request->setRequestFormat(TurboBundle::STREAM_FORMAT); - $templateName = '_puzzle_search_results.stream.html.twig'; - } - } - - $limit = 20; - - $usingSearch = is_string($search); - - return $this->render($templateName, [ - 'puzzles' => $foundPuzzle, - 'total_puzzles_count' => $totalPuzzlesCount, - 'puzzle_statuses' => $puzzleStatuses, - 'ranking' => $userRanking, - 'tags' => $this->getTags->allGroupedPerPuzzle(), - 'search_form' => $searchForm, - 'form_data' => $searchData, - 'current_offset' => $offset, - 'next_offset' => $offset + $limit, - 'remaining' => max($totalPuzzlesCount - $limit - $offset, 0), - 'using_search' => $usingSearch, - ]); - } - - private function isDefaultSearch(SearchPuzzleFormData $searchData, int $offset): bool - { - return $searchData->brand === null - && $searchData->search === null - && $searchData->tag === null - && $searchData->pieces === null - && $searchData->sortBy === 'most-solved' - && $offset === 0; - } - - /** - * @return array{puzzles: array, count: int} - */ - private function getInitialPuzzlesFromCache(): array + public function __invoke(): Response { - return $this->cache->get('initial_puzzles_v1', function (ItemInterface $item): array { - $item->expiresAfter(3600); - - $pieces = PiecesFilter::fromUserInput(null); - - return [ - 'puzzles' => $this->searchPuzzle->byUserInput(null, null, $pieces, null, 'most-solved', 0), - 'count' => $this->searchPuzzle->countByUserInput(null, null, $pieces, null), - ]; - }); + return $this->render('puzzles.html.twig'); } } diff --git a/src/FormData/SearchPuzzleFormData.php b/src/FormData/SearchPuzzleFormData.php deleted file mode 100644 index 4a0edefd..00000000 --- a/src/FormData/SearchPuzzleFormData.php +++ /dev/null @@ -1,50 +0,0 @@ -query->get('brand'); - if (is_string($brand) && $brand !== '') { - $self->brand = $brand; - } - - $search = $request->query->get('search'); - if (is_string($search)) { - $self->search = $search; - } - - $tag = $request->query->get('tag'); - if (is_string($tag) && Uuid::isValid($tag)) { - $self->tag = $tag; - } - - $pieces = $request->query->get('pieces'); - if (is_string($pieces)) { - $self->pieces = $pieces; - } - - $self->sortBy = $request->query->get('sortBy', 'most-solved'); - - return $self; - } -} diff --git a/src/FormType/SearchPuzzleFormType.php b/src/FormType/SearchPuzzleFormType.php deleted file mode 100644 index 5f040f2b..00000000 --- a/src/FormType/SearchPuzzleFormType.php +++ /dev/null @@ -1,104 +0,0 @@ - - */ -final class SearchPuzzleFormType extends AbstractType -{ - public function __construct( - readonly private GetManufacturers $getManufacturers, - readonly private GetTags $getTags, - readonly private TranslatorInterface $translator, - ) { - } - - /** - * @param mixed[] $options - */ - public function buildForm(FormBuilderInterface $builder, array $options): void - { - $brandChoices = []; - foreach ($this->getManufacturers->onlyApprovedOrAddedByPlayer() as $manufacturer) { - $brandChoices["{$manufacturer->manufacturerName} ({$manufacturer->puzzlesCount})"] = $manufacturer->manufacturerId; - } - - $tagChoices = []; - foreach ($this->getTags->all() as $tag) { - $tagChoices[$tag->name] = $tag->tagId; - } - - $builder->add('brand', ChoiceType::class, [ - 'label' => 'forms.brand', - 'required' => false, - 'autocomplete' => true, - 'choices' => $brandChoices, - 'placeholder' => 'forms.brand', - 'empty_data' => '', - 'choice_translation_domain' => false, - ]); - - $builder->add('pieces', ChoiceType::class, [ - 'required' => false, - 'expanded' => true, - 'multiple' => false, - 'empty_data' => '', - 'choices' => [ - '' => $this->translator->trans('all_pieces'), - '1-499' => '1-499', - '500' => '500', - '501-999' => '501-999', - '1000' => '1000', - '1001+' => '1001+', - ], - 'choice_translation_domain' => false, - ]); - - $builder->add('tag', ChoiceType::class, [ - 'label' => 'forms.competition', - 'required' => false, - 'autocomplete' => true, - 'empty_data' => '', - 'choice_translation_domain' => false, - 'choices' => $tagChoices, - 'placeholder' => 'forms.competition', - ]); - - $builder->add('search', TextType::class, [ - 'required' => false, - 'empty_data' => '', - 'attr' => [ - 'placeholder' => 'forms.puzzle_search_placeholder', - ], - ]); - } - - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'data_class' => SearchPuzzleFormData::class, - 'method' => 'get', - 'csrf_protection' => false, - 'allow_extra_fields' => true, - ]); - } - - public function getBlockPrefix(): string - { - // Return an empty string to remove form name prefix from field names - return ''; - } -} diff --git a/templates/_puzzle_load_more.html.twig b/templates/_puzzle_load_more.html.twig deleted file mode 100644 index 2aa7a1c7..00000000 --- a/templates/_puzzle_load_more.html.twig +++ /dev/null @@ -1,25 +0,0 @@ -{% if next_offset < total_puzzles_count %} - {% set loadMoreParameters = app.request.query.all|merge({'offset': next_offset}) %} - -

- - - {{ 'loading'|trans }} - - - {{ 'load_more'|trans }} - - -
- {{ 'remaining'|trans({'%puzzle%': remaining }) }} -
- {{ 'back_to_top'|trans }} -

-{% endif %} diff --git a/templates/_puzzle_search_results.html.twig b/templates/_puzzle_search_results.html.twig deleted file mode 100644 index e1b9d598..00000000 --- a/templates/_puzzle_search_results.html.twig +++ /dev/null @@ -1,90 +0,0 @@ - -
-
-
- {{ 'total_found'|trans({'%count%': total_puzzles_count }) }} -
- - -
- - -
- - - {% if total_puzzles_count == 0 %} -

{{ 'puzzle_overview.missing_puzzle_info'|trans }}

- {% endif %} - - - {% for puzzle in puzzles %} - {{ include('_puzzle_item.html.twig', { - 'search': form_data.search, - }) }} - {% endfor %} - - - {% if total_puzzles_count == 0 %} -
- {{ 'filters.no_puzzle_matches_filters'|trans }} -
- {% endif %} - - - {{ include('_puzzle_load_more.html.twig') }} - -
- diff --git a/templates/_puzzle_search_results.stream.html.twig b/templates/_puzzle_search_results.stream.html.twig deleted file mode 100644 index 320f55b5..00000000 --- a/templates/_puzzle_search_results.stream.html.twig +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - diff --git a/templates/components/PuzzleSearch.html.twig b/templates/components/PuzzleSearch.html.twig new file mode 100644 index 00000000..1c299298 --- /dev/null +++ b/templates/components/PuzzleSearch.html.twig @@ -0,0 +1,193 @@ +
+
+
+
+ +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+ {{ 'total_found'|trans({'%count%': this.totalCount }) }} +
+ +
+ + {{ 'searching'|trans }} +
+
+ + +
+ + {% if this.totalCount == 0 %} +

{{ 'puzzle_overview.missing_puzzle_info'|trans }}

+
+ {{ 'filters.no_puzzle_matches_filters'|trans }} +
+ {% else %} +
+ {% for puzzle in this.puzzles %} + {{ include('_puzzle_item.html.twig', { + 'puzzle': puzzle, + 'search': this.search, + 'puzzle_statuses': this.puzzleStatuses, + 'ranking': this.userRanking, + 'tags': this.tags, + }) }} + {% endfor %} +
+ {% endif %} + + {% if this.hasMore %} +

+ +
+ {{ 'remaining'|trans({'%puzzle%': this.remainingCount }) }} +
+ {{ 'back_to_top'|trans }} +

+ {% endif %} +
diff --git a/templates/puzzles.html.twig b/templates/puzzles.html.twig index 5ca73941..a1aba5c4 100644 --- a/templates/puzzles.html.twig +++ b/templates/puzzles.html.twig @@ -8,7 +8,9 @@ {% endblock %} {% block robots %} - {% if using_search %} + {# LiveComponent handles URL params - check if any filter is applied via URL #} + {% set hasFilters = app.request.query.get('search') or app.request.query.get('brand') or app.request.query.get('pieces') or app.request.query.get('tag') %} + {% if hasFilters %} {% else %} @@ -43,62 +45,5 @@ {% block content %}

{{ 'puzzle_overview.title'|trans }}

-
-
-
-
- {{ form_errors(search_form.search) }} - {{ form_widget(search_form.search) }} -
- -
- {{ form_errors(search_form.brand) }} - {{ form_widget(search_form.brand) }} -
- -
- {{ form_errors(search_form.tag) }} - {{ form_widget(search_form.tag) }} -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - - - {{ form_rest(search_form) }} -
- - {{ include('_puzzle_search_results.html.twig') }} -
+ {{ component('PuzzleSearch') }} {% endblock %}