mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-09 15:05:35 +00:00
feat: add validation for search modal
This commit is contained in:
29
inertia/components/dashboard/search/no_search_result.tsx
Normal file
29
inertia/components/dashboard/search/no_search_result.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
|
||||
const NoSearchResultStyle = styled.i({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.25em',
|
||||
|
||||
'& > span': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default function NoSearchResult() {
|
||||
const { t } = useTranslation('common');
|
||||
return (
|
||||
<NoSearchResultStyle>
|
||||
{t('search-with')}
|
||||
{''}
|
||||
<span>
|
||||
<FcGoogle size={20} />
|
||||
oogle
|
||||
</span>
|
||||
</NoSearchResultStyle>
|
||||
);
|
||||
}
|
||||
@@ -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<string>('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [selectedItem, setSelectedItem] = useState<SearchResult | null>(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<HTMLFormElement>) =>
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
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' }}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
@@ -100,37 +132,14 @@ function SearchModal({ openItem: OpenItem }: SearchModalProps) {
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<UnstyledList css={{ maxHeight: '500px', overflow: 'auto' }}>
|
||||
{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 (
|
||||
<li
|
||||
key={result.type + result.id.toString()}
|
||||
css={{
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
gap: '0.35em',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ItemIcon size={24} />
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: result.matched_part ?? result.name,
|
||||
}}
|
||||
/>
|
||||
{collection && <>({collection.name})</>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</UnstyledList>
|
||||
{results.length > 0 && selectedItem && (
|
||||
<SearchResultList
|
||||
results={results}
|
||||
selectedItem={selectedItem}
|
||||
setSelectedItem={setSelectedItem}
|
||||
/>
|
||||
)}
|
||||
{results.length === 0 && !!searchTerm.trim() && <NoSearchResult />}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={searchTerm.length === 0}
|
||||
|
||||
143
inertia/components/dashboard/search/search_result_item.tsx
Normal file
143
inertia/components/dashboard/search/search_result_item.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { RefObject, useEffect, useRef, useState } from 'react';
|
||||
import { AiOutlineFolder } from 'react-icons/ai';
|
||||
import TextEllipsis from '~/components/common/text_ellipsis';
|
||||
import LinkFavicon from '~/components/dashboard/link/link_favicon';
|
||||
import useCollections from '~/hooks/use_collections';
|
||||
import {
|
||||
SearchResult,
|
||||
SearchResultCollection,
|
||||
SearchResultLink,
|
||||
} from '~/types/search';
|
||||
|
||||
const SearchItemStyle = styled('li', {
|
||||
shouldForwardProp: (propName) => propName !== 'isActive',
|
||||
})<{ isActive: boolean }>(({ theme, isActive }) => ({
|
||||
fontSize: '16px',
|
||||
backgroundColor: isActive ? theme.colors.background : 'transparent',
|
||||
display: 'flex',
|
||||
gap: '0.35em',
|
||||
alignItems: 'center',
|
||||
borderRadius: theme.border.radius,
|
||||
padding: '0.25em 0.35em !important',
|
||||
}));
|
||||
|
||||
const ItemLegeng = styled.span(({ theme }) => ({
|
||||
fontSize: '13px',
|
||||
color: theme.colors.grey,
|
||||
}));
|
||||
|
||||
interface CommonResultProps {
|
||||
isActive: boolean;
|
||||
innerRef: RefObject<HTMLLIElement>;
|
||||
onHoverEnter: () => void;
|
||||
onHoverLeave: () => void;
|
||||
}
|
||||
|
||||
export default function SearchResultItem({
|
||||
result,
|
||||
isActive,
|
||||
onHover,
|
||||
}: {
|
||||
result: SearchResult;
|
||||
isActive: boolean;
|
||||
onHover: (result: SearchResult) => void;
|
||||
}) {
|
||||
const itemRef = useRef<HTMLLIElement>(null);
|
||||
const [isHovering, setHovering] = useState<boolean>(false);
|
||||
|
||||
const handleHoverEnter = () => {
|
||||
if (!isHovering) {
|
||||
onHover(result);
|
||||
setHovering(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHoverLeave = () => setHovering(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && !isHovering) {
|
||||
itemRef.current?.scrollIntoView({
|
||||
behavior: 'instant',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
}, [itemRef, isActive]);
|
||||
|
||||
return result.type === 'collection' ? (
|
||||
<ResultCollection
|
||||
result={result}
|
||||
isActive={isActive}
|
||||
onHoverEnter={handleHoverEnter}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
innerRef={itemRef}
|
||||
/>
|
||||
) : (
|
||||
<ResultLink
|
||||
result={result}
|
||||
isActive={isActive}
|
||||
onHoverEnter={handleHoverEnter}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
innerRef={itemRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultLink({
|
||||
result,
|
||||
isActive,
|
||||
innerRef,
|
||||
onHoverEnter,
|
||||
onHoverLeave,
|
||||
}: {
|
||||
result: SearchResultLink;
|
||||
} & CommonResultProps) {
|
||||
const { collections } = useCollections();
|
||||
const collection = collections.find((c) => c.id === result.collection_id);
|
||||
const link = collection?.links.find((l) => l.id === result.id);
|
||||
|
||||
if (!collection || !link) return <></>;
|
||||
|
||||
return (
|
||||
<SearchItemStyle
|
||||
onMouseEnter={onHoverEnter}
|
||||
onMouseLeave={onHoverLeave}
|
||||
isActive={isActive}
|
||||
key={result.type + result.id.toString()}
|
||||
ref={innerRef}
|
||||
>
|
||||
<LinkFavicon url={link.url} size={20} />
|
||||
<TextEllipsis
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: result.matched_part ?? result.name,
|
||||
}}
|
||||
/>
|
||||
<ItemLegeng>({collection.name})</ItemLegeng>
|
||||
</SearchItemStyle>
|
||||
);
|
||||
}
|
||||
|
||||
const ResultCollection = ({
|
||||
result,
|
||||
isActive,
|
||||
innerRef,
|
||||
onHoverEnter,
|
||||
onHoverLeave,
|
||||
}: {
|
||||
result: SearchResultCollection;
|
||||
} & CommonResultProps) => (
|
||||
<SearchItemStyle
|
||||
onMouseEnter={onHoverEnter}
|
||||
onMouseLeave={onHoverLeave}
|
||||
isActive={isActive}
|
||||
key={result.type + result.id.toString()}
|
||||
ref={innerRef}
|
||||
>
|
||||
<AiOutlineFolder size={24} />
|
||||
<TextEllipsis
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: result.matched_part ?? result.name,
|
||||
}}
|
||||
/>
|
||||
</SearchItemStyle>
|
||||
);
|
||||
52
inertia/components/dashboard/search/search_result_list.tsx
Normal file
52
inertia/components/dashboard/search/search_result_list.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import UnstyledList from '~/components/common/unstyled/unstyled_list';
|
||||
import SearchResultItem from '~/components/dashboard/search/search_result_item';
|
||||
import useShortcut from '~/hooks/use_shortcut';
|
||||
import { SearchResult } from '~/types/search';
|
||||
|
||||
export default function SearchResultList({
|
||||
results,
|
||||
selectedItem,
|
||||
setSelectedItem,
|
||||
}: {
|
||||
results: SearchResult[];
|
||||
selectedItem: SearchResult;
|
||||
setSelectedItem: (result: SearchResult) => void;
|
||||
}) {
|
||||
const selectedItemIndex = results.findIndex(
|
||||
(item) => item.id === selectedItem.id && item.type === selectedItem.type
|
||||
);
|
||||
|
||||
useShortcut(
|
||||
'ARROW_UP',
|
||||
() => setSelectedItem(results[selectedItemIndex - 1]),
|
||||
{
|
||||
enabled: results.length > 1 && selectedItemIndex !== 0,
|
||||
disableGlobalCheck: true,
|
||||
}
|
||||
);
|
||||
useShortcut(
|
||||
'ARROW_DOWN',
|
||||
() => setSelectedItem(results[selectedItemIndex + 1]),
|
||||
{
|
||||
enabled: results.length > 1 && selectedItemIndex !== results.length - 1,
|
||||
disableGlobalCheck: true,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<UnstyledList css={{ maxHeight: '500px', overflow: 'auto' }}>
|
||||
{results.map((result) => (
|
||||
<SearchResultItem
|
||||
result={result}
|
||||
onHover={setSelectedItem}
|
||||
isActive={
|
||||
selectedItem &&
|
||||
selectedItem.id === result.id &&
|
||||
selectedItem.type === result.type
|
||||
}
|
||||
key={result.type + result.id.toString()}
|
||||
/>
|
||||
))}
|
||||
</UnstyledList>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user