feat: add a search modal using the database (wip)

This commit is contained in:
Sonny
2024-05-25 03:40:08 +02:00
committed by Sonny
parent b28499a69a
commit 56c05f1bf6
21 changed files with 535 additions and 621 deletions

View File

@@ -17,6 +17,7 @@ interface ModalProps {
title?: string;
children: ReactNode;
opened: boolean;
hideCloseBtn?: boolean;
close: () => void;
}
@@ -25,13 +26,14 @@ export default function Modal({
title,
children,
opened = true,
hideCloseBtn = false,
close,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const { setGlobalHotkeysEnabled } = useGlobalHotkeys();
useClickOutside(modalRef, close);
useShortcut('ESCAPE_KEY', close, { ignoreGlobalHotkeysStatus: true });
useShortcut('ESCAPE_KEY', close);
useEffect(() => setGlobalHotkeysEnabled(!opened), [opened]);
@@ -44,12 +46,17 @@ export default function Modal({
createPortal(
<ModalWrapper>
<ModalContainer ref={modalRef}>
<ModalHeader>
{title && <TextEllipsis>{title}</TextEllipsis>}
<ModalCloseBtn onClick={close}>
<IoClose size={20} />
</ModalCloseBtn>
</ModalHeader>
{!hideCloseBtn ||
(title && (
<ModalHeader>
{title && <TextEllipsis>{title}</TextEllipsis>}
{!hideCloseBtn && (
<ModalCloseBtn onClick={close}>
<IoClose size={20} />
</ModalCloseBtn>
)}
</ModalHeader>
))}
<ModalBody>{children}</ModalBody>
</ModalContainer>
</ModalWrapper>,

View File

@@ -1,17 +1,21 @@
import type Collection from '#models/collection';
import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { AiFillFolderOpen, AiOutlineFolder } from 'react-icons/ai';
import TextEllipsis from '~/components/common/text_ellipsis';
import { Item } from '~/components/dashboard/side_nav/nav_item';
import useActiveCollection from '~/hooks/use_active_collection';
import { appendCollectionId } from '~/lib/navigation';
const CollectionItemStyle = styled(Item)<{ isActive: boolean }>(
({ theme, isActive }) => ({
cursor: 'pointer',
color: isActive ? theme.colors.primary : theme.colors.font,
backgroundColor: theme.colors.secondary,
})
);
const CollectionItemStyle = styled(Item, {
shouldForwardProp: (propName) => propName !== 'isActive',
})<{ isActive: boolean }>(({ theme, isActive }) => ({
cursor: 'pointer',
color: isActive ? theme.colors.primary : theme.colors.font,
backgroundColor: theme.colors.secondary,
}));
const CollectionItemLink = CollectionItemStyle.withComponent(Link);
const LinksCount = styled.div(({ theme }) => ({
minWidth: 'fit-content',
@@ -25,13 +29,13 @@ export default function CollectionItem({
}: {
collection: Collection;
}) {
const { activeCollection, setActiveCollection } = useActiveCollection();
const { activeCollection } = useActiveCollection();
const isActiveCollection = collection.id === activeCollection?.id;
const FolderIcon = isActiveCollection ? AiFillFolderOpen : AiOutlineFolder;
return (
<CollectionItemStyle
onClick={() => setActiveCollection(collection)}
<CollectionItemLink
href={appendCollectionId(route('dashboard').url, collection.id)}
isActive={isActiveCollection}
>
<FolderIcon css={{ minWidth: '24px' }} size={24} />
@@ -39,6 +43,6 @@ export default function CollectionItem({
{collection.links.length > 0 && (
<LinksCount> {collection.links.length}</LinksCount>
)}
</CollectionItemStyle>
</CollectionItemLink>
);
}

View File

@@ -0,0 +1,147 @@
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 useCollections from '~/hooks/use_collections';
import useToggle from '~/hooks/use_modal';
import useShortcut from '~/hooks/use_shortcut';
import { makeRequest } from '~/lib/request';
import { SearchResult } from '~/types/search';
const SearchInput = styled.input(({ theme }) => ({
width: '100%',
fontSize: '20px',
color: theme.colors.font,
backgroundColor: 'transparent',
paddingLeft: 0,
border: '1px solid transparent',
}));
interface SearchModalProps {
openItem: any;
}
function SearchModal({ openItem: OpenItem }: SearchModalProps) {
const { t } = useTranslation();
const { collections } = useCollections();
const [searchTerm, setSearchTerm] = useState<string>('');
const [results, setResults] = useState<SearchResult[]>([]);
const searchModal = useToggle(!!searchTerm);
const handleCloseModal = useCallback(() => {
searchModal.close();
setSearchTerm('');
}, [searchModal]);
const handleSearchInputChange = (value: string) => setSearchTerm(value);
const handleSubmit = (event: FormEvent<HTMLFormElement>) =>
event.preventDefault();
useShortcut('OPEN_SEARCH_KEY', searchModal.open, {
enabled: !searchModal.isShowing,
});
useShortcut('ESCAPE_KEY', handleCloseModal, {
enabled: searchModal.isShowing,
});
useEffect(() => {
if (searchTerm.trim() === '') {
return setResults([]);
}
const { url, method } = route('search', { qs: { term: searchTerm } });
makeRequest({
method,
url,
}).then(({ results: _results }) => setResults(_results));
}, [searchTerm]);
return (
<>
<OpenItem onClick={searchModal.open}>
<IoIosSearch /> {t('common:search')}
</OpenItem>
<Modal
close={handleCloseModal}
opened={searchModal.isShowing}
hideCloseBtn
>
<form
onSubmit={handleSubmit}
css={{
width: '100%',
display: 'flex',
gap: '0.5em',
flexDirection: 'column',
}}
>
<div
css={{
display: 'flex',
gap: '0.35em',
alignItems: 'center',
justifyContent: 'center',
}}
>
<label htmlFor="search" css={{ display: 'flex' }}>
<IoIosSearch size={24} />
</label>
<SearchInput
name="search"
id="search"
onChange={({ target }) => handleSearchInputChange(target.value)}
value={searchTerm}
placeholder={t('common:search')}
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>
)}
<button
type="submit"
disabled={searchTerm.length === 0}
style={{ display: 'none' }}
>
{t('common:confirm')}
</button>
</form>
</Modal>
</>
);
}
export default SearchModal;

View File

@@ -2,8 +2,8 @@ import styled from '@emotion/styled';
import { route } from '@izzyjs/route/client';
import { useTranslation } from 'react-i18next';
import { AiOutlineFolderAdd } from 'react-icons/ai';
import { IoAdd } from 'react-icons/io5';
import { MdOutlineAdminPanelSettings } from 'react-icons/md';
import { IoAdd, IoShieldHalfSharp } from 'react-icons/io5';
import SearchModal from '~/components/dashboard/search/search_modal';
import FavoriteList from '~/components/dashboard/side_nav/favorite/favorite_list';
import { Item, ItemLink } from '~/components/dashboard/side_nav/nav_item';
import UserCard from '~/components/dashboard/side_nav/user_card';
@@ -40,6 +40,8 @@ const AddButton = styled(ItemLink)(({ theme }) => ({
},
}));
const SearchButton = AddButton.withComponent(Item);
export default function SideNavigation() {
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
@@ -48,9 +50,10 @@ export default function SideNavigation() {
<div css={{ paddingInline: '10px' }}>
<UserCard />
<AdminButton>
<MdOutlineAdminPanelSettings /> {t('admin')}
<IoShieldHalfSharp /> {t('admin')}
</AdminButton>
<ModalSettings openItem={SettingsButton} />
<SearchModal openItem={SearchButton} />
<AddButton
href={appendCollectionId(
route('link.create-form').url,

View File

@@ -43,7 +43,8 @@ export default function FormLink({
handleSubmit,
}: FormLinkProps) {
const { t } = useTranslation('common');
const collectionId = useSearchParam('collectionId') ?? collections?.[0].id;
const collectionId =
Number(useSearchParam('collectionId')) ?? collections?.[0].id;
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();

View File

@@ -31,7 +31,7 @@ interface FormLayoutProps {
disableHomeLink?: boolean;
submitBtnDanger?: boolean;
collectionId?: string;
collectionId?: number;
}
export default function FormLayout({

View File

@@ -1 +1,2 @@
export const LS_LANG_KEY = 'language';
export const GOOGLE_SEARCH_URL = 'https://google.com/search?q=';

View File

@@ -1,43 +0,0 @@
import { buildSearchItem, formatSearchItem } from 'lib/search';
import { useMemo } from 'react';
import useCollections from '~/hooks/use_collections';
import { SearchItem, SearchResult } from '~/types/search';
export default function useSearchItem({
searchTerm = '',
disableLinks = false,
disableCollections = false,
}: {
searchTerm: string;
disableLinks?: boolean;
disableCollections?: boolean;
}) {
const { collections } = useCollections();
const itemsSearch: SearchItem[] = useMemo<SearchItem[]>(() => {
return collections.reduce((acc, collection) => {
const collectionItem = buildSearchItem(collection, 'collection');
const items: SearchItem[] = collection.links.map((link) =>
buildSearchItem(link, 'link')
);
return [...acc, ...items, collectionItem];
}, [] as SearchItem[]);
}, [collections]);
const itemsCompletion: SearchResult[] = useMemo(() => {
return itemsSearch.reduce((acc, item) => {
const formattedItem = formatSearchItem(item, searchTerm);
if (
(!disableLinks && item.type === 'link') ||
(!disableCollections && item.type === 'collection')
) {
return formattedItem ? [...acc, formattedItem] : acc;
}
return acc;
}, [] as SearchResult[]);
}, [itemsSearch, searchTerm, disableLinks, disableCollections]);
return itemsCompletion;
}

View File

@@ -2,18 +2,27 @@ import KEYS from '#constants/keys';
import { useHotkeys } from 'react-hotkeys-hook';
import useGlobalHotkeys from '~/hooks/use_global_hotkeys';
type ShortcutOptions = { ignoreGlobalHotkeysStatus?: boolean };
type ShortcutOptions = {
enabled?: boolean;
};
export default function useShortcut(
key: keyof typeof KEYS,
cb: () => void,
options: ShortcutOptions = {
ignoreGlobalHotkeysStatus: false,
{ enabled }: ShortcutOptions = {
enabled: true,
}
) {
const { globalHotkeysEnabled } = useGlobalHotkeys();
return useHotkeys(KEYS[key], cb, {
enabled: !options.ignoreGlobalHotkeysStatus ? globalHotkeysEnabled : true,
enableOnFormTags: ['INPUT'],
});
return useHotkeys(
KEYS[key],
(event) => {
event.preventDefault();
cb();
},
{
enabled: key === 'ESCAPE_KEY' ? true : enabled && globalHotkeysEnabled,
enableOnFormTags: ['INPUT'],
}
);
}

View File

@@ -1,54 +0,0 @@
import Collection from '#models/collection';
import Link from '#models/link';
import { route } from '@izzyjs/route/client';
import { appendCollectionId } from '~/lib/navigation';
import { SearchItem, SearchResult } from '~/types/search';
export function buildSearchItem(
item: Collection | Link,
type: SearchItem['type']
): SearchItem {
return {
id: item.id,
name: item.name,
url:
type === 'link'
? (item as Link).url
: appendCollectionId(route('dashboard').url, item.id),
type,
collection: type === 'link' ? (item as Link).collection : undefined,
};
}
export function formatSearchItem(
item: SearchItem,
searchTerm: string
): SearchResult | null {
const lowerCaseSearchTerm = searchTerm.toLowerCase().trim();
const lowerCaseName = item.name.toLowerCase().trim();
let currentIndex = 0;
let formattedName = '';
for (const [i, element] of Object(lowerCaseName).entries()) {
if (element === lowerCaseSearchTerm[currentIndex]) {
formattedName += `<b>${item.name[i]}</b>`;
currentIndex++;
} else {
formattedName += item.name[i];
}
}
if (currentIndex !== lowerCaseSearchTerm.length) {
// Search term not fully matched
return null;
}
return {
id: item.id,
name: <div dangerouslySetInnerHTML={{ __html: formattedName }} />,
url: item.url,
type: item.type,
collection: item.collection,
};
}

View File

@@ -105,19 +105,28 @@ function DashboardProviders(
const favoritesContextValue = useMemo(() => ({ favorites }), [favorites]);
const globalHotkeysContextValue = useMemo(
() => ({
globalHotkeysEnabled: globalHotkeysEnabled,
globalHotkeysEnabled,
setGlobalHotkeysEnabled,
}),
[globalHotkeysEnabled]
);
useShortcut('OPEN_CREATE_LINK_KEY', () =>
router.visit(
appendCollectionId(route('link.create-form').url, activeCollection?.id)
)
useShortcut(
'OPEN_CREATE_LINK_KEY',
() =>
router.visit(
appendCollectionId(route('link.create-form').url, activeCollection?.id)
),
{
enabled: globalHotkeysEnabled,
}
);
useShortcut('OPEN_CREATE_COLLECTION_KEY', () =>
router.visit(route('collection.create-form').url)
useShortcut(
'OPEN_CREATE_COLLECTION_KEY',
() => router.visit(route('collection.create-form').url),
{
enabled: globalHotkeysEnabled,
}
);
return (
<CollectionsContext.Provider value={collectionsContextValue}>

View File

@@ -13,7 +13,8 @@ export default function CreateLinkPage({
collections: Collection[];
}) {
const { t } = useTranslation('common');
const collectionId = useSearchParam('collectionId') ?? collections[0].id;
const collectionId =
Number(useSearchParam('collectionId')) ?? collections[0].id;
const { data, setData, submit, processing } = useForm({
name: '',
description: '',

View File

@@ -1,17 +1,18 @@
import type Link from '#models/link';
export interface SearchItem {
id: string;
name: string;
url: string;
type: 'collection' | 'link';
collection?: undefined | Link['collection'];
}
export interface SearchResult {
id: string;
name: React.ReactNode; // React node to support bold text
url: string;
type: 'collection' | 'link';
collection?: undefined | Link['collection'];
}
export type SearchResult =
| {
id: string;
type: 'collection';
name: string;
matched_part?: string;
rank?: number;
}
| {
id: string;
type: 'link';
name: string;
matched_part?: string;
rank?: number;
collection_id: number;
};