mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-10 07:25:35 +00:00
refactor: search modal component
This commit is contained in:
@@ -2,7 +2,6 @@ import ButtonLink from 'components/ButtonLink';
|
|||||||
import Modal from 'components/Modal/Modal';
|
import Modal from 'components/Modal/Modal';
|
||||||
import TextBox from 'components/TextBox';
|
import TextBox from 'components/TextBox';
|
||||||
import * as Keys from 'constants/keys';
|
import * as Keys from 'constants/keys';
|
||||||
import PATHS from 'constants/paths';
|
|
||||||
import { GOOGLE_SEARCH_URL } from 'constants/search-urls';
|
import { GOOGLE_SEARCH_URL } from 'constants/search-urls';
|
||||||
import { AnimatePresence } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
import useActiveCategory from 'hooks/useActiveCategory';
|
import useActiveCategory from 'hooks/useActiveCategory';
|
||||||
@@ -11,24 +10,13 @@ import useCategories from 'hooks/useCategories';
|
|||||||
import useGlobalHotkeys from 'hooks/useGlobalHotkeys';
|
import useGlobalHotkeys from 'hooks/useGlobalHotkeys';
|
||||||
import { useLocalStorage } from 'hooks/useLocalStorage';
|
import { useLocalStorage } from 'hooks/useLocalStorage';
|
||||||
import useModal from 'hooks/useModal';
|
import useModal from 'hooks/useModal';
|
||||||
|
import useQueryParam from 'hooks/useQueryParam';
|
||||||
|
import useSearchItem from 'hooks/useSearchItem';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useRouter } from 'next/router';
|
import { FormEvent, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
|
||||||
FormEvent,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { BsSearch } from 'react-icons/bs';
|
import { BsSearch } from 'react-icons/bs';
|
||||||
import {
|
import { SearchResult } from 'types/types';
|
||||||
CategoryWithLinks,
|
|
||||||
LinkWithCategory,
|
|
||||||
SearchItem,
|
|
||||||
SearchResult,
|
|
||||||
} from 'types/types';
|
|
||||||
import LabelSearchWithGoogle from './LabelSearchWithGoogle';
|
import LabelSearchWithGoogle from './LabelSearchWithGoogle';
|
||||||
import { SearchFilter } from './SearchFilter';
|
import { SearchFilter } from './SearchFilter';
|
||||||
import SearchList from './SearchList';
|
import SearchList from './SearchList';
|
||||||
@@ -40,62 +28,12 @@ interface SearchModalProps {
|
|||||||
childClassname?: string;
|
childClassname?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSearchItem(
|
|
||||||
item: CategoryWithLinks | LinkWithCategory,
|
|
||||||
type: SearchItem['type'],
|
|
||||||
): SearchItem {
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
url:
|
|
||||||
type === 'link'
|
|
||||||
? (item as LinkWithCategory).url
|
|
||||||
: `${PATHS.HOME}?categoryId=${item.id}`,
|
|
||||||
type,
|
|
||||||
category: type === 'link' ? (item as LinkWithCategory).category : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (let i = 0; i < lowerCaseName.length; i++) {
|
|
||||||
if (lowerCaseName[i] === 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,
|
|
||||||
category: item.category,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchModal({
|
function SearchModal({
|
||||||
noHeader = true,
|
noHeader = true,
|
||||||
children,
|
children,
|
||||||
childClassname = '',
|
childClassname = '',
|
||||||
}: Readonly<SearchModalProps>) {
|
}: Readonly<SearchModalProps>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
|
||||||
const autoFocusRef = useAutoFocus();
|
const autoFocusRef = useAutoFocus();
|
||||||
const { categories } = useCategories();
|
const { categories } = useCategories();
|
||||||
const { setActiveCategory } = useActiveCategory();
|
const { setActiveCategory } = useActiveCategory();
|
||||||
@@ -108,12 +46,11 @@ function SearchModal({
|
|||||||
'search-category',
|
'search-category',
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const [search, setSearch] = useState<string>(
|
const { queryParam, clearQuery } = useQueryParam('q');
|
||||||
(router.query.q as string) || '',
|
const [searchTerm, setSearchTerm] = useState<string>(queryParam);
|
||||||
);
|
|
||||||
const [selectedItem, setSelectedItem] = useState<SearchResult | null>(null);
|
const [selectedItem, setSelectedItem] = useState<SearchResult | null>(null);
|
||||||
|
|
||||||
const searchModal = useModal(!!search && typeof window !== 'undefined');
|
const searchModal = useModal(!!searchTerm);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => setGlobalHotkeysEnabled(!searchModal.isShowing),
|
() => setGlobalHotkeysEnabled(!searchModal.isShowing),
|
||||||
@@ -122,63 +59,28 @@ function SearchModal({
|
|||||||
|
|
||||||
const handleCloseModal = useCallback(() => {
|
const handleCloseModal = useCallback(() => {
|
||||||
searchModal.close();
|
searchModal.close();
|
||||||
setSearch('');
|
clearQuery();
|
||||||
if (!!search) {
|
setSearchTerm('');
|
||||||
router.replace({
|
}, [searchModal, clearQuery]);
|
||||||
query: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [router, search, searchModal]);
|
|
||||||
|
|
||||||
const itemsSearch = useMemo<SearchItem[]>(() => {
|
const searchItemsResult = useSearchItem({
|
||||||
return categories.reduce((acc, category) => {
|
searchTerm,
|
||||||
const categoryItem = buildSearchItem(category, 'category');
|
disableLinks: !canSearchLink,
|
||||||
const items: SearchItem[] = category.links.map((link) =>
|
disableCategories: !canSearchCategory,
|
||||||
buildSearchItem(link, 'link'),
|
});
|
||||||
);
|
|
||||||
return [...acc, ...items, categoryItem];
|
|
||||||
}, [] as SearchItem[]);
|
|
||||||
}, [categories]);
|
|
||||||
|
|
||||||
const itemsCompletion = useMemo(() => {
|
const handleSearchInputChange = (value: string) => setSearchTerm(value);
|
||||||
return itemsSearch.reduce((acc, item) => {
|
const handleCanSearchLink = (checked: boolean) => setCanSearchLink(checked);
|
||||||
const formattedItem = formatSearchItem(item, search);
|
const handleCanSearchCategory = (checked: boolean) =>
|
||||||
|
setCanSearchCategory(checked);
|
||||||
if (
|
|
||||||
(canSearchLink && item.type === 'link') ||
|
|
||||||
(canSearchCategory && item.type === 'category')
|
|
||||||
) {
|
|
||||||
return formattedItem ? [...acc, formattedItem] : acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, [] as SearchResult[]);
|
|
||||||
}, [itemsSearch, search, canSearchLink, canSearchCategory]);
|
|
||||||
|
|
||||||
const canSubmit = useMemo<boolean>(() => search.length > 0, [search]);
|
|
||||||
|
|
||||||
const handleSearchInputChange = useCallback(
|
|
||||||
(value: string) => setSearch(value),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCanSearchLink = useCallback(
|
|
||||||
(checked: boolean) => setCanSearchLink(checked),
|
|
||||||
[setCanSearchLink],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCanSearchCategory = useCallback(
|
|
||||||
(checked: boolean) => setCanSearchCategory(checked),
|
|
||||||
[setCanSearchCategory],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(event: FormEvent<HTMLFormElement>) => {
|
(event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleCloseModal();
|
handleCloseModal();
|
||||||
|
|
||||||
if (itemsCompletion.length === 0) {
|
if (searchItemsResult.length === 0) {
|
||||||
return window.open(GOOGLE_SEARCH_URL + encodeURI(search.trim()));
|
return window.open(GOOGLE_SEARCH_URL + encodeURI(searchTerm.trim()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedItem) return;
|
if (!selectedItem) return;
|
||||||
@@ -193,8 +95,8 @@ function SearchModal({
|
|||||||
[
|
[
|
||||||
categories,
|
categories,
|
||||||
handleCloseModal,
|
handleCloseModal,
|
||||||
itemsCompletion.length,
|
searchItemsResult.length,
|
||||||
search,
|
searchTerm,
|
||||||
selectedItem,
|
selectedItem,
|
||||||
setActiveCategory,
|
setActiveCategory,
|
||||||
],
|
],
|
||||||
@@ -240,7 +142,7 @@ function SearchModal({
|
|||||||
<TextBox
|
<TextBox
|
||||||
name='search'
|
name='search'
|
||||||
onChangeCallback={handleSearchInputChange}
|
onChangeCallback={handleSearchInputChange}
|
||||||
value={search}
|
value={searchTerm}
|
||||||
placeholder={t('common:search')}
|
placeholder={t('common:search')}
|
||||||
innerRef={autoFocusRef}
|
innerRef={autoFocusRef}
|
||||||
fieldClass={styles['search-input-field']}
|
fieldClass={styles['search-input-field']}
|
||||||
@@ -253,9 +155,9 @@ function SearchModal({
|
|||||||
canSearchCategory={canSearchCategory}
|
canSearchCategory={canSearchCategory}
|
||||||
setCanSearchCategory={handleCanSearchCategory}
|
setCanSearchCategory={handleCanSearchCategory}
|
||||||
/>
|
/>
|
||||||
{search.length > 0 && (
|
{searchTerm.length > 0 && (
|
||||||
<SearchList
|
<SearchList
|
||||||
items={itemsCompletion}
|
items={searchItemsResult}
|
||||||
selectedItem={selectedItem}
|
selectedItem={selectedItem}
|
||||||
setSelectedItem={setSelectedItem}
|
setSelectedItem={setSelectedItem}
|
||||||
noItem={<LabelSearchWithGoogle />}
|
noItem={<LabelSearchWithGoogle />}
|
||||||
@@ -264,7 +166,7 @@ function SearchModal({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type='submit'
|
type='submit'
|
||||||
disabled={!canSubmit}
|
disabled={searchTerm.length === 0}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
>
|
>
|
||||||
{t('common:confirm')}
|
{t('common:confirm')}
|
||||||
|
|||||||
19
src/hooks/useQueryParam.tsx
Normal file
19
src/hooks/useQueryParam.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
export default function useQueryParam(param: string) {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryParam = (router.query[param] as string) || '';
|
||||||
|
|
||||||
|
const clearQuery = () => {
|
||||||
|
if (!!queryParam) {
|
||||||
|
router.replace({
|
||||||
|
query: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryParam,
|
||||||
|
clearQuery,
|
||||||
|
};
|
||||||
|
}
|
||||||
43
src/hooks/useSearchItem.tsx
Normal file
43
src/hooks/useSearchItem.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { buildSearchItem, formatSearchItem } from 'lib/search';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { SearchItem, SearchResult } from 'types/types';
|
||||||
|
import useCategories from './useCategories';
|
||||||
|
|
||||||
|
export default function useSearchItem({
|
||||||
|
searchTerm = '',
|
||||||
|
disableLinks = false,
|
||||||
|
disableCategories = false,
|
||||||
|
}: {
|
||||||
|
searchTerm: string;
|
||||||
|
disableLinks?: boolean;
|
||||||
|
disableCategories?: boolean;
|
||||||
|
}) {
|
||||||
|
const { categories } = useCategories();
|
||||||
|
|
||||||
|
const itemsSearch = useMemo<SearchItem[]>(() => {
|
||||||
|
return categories.reduce((acc, category) => {
|
||||||
|
const categoryItem = buildSearchItem(category, 'category');
|
||||||
|
const items: SearchItem[] = category.links.map((link) =>
|
||||||
|
buildSearchItem(link, 'link'),
|
||||||
|
);
|
||||||
|
return [...acc, ...items, categoryItem];
|
||||||
|
}, [] as SearchItem[]);
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
const itemsCompletion = useMemo(() => {
|
||||||
|
return itemsSearch.reduce((acc, item) => {
|
||||||
|
const formattedItem = formatSearchItem(item, searchTerm);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(!disableLinks && item.type === 'link') ||
|
||||||
|
(!disableCategories && item.type === 'category')
|
||||||
|
) {
|
||||||
|
return formattedItem ? [...acc, formattedItem] : acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, [] as SearchResult[]);
|
||||||
|
}, [itemsSearch, searchTerm, disableLinks, disableCategories]);
|
||||||
|
|
||||||
|
return itemsCompletion;
|
||||||
|
}
|
||||||
56
src/lib/search.tsx
Normal file
56
src/lib/search.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import PATHS from 'constants/paths';
|
||||||
|
import {
|
||||||
|
CategoryWithLinks,
|
||||||
|
LinkWithCategory,
|
||||||
|
SearchItem,
|
||||||
|
SearchResult,
|
||||||
|
} from 'types/types';
|
||||||
|
|
||||||
|
export function buildSearchItem(
|
||||||
|
item: CategoryWithLinks | LinkWithCategory,
|
||||||
|
type: SearchItem['type'],
|
||||||
|
): SearchItem {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
url:
|
||||||
|
type === 'link'
|
||||||
|
? (item as LinkWithCategory).url
|
||||||
|
: `${PATHS.HOME}?categoryId=${item.id}`,
|
||||||
|
type,
|
||||||
|
category: type === 'link' ? (item as LinkWithCategory).category : 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 (let i = 0; i < lowerCaseName.length; i++) {
|
||||||
|
if (lowerCaseName[i] === 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,
|
||||||
|
category: item.category,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
|
|
||||||
.react-toggle--checked .react-toggle-thumb {
|
.react-toggle--checked .react-toggle-thumb {
|
||||||
left: 27px;
|
left: 27px;
|
||||||
border-color: #19ab27;
|
border-color: $light-blue;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle--focus .react-toggle-thumb {
|
.react-toggle--focus .react-toggle-thumb {
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ h6 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
button:not(.reset),
|
button:not(.reset),
|
||||||
a.btn:not(.reset) {
|
a.btn:not(.reset) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
Reference in New Issue
Block a user