diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 57fb23d5d..e9f9b0dc9 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -37,6 +37,7 @@ @import 'components/figure_image'; @import 'components/figure_lightbox'; @import 'components/post'; +@import 'components/search'; @import 'components/subject'; @import 'components/login'; @import 'components/modlog'; diff --git a/assets/styles/components/_search.scss b/assets/styles/components/_search.scss new file mode 100644 index 000000000..d179dd372 --- /dev/null +++ b/assets/styles/components/_search.scss @@ -0,0 +1,24 @@ +.search-container { + background: var(--kbin-input-bg); + border: var(--kbin-input-border); + border-radius: var(--kbin-rounded-edges-radius) !important; + + input.form-control { + border-radius: 0 !important; + border: none; + background: transparent; + margin: 0 .5em; + padding: .5rem .25rem; + } + + button { + border-radius: 0 var(--kbin-rounded-edges-radius) var(--kbin-rounded-edges-radius) 0 !important; + border: 0; + padding: 1rem 0.5rem; + + &:not(:hover) { + background: var(--kbin-input-bg); + color: var(--kbin-input-text-color) !important; + } + } +} diff --git a/assets/styles/layout/_forms.scss b/assets/styles/layout/_forms.scss index 8d42c269d..e0e606d7a 100644 --- a/assets/styles/layout/_forms.scss +++ b/assets/styles/layout/_forms.scss @@ -525,3 +525,9 @@ div.input-box { border-radius: var(--kbin-rounded-edges-radius) !important; } } + +.form-control { + display: block; + width: 100%; + +} diff --git a/assets/styles/layout/_layout.scss b/assets/styles/layout/_layout.scss index 789c8f20c..c7b387c12 100644 --- a/assets/styles/layout/_layout.scss +++ b/assets/styles/layout/_layout.scss @@ -214,7 +214,9 @@ figure { code, .ts-control > [data-value].item, .image-preview-container { - border-radius: var(--kbin-rounded-edges-radius) !important; + &:not(.ignore-edges) { + border-radius: var(--kbin-rounded-edges-radius) !important; + } } .ts-wrapper { @@ -361,6 +363,12 @@ figure { gap: .25rem; } +@include media-breakpoint-down(lg) { + .flex.mobile { + display: block; + } +} + .flex-wrap { flex-wrap: wrap; } diff --git a/assets/styles/layout/_section.scss b/assets/styles/layout/_section.scss index 7f44e8166..47e75a8eb 100644 --- a/assets/styles/layout/_section.scss +++ b/assets/styles/layout/_section.scss @@ -68,11 +68,3 @@ color: var(--kbin-alert-danger-text-color); } } - -.page-search { - .section--top { - button { - padding: 1rem 1.5rem; - } - } -} \ No newline at end of file diff --git a/src/Controller/Api/Search/SearchRetrieveApi.php b/src/Controller/Api/Search/SearchRetrieveApi.php index c92b4a0ec..f2499fcad 100644 --- a/src/Controller/Api/Search/SearchRetrieveApi.php +++ b/src/Controller/Api/Search/SearchRetrieveApi.php @@ -103,6 +103,27 @@ class SearchRetrieveApi extends BaseApi required: true, schema: new OA\Schema(type: 'string') )] + #[OA\Parameter( + name: 'authorId', + description: 'User id of the author', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'magazineId', + description: 'Id of the magazine', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'type', + description: 'The type of content', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string', enum: ['', 'entry', 'post']) + )] #[OA\Tag(name: 'search')] public function __invoke( SearchManager $manager, @@ -122,8 +143,16 @@ public function __invoke( $page = $this->getPageNb($request); $perPage = self::constrainPerPage($request->get('perPage', SearchRepository::PER_PAGE)); + $authorIdRaw = $request->get('authorId'); + $authorId = null === $authorIdRaw ? null : \intval($authorIdRaw); + $magazineIdRaw = $request->get('magazineId'); + $magazineId = null === $magazineIdRaw ? null : \intval($magazineIdRaw); + $type = $request->get('type'); + if ('entry' !== $type && 'post' !== $type && null !== $type) { + throw new BadRequestHttpException(); + } - $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage); + $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage, authorId: $authorId, magazineId: $magazineId, specificType: $type); $dtos = []; foreach ($items->getCurrentPageResults() as $value) { \assert($value instanceof ContentInterface); diff --git a/src/Controller/Api/User/UserRetrieveApi.php b/src/Controller/Api/User/UserRetrieveApi.php index 97ce14fef..ab95009af 100644 --- a/src/Controller/Api/User/UserRetrieveApi.php +++ b/src/Controller/Api/User/UserRetrieveApi.php @@ -275,6 +275,18 @@ public function settings( in: 'query', schema: new OA\Schema(type: 'string', default: UserRepository::USERS_ALL, enum: UserRepository::USERS_OPTIONS) )] + #[OA\Parameter( + name: 'q', + description: 'The term to search for', + in: 'query', + schema: new OA\Schema(type: 'string') + )] + #[OA\Parameter( + name: 'withAbout', + description: 'Only include users with a filled in profile', + in: 'query', + schema: new OA\Schema(type: 'boolean') + )] #[OA\Tag(name: 'user')] public function collection( UserRepository $userRepository, @@ -286,11 +298,15 @@ public function collection( $request = $this->request->getCurrentRequest(); $group = $request->get('group', UserRepository::USERS_ALL); + $withAboutRaw = $request->get('withAbout'); + $withAbout = null === $withAboutRaw ? false : \boolval($withAboutRaw); - $users = $userRepository->findWithAboutPaginated( + $users = $userRepository->findPaginated( $this->getPageNb($request), + $withAbout, $group, - $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)) + $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)), + $request->get('q'), ); $dtos = []; diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php index c7451b83e..1bbe8ea38 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/SearchController.php @@ -5,8 +5,10 @@ namespace App\Controller; use App\ActivityPub\ActorHandle; +use App\DTO\SearchDto; use App\Entity\Magazine; use App\Entity\User; +use App\Form\SearchType; use App\Message\ActivityPub\Inbox\ActivityMessage; use App\Service\ActivityPub\ApHttpClient; use App\Service\ActivityPubManager; @@ -33,52 +35,63 @@ public function __construct( public function __invoke(Request $request): Response { - $query = $request->query->get('q') ? trim($request->query->get('q')) : null; - - if (!$query) { - return $this->render( - 'search/front.html.twig', - [ - 'objects' => [], - 'results' => [], - 'q' => '', - ] - ); - } - - $this->logger->debug('searching for {query}', ['query' => $query]); - - $objects = []; + $dto = new SearchDto(); + $form = $this->createForm(SearchType::class, $dto, ['csrf_protection' => false]); + try { + $form = $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var SearchDto $dto */ + $dto = $form->getData(); + $query = $dto->q; + $this->logger->debug('searching for {query}', ['query' => $query]); + + $objects = []; + + // looking up handles (users and mags) + if (str_contains($query, '@') && $this->federatedSearchAllowed()) { + if ($handle = ActorHandle::parse($query)) { + $this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]); + $objects = array_merge($objects, $this->lookupHandle($handle)); + } else { + $this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]); + } + } - // looking up handles (users and mags) - if (str_contains($query, '@') && $this->federatedSearchAllowed()) { - if ($handle = ActorHandle::parse($query)) { - $this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]); - $objects = array_merge($objects, $this->lookupHandle($handle)); - } else { - $this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]); - } - } + // looking up object by AP id (i.e. urls) + if (false !== filter_var($query, FILTER_VALIDATE_URL)) { + $objects = $this->manager->findByApId($query); + if (!$objects) { + $body = $this->apHttpClient->getActivityObject($query, false); + $this->bus->dispatch(new ActivityMessage($body)); + } + } - // looking up object by AP id (i.e. urls) - if (false !== filter_var($query, FILTER_VALIDATE_URL)) { - $objects = $this->manager->findByApId($query); - if (!$objects) { - $body = $this->apHttpClient->getActivityObject($query, false); - $this->bus->dispatch(new ActivityMessage($body)); + $user = $this->getUser(); + $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request), authorId: $dto->user?->getId(), magazineId: $dto->magazine?->getId(), specificType: $dto->type); + + $this->logger->debug('results: {num}', ['num' => $res->count()]); + + return $this->render( + 'search/front.html.twig', + [ + 'objects' => $objects, + 'results' => $this->overviewManager->buildList($res), + 'pagination' => $res, + 'form' => $form->createView(), + 'q' => $query, + ] + ); } + } catch (\Exception $e) { + $this->logger->error($e); } - $user = $this->getUser(); - $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request)); - return $this->render( 'search/front.html.twig', [ - 'objects' => $objects, - 'results' => $this->overviewManager->buildList($res), - 'pagination' => $res, - 'q' => $request->query->get('q'), + 'objects' => [], + 'results' => [], + 'form' => $form->createView(), ] ); } diff --git a/src/DTO/SearchDto.php b/src/DTO/SearchDto.php index c9070f0df..de26533c6 100644 --- a/src/DTO/SearchDto.php +++ b/src/DTO/SearchDto.php @@ -4,7 +4,13 @@ namespace App\DTO; +use App\Entity\Magazine; +use App\Entity\User; + class SearchDto { - public string $val; + public string $q; + public ?string $type = null; + public ?User $user = null; + public ?Magazine $magazine = null; } diff --git a/src/Form/SearchType.php b/src/Form/SearchType.php new file mode 100644 index 000000000..5d7410e50 --- /dev/null +++ b/src/Form/SearchType.php @@ -0,0 +1,36 @@ +setMethod('GET') + ->add('q', TextType::class, [ + 'required' => true, + 'attr' => [ + 'placeholder' => 'type_search_term', + ], + ]) + ->add('magazine', MagazineAutocompleteType::class, ['required' => false]) + ->add('user', UserAutocompleteType::class, ['required' => false]) + ->add('type', ChoiceType::class, [ + 'choices' => [ + 'search_type_all' => null, + 'search_type_entry' => 'entry', + 'search_type_post' => 'post', + ], + ]); + } +} diff --git a/src/Form/Type/UserAutocompleteType.php b/src/Form/Type/UserAutocompleteType.php new file mode 100644 index 000000000..d1cc01909 --- /dev/null +++ b/src/Form/Type/UserAutocompleteType.php @@ -0,0 +1,59 @@ +setDefaults([ + 'class' => User::class, + 'choice_label' => 'username', + 'placeholder' => 'select_user', + 'filter_query' => function (QueryBuilder $qb, string $query) { + if ($currentUser = $this->security->getUser()) { + $qb + ->andWhere( + \sprintf( + 'entity.id NOT IN (SELECT IDENTITY(ub.blocked) FROM %s ub WHERE ub.blocker = :user)', + UserBlock::class, + ) + ) + ->setParameter('user', $currentUser); + } + + if (!$query) { + return; + } + + $qb->andWhere('entity.username LIKE :filter') + ->andWhere('entity.visibility = :visibility') + ->setParameter('filter', '%'.$query.'%') + ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) + ; + }, + ]); + } + + public function getParent(): string + { + return BaseEntityAutocompleteType::class; + } +} diff --git a/src/Repository/SearchRepository.php b/src/Repository/SearchRepository.php index a5d89e5eb..84a412133 100644 --- a/src/Repository/SearchRepository.php +++ b/src/Repository/SearchRepository.php @@ -13,6 +13,7 @@ use Doctrine\ORM\EntityManagerInterface; use Pagerfanta\Pagerfanta; use Pagerfanta\PagerfantaInterface; +use Psr\Log\LoggerInterface; class SearchRepository { @@ -21,6 +22,7 @@ class SearchRepository public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ContentPopulationTransformer $transformer, + private readonly LoggerInterface $logger, ) { } @@ -79,10 +81,15 @@ public function findBoosts(int $page, User $user): PagerfantaInterface return $pagerfanta; } - public function search(?User $searchingUser, string $query, int $page = 1): PagerfantaInterface + /** + * @param 'entry'|'post'|null $specificType + */ + public function search(?User $searchingUser, string $query, int $page = 1, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null): PagerfantaInterface { + $authorWhere = null !== $authorId ? 'AND e.user_id = :authorId' : ''; + $magazineWhere = null !== $magazineId ? 'AND e.magazine_id = :magazineId' : ''; $conn = $this->entityManager->getConnection(); - $sql = "SELECT e.id, e.created_at, e.visibility, 'entry' AS type FROM entry e + $sqlEntry = "SELECT e.id, e.created_at, e.visibility, 'entry' AS type FROM entry e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE (body_ts @@ plainto_tsquery( :query ) = true OR title_ts @@ plainto_tsquery( :query ) = true) @@ -91,6 +98,7 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_id = e.id) + $authorWhere $magazineWhere UNION ALL SELECT e.id, e.created_at, e.visibility, 'entry_comment' AS type FROM entry_comment e INNER JOIN public.user u ON u.id = user_id @@ -101,8 +109,9 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_comment_id = e.id) - UNION ALL - SELECT e.id, e.created_at, e.visibility, 'post' AS type FROM post e + $authorWhere $magazineWhere + "; + $sqlPost = "SELECT e.id, e.created_at, e.visibility, 'post' AS type FROM post e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE body_ts @@ plainto_tsquery( :query ) = true @@ -111,6 +120,7 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_id = e.id) + $authorWhere $magazineWhere UNION ALL SELECT e.id, e.created_at, e.visibility, 'post_comment' AS type FROM post_comment e INNER JOIN public.user u ON u.id = user_id @@ -121,12 +131,38 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_comment_id = e.id) - ORDER BY created_at DESC"; - $adapter = new NativeQueryAdapter($conn, $sql, [ + $authorWhere $magazineWhere + "; + + if (null === $specificType) { + $sql = "$sqlEntry UNION ALL $sqlPost ORDER BY created_at DESC"; + } else { + if ('entry' === $specificType) { + $sql = "$sqlEntry ORDER BY created_at DESC"; + } elseif ('post' === $specificType) { + $sql = "$sqlPost ORDER BY created_at DESC"; + } else { + throw new \LogicException($specificType.' is not supported'); + } + } + + $this->logger->debug('Search query: {sql}', ['sql' => $sql]); + + $parameters = [ 'query' => $query, 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'queryingUser' => $searchingUser?->getId() ?? -1, - ], transformer: $this->transformer); + ]; + + if (null !== $authorId) { + $parameters['authorId'] = $authorId; + } + + if (null !== $magazineId) { + $parameters['magazineId'] = $magazineId; + } + + $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer); $pagerfanta = new Pagerfanta($adapter); $pagerfanta->setCurrentPage($page); diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 2c7c91dda..2b714b151 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -477,12 +477,9 @@ private function findUsersQueryBuilder(string $group, ?bool $recentlyActive = tr ->orderBy('u.lastActive', 'DESC'); } - public function findWithAboutPaginated( - int $page, - string $group = self::USERS_ALL, - int $perPage = self::PER_PAGE - ): PagerfantaInterface { - $query = $this->findWithAboutQueryBuilder($group)->getQuery(); + public function findPaginated(int $page, bool $needsAbout, string $group = self::USERS_ALL, int $perPage = self::PER_PAGE, ?string $query = null): PagerfantaInterface + { + $query = $this->findQueryBuilder($group, $query, $needsAbout)->getQuery(); $pagerfanta = new Pagerfanta( new QueryAdapter( @@ -500,11 +497,19 @@ public function findWithAboutPaginated( return $pagerfanta; } - private function findWithAboutQueryBuilder(string $group): QueryBuilder + private function findQueryBuilder(string $group, ?string $query, bool $needsAbout): QueryBuilder { - $qb = $this->createQueryBuilder('u') - ->andWhere('u.about != \'\'') - ->andWhere('u.about IS NOT NULL'); + $qb = $this->createQueryBuilder('u'); + + if ($needsAbout) { + $qb->andWhere('u.about != \'\'') + ->andWhere('u.about IS NOT NULL'); + } + + if (null !== $query) { + $qb->andWhere('u.username LIKE :query') + ->setParameter('query', '%'.$query.'%'); + } switch ($group) { case self::USERS_LOCAL: diff --git a/src/Service/SearchManager.php b/src/Service/SearchManager.php index 8b62a943f..fbdae9916 100644 --- a/src/Service/SearchManager.php +++ b/src/Service/SearchManager.php @@ -47,9 +47,9 @@ public function findDomainsPaginated(string $domain, int $page = 1, int $perPage return $this->domainRepository->search($domain, $page, $perPage); } - public function findPaginated(?User $queryingUser, string $val, int $page = 1, int $perPage = SearchRepository::PER_PAGE): PagerfantaInterface + public function findPaginated(?User $queryingUser, string $val, int $page = 1, int $perPage = SearchRepository::PER_PAGE, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null): PagerfantaInterface { - return $this->repository->search($queryingUser, $val, $page, $perPage); + return $this->repository->search($queryingUser, $val, $page, authorId: $authorId, magazineId: $magazineId, specificType: $specificType); } public function findByApId(string $url): array diff --git a/templates/magazine/list_all.html.twig b/templates/magazine/list_all.html.twig index 5a9f45cbe..452c2349e 100644 --- a/templates/magazine/list_all.html.twig +++ b/templates/magazine/list_all.html.twig @@ -21,9 +21,9 @@