diff --git a/app/controllers/searches_controller.ts b/app/controllers/searches_controller.ts index 4055122..5645cda 100644 --- a/app/controllers/searches_controller.ts +++ b/app/controllers/searches_controller.ts @@ -5,7 +5,8 @@ export default class SearchesController { async search({ request, auth }: HttpContext) { const term = request.qs()?.term; if (!term) { - return console.warn('qs term null'); + console.warn('qs term null'); + return { error: 'missing "term" query param' }; } const { rows } = await db.rawQuery('SELECT * FROM search_text(?, ?)', [ diff --git a/database/migrations/1716561885061_create_full_text_searches_table.ts b/database/migrations/1716561885061_create_full_text_searches_table.ts index ee23cad..eda55d7 100644 --- a/database/migrations/1716561885061_create_full_text_searches_table.ts +++ b/database/migrations/1716561885061_create_full_text_searches_table.ts @@ -18,6 +18,7 @@ export default class extends BaseSchema { id INTEGER, type TEXT, name VARCHAR(254), + url TEXT, collection_id INTEGER, matched_part TEXT, rank DOUBLE PRECISION @@ -25,20 +26,20 @@ export default class extends BaseSchema { AS $$ BEGIN RETURN QUERY - SELECT links.id, 'link' AS type, links.name, collections.id AS collection_id, - ts_headline('english', unaccent(links.name), plainto_tsquery('english', unaccent(search_query))) AS matched_part, - ts_rank_cd(to_tsvector('english', unaccent(links.name)), plainto_tsquery('english', unaccent(search_query)))::DOUBLE PRECISION AS rank + SELECT links.id, 'link' AS type, links.name, links.url, collections.id AS collection_id, + ts_headline('english', unaccent(links.name), plainto_tsquery('english', unaccent(search_query))) AS matched_part, + ts_rank_cd(to_tsvector('english', unaccent(links.name)), plainto_tsquery('english', unaccent(search_query)))::DOUBLE PRECISION AS rank FROM links LEFT JOIN collections ON links.collection_id = collections.id WHERE unaccent(links.name) ILIKE '%' || unaccent(search_query) || '%' - AND (p_author_id IS NULL OR links.author_id = p_author_id) + AND (p_author_id IS NULL OR links.author_id = p_author_id) UNION ALL - SELECT collections.id, 'collection' AS type, collections.name, NULL AS collection_id, - ts_headline('english', unaccent(collections.name), plainto_tsquery('english', unaccent(search_query))) AS matched_part, - ts_rank_cd(to_tsvector('english', unaccent(collections.name)), plainto_tsquery('english', unaccent(search_query)))::DOUBLE PRECISION AS rank + SELECT collections.id, 'collection' AS type, collections.name, NULL AS url, NULL AS collection_id, + ts_headline('english', unaccent(collections.name), plainto_tsquery('english', unaccent(search_query))) AS matched_part, + ts_rank_cd(to_tsvector('english', unaccent(collections.name)), plainto_tsquery('english', unaccent(search_query)))::DOUBLE PRECISION AS rank FROM collections WHERE unaccent(collections.name) ILIKE '%' || unaccent(search_query) || '%' - AND (p_author_id IS NULL OR collections.author_id = p_author_id) + AND (p_author_id IS NULL OR collections.author_id = p_author_id) ORDER BY rank DESC NULLS LAST, matched_part DESC NULLS LAST; END; $$ diff --git a/inertia/components/common/dropdown/dropdown.tsx b/inertia/components/common/dropdown/dropdown.tsx index 43e7ff0..685cd26 100644 --- a/inertia/components/common/dropdown/dropdown.tsx +++ b/inertia/components/common/dropdown/dropdown.tsx @@ -44,7 +44,7 @@ export default function Dropdown({ const { isShowing, toggle, close } = useToggle(); useClickOutside(dropdownRef, close); - useShortcut('ESCAPE_KEY', close); + useShortcut('ESCAPE_KEY', close, { disableGlobalCheck: true }); return ( span': { + display: 'flex', + alignItems: 'center', + }, +}); + +export default function NoSearchResult() { + const { t } = useTranslation('common'); + return ( + + {t('search-with')} + {''} + + + oogle + + + ); +} diff --git a/inertia/components/dashboard/search/search_modal.tsx b/inertia/components/dashboard/search/search_modal.tsx index 36c20b5..8cabeb7 100644 --- a/inertia/components/dashboard/search/search_modal.tsx +++ b/inertia/components/dashboard/search/search_modal.tsx @@ -2,11 +2,12 @@ import styled from '@emotion/styled'; import { route } from '@izzyjs/route/client'; import { FormEvent, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { AiOutlineFolder } from 'react-icons/ai'; -import { CiLink } from 'react-icons/ci'; import { IoIosSearch } from 'react-icons/io'; import Modal from '~/components/common/modal/modal'; -import UnstyledList from '~/components/common/unstyled/unstyled_list'; +import NoSearchResult from '~/components/dashboard/search/no_search_result'; +import SearchResultList from '~/components/dashboard/search/search_result_list'; +import { GOOGLE_SEARCH_URL } from '~/constants'; +import useActiveCollection from '~/hooks/use_active_collection'; import useCollections from '~/hooks/use_collections'; import useToggle from '~/hooks/use_modal'; import useShortcut from '~/hooks/use_shortcut'; @@ -29,8 +30,11 @@ interface SearchModalProps { function SearchModal({ openItem: OpenItem }: SearchModalProps) { const { t } = useTranslation(); const { collections } = useCollections(); + const { setActiveCollection } = useActiveCollection(); + const [searchTerm, setSearchTerm] = useState(''); const [results, setResults] = useState([]); + const [selectedItem, setSelectedItem] = useState(null); const searchModal = useToggle(!!searchTerm); @@ -40,25 +44,52 @@ function SearchModal({ openItem: OpenItem }: SearchModalProps) { }, [searchModal]); const handleSearchInputChange = (value: string) => setSearchTerm(value); - const handleSubmit = (event: FormEvent) => + const handleSubmit = (event: FormEvent) => { event.preventDefault(); + handleCloseModal(); + + if (results.length === 0) { + return window.open(GOOGLE_SEARCH_URL + encodeURI(searchTerm.trim())); + } + + if (!selectedItem) return; + + if (selectedItem.type === 'collection') { + const collection = collections.find((c) => c.id === selectedItem.id); + if (collection) { + setActiveCollection(collection); + } + return; + } + + window.open(selectedItem.url); + }; useShortcut('OPEN_SEARCH_KEY', searchModal.open, { enabled: !searchModal.isShowing, }); useShortcut('ESCAPE_KEY', handleCloseModal, { enabled: searchModal.isShowing, + disableGlobalCheck: true, }); useEffect(() => { if (searchTerm.trim() === '') { return setResults([]); } + + const controller = new AbortController(); const { url, method } = route('search', { qs: { term: searchTerm } }); makeRequest({ method, url, - }).then(({ results: _results }) => setResults(_results)); + controller, + }).then(({ results: _results }) => { + setResults(_results); + setSelectedItem(_results?.[0]); + }); + + return () => controller.abort(); }, [searchTerm]); return ( @@ -70,6 +101,7 @@ function SearchModal({ openItem: OpenItem }: SearchModalProps) { close={handleCloseModal} opened={searchModal.isShowing} hideCloseBtn + css={{ width: '650px' }} >
- {results.length > 0 && ( - - {results.map((result) => { - const ItemIcon = - result.type === 'link' ? CiLink : AiOutlineFolder; - const collection = - result.type === 'link' - ? collections.find((c) => c.id === result.collection_id) - : undefined; - return ( -
  • - - - {collection && <>({collection.name})} -
  • - ); - })} -
    + {results.length > 0 && selectedItem && ( + )} + {results.length === 0 && !!searchTerm.trim() && }