mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-09 15:05:35 +00:00
feat: add a search modal using the database (wip)
This commit is contained in:
@@ -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>,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
147
inertia/components/dashboard/search/search_modal.tsx
Normal file
147
inertia/components/dashboard/search/search_modal.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -31,7 +31,7 @@ interface FormLayoutProps {
|
||||
|
||||
disableHomeLink?: boolean;
|
||||
submitBtnDanger?: boolean;
|
||||
collectionId?: string;
|
||||
collectionId?: number;
|
||||
}
|
||||
|
||||
export default function FormLayout({
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const LS_LANG_KEY = 'language';
|
||||
export const GOOGLE_SEARCH_URL = 'https://google.com/search?q=';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
31
inertia/types/search.d.ts
vendored
31
inertia/types/search.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user