diff --git a/components/Modal/Modal.tsx b/components/Modal/Modal.tsx new file mode 100644 index 0000000..1e93548 --- /dev/null +++ b/components/Modal/Modal.tsx @@ -0,0 +1,32 @@ +import { ReactNode } from "react"; +import { createPortal } from "react-dom"; + +import styles from "./modal.module.scss"; + +interface ModalProps { + close?: (...args: any) => void | Promise; + + title?: string; + children: ReactNode; + + showCloseBtn?: boolean; +} +export default function Modal({ + close, + title, + children, + showCloseBtn = true, +}: ModalProps) { + return createPortal( +
+
+
+

{title}

+ {showCloseBtn && } +
+
{children}
+
+
, + document.body + ); +} diff --git a/components/Modal/modal.module.scss b/components/Modal/modal.module.scss new file mode 100644 index 0000000..cde0bbe --- /dev/null +++ b/components/Modal/modal.module.scss @@ -0,0 +1,22 @@ +@import "../../styles/colors.scss"; + +.modal-wrapper { + z-index: 9999; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + background: rgba($black, 0.5); + backdrop-filter: blur(0.25em); + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +.modal-container { + background: $light-grey; + min-height: 250px; + min-width: 250px; +} diff --git a/components/SearchModal/SearchModal.tsx b/components/SearchModal/SearchModal.tsx new file mode 100644 index 0000000..5f5e858 --- /dev/null +++ b/components/SearchModal/SearchModal.tsx @@ -0,0 +1,183 @@ +import LinkTag from "next/link"; +import { ReactNode, useCallback, useMemo, useState } from "react"; +import { FcGoogle } from "react-icons/fc"; + +import useAutoFocus from "../../hooks/useAutoFocus"; + +import FormLayout from "../FormLayout"; +import LinkFavicon from "../Links/LinkFavicon"; +import Modal from "../Modal/Modal"; +import TextBox from "../TextBox"; + +import { Category, ItemComplete, Link } from "../../types"; + +import styles from "./search.module.scss"; + +export default function SearchModal({ + close, + handleSelectCategory, + categories, + favorites, + items, +}: { + close: any; + handleSelectCategory: (category: Category) => void; + categories: Category[]; + favorites: Link[]; + items: ItemComplete[]; +}) { + const autoFocusRef = useAutoFocus(); + + const [search, setSearch] = useState(""); + const canSubmit = useMemo(() => search.length > 0, [search]); + + const itemsCompletion = useMemo(() => { + if (search.length === 0) { + return []; + } + return items.filter((item) => + item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase().trim()) + ); + }, [items, search]); + + const handleSubmit = useCallback( + (event) => { + event.preventDefault(); + setSearch(""); + if (itemsCompletion.length > 0) { + const firstItem = itemsCompletion[0]; + if (firstItem.type === "link") { + window.open(firstItem.url); + } else { + const category = categories.find((c) => c.id === firstItem.id); + console.log(category); + if (category) { + handleSelectCategory(category); + } + } + } else { + window.open(`https://google.com/search?q=${encodeURI(search.trim())}`); + } + close(); + }, + [categories, close, handleSelectCategory, itemsCompletion, search] + ); + + return ( + + + setSearch(value)} + value={search} + placeholder="Rechercher" + innerRef={autoFocusRef} + fieldClass={styles["search-input-field"]} + /> + {search.length === 0 && favorites.length > 0 && ( + ({ + id: favorite.id, + name: favorite.name, + url: favorite.url, + type: "link", + }))} + noItem={

ajouter un favoris

} + /> + )} + {search.length > 0 && ( + ({ + id: item.id, + name: item.name, + url: item.url, + type: item.type, + }))} + noItem={ + + Recherche avec{" "} + + + oogle + + + } + /> + )} +
+
+ ); +} + +function ListItemComponent({ + items, + noItem, +}: { + items: ItemComplete[]; + noItem?: ReactNode; +}) { + return ( +
    + {items.length > 0 ? ( + items.map((item) => ( + + )) + ) : noItem ? ( + noItem + ) : ( + no item found + )} +
+ ); +} + +function ItemComponent({ item }: { item: ItemComplete }) { + const { name, type, url } = item; + return ( +
  • + + {type === "link" ? ( + + ) : ( + category + )} + {name} + +
  • + ); +} diff --git a/components/SearchModal/search.module.scss b/components/SearchModal/search.module.scss new file mode 100644 index 0000000..d0ece6d --- /dev/null +++ b/components/SearchModal/search.module.scss @@ -0,0 +1,7 @@ +.search-input-field { + & input { + border-radius: 1.5em; + padding: 1em; + border: 1px solid transparent; + } +} diff --git a/components/SideMenu/SideMenu.tsx b/components/SideMenu/SideMenu.tsx index 621c390..7ffd8a8 100644 --- a/components/SideMenu/SideMenu.tsx +++ b/components/SideMenu/SideMenu.tsx @@ -50,14 +50,20 @@ function MenuControls({ }) { return (
    - Créer categorie - - Créer categorie C - - - Créer lien - Créer lien L - +
    + Rechercher + S +
    +
    + Créer categorie + C +
    +
    + + Créer lien + + L +
    ); } diff --git a/hooks/useModal.tsx b/hooks/useModal.tsx new file mode 100644 index 0000000..567d8ae --- /dev/null +++ b/hooks/useModal.tsx @@ -0,0 +1,18 @@ +import { useState } from "react"; + +const useModal = () => { + const [isShowing, setIsShowing] = useState(false); + + const toggle = () => setIsShowing((value) => !value); + const open = () => setIsShowing(true); + const close = () => setIsShowing(false); + + return { + isShowing, + toggle, + open, + close, + }; +}; + +export default useModal; diff --git a/package-lock.json b/package-lock.json index 7022fbb..1cf924b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "@prisma/client": "^4.12.0", "@svgr/webpack": "^7.0.0", "axios": "^1.3.5", - "hotkeys-js": "^3.10.2", "next": "^13.3.0", "next-auth": "^4.22.0", "next-seo": "^6.0.0", @@ -17,6 +16,7 @@ "nprogress": "^0.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hotkeys-hook": "^4.4.0", "react-icons": "^4.8.0", "react-select": "^5.7.2", "sass": "^1.62.0", @@ -4571,11 +4571,6 @@ "react-is": "^16.7.0" } }, - "node_modules/hotkeys-js": { - "version": "3.10.2", - "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.10.2.tgz", - "integrity": "sha512-Z6vLmJTYzkbZZXlBkhrYB962Q/rZGc/WHQiyEGu9ZZVF7bAeFDjjDa31grWREuw9Ygb4zmlov2bTkPYqj0aFnQ==" - }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -5753,6 +5748,15 @@ "react": "^18.2.0" } }, + "node_modules/react-hotkeys-hook": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.4.0.tgz", + "integrity": "sha512-wOaCWLwgT/f895CMJrR9hmzVf+gfL8IpjWDXWXKngBp9i6Xqzf0tvLv4VI8l3Vlsg/cc4C/Iik3Ck76L/Hj0tw==", + "peerDependencies": { + "react": ">=16.8.1", + "react-dom": ">=16.8.1" + } + }, "node_modules/react-icons": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.8.0.tgz", diff --git a/package.json b/package.json index 50c8e71..4128588 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "@prisma/client": "^4.12.0", "@svgr/webpack": "^7.0.0", "axios": "^1.3.5", - "hotkeys-js": "^3.10.2", "next": "^13.3.0", "next-auth": "^4.22.0", "next-seo": "^6.0.0", @@ -19,6 +18,7 @@ "nprogress": "^0.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hotkeys-hook": "^4.4.0", "react-icons": "^4.8.0", "react-select": "^5.7.2", "sass": "^1.62.0", @@ -33,4 +33,4 @@ "prisma": "^4.12.0", "typescript": "5.0.4" } -} \ No newline at end of file +} diff --git a/pages/index.tsx b/pages/index.tsx index 4b395d3..f169790 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,22 +1,33 @@ -import hotkeys from "hotkeys-js"; import { useRouter } from "next/router"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +import useModal from "../hooks/useModal"; import Links from "../components/Links/Links"; +import SearchModal from "../components/SearchModal/SearchModal"; import SideMenu from "../components/SideMenu/SideMenu"; -import { Category, Link } from "../types"; +import { Category, ItemComplete, Link } from "../types"; import { prisma } from "../utils/back"; import { BuildCategory } from "../utils/front"; +const OPEN_SEARCH_KEY = "s"; +const CLOSE_SEARCH_KEY = "escape"; + +const OPEN_CREATE_LINK_KEY = "l"; +const OPEN_CREATE_CATEGORY_KEY = "c"; + interface HomeProps { categories: Category[]; favorites: Link[]; + items: ItemComplete[]; currentCategory: Category | undefined; } -function Home({ categories, favorites, currentCategory }: HomeProps) { +function Home({ categories, favorites, currentCategory, items }: HomeProps) { const router = useRouter(); + const modal = useModal(); const [categoryActive, setCategoryActive] = useState( currentCategory || categories?.[0] @@ -32,22 +43,32 @@ function Home({ categories, favorites, currentCategory }: HomeProps) { ); }; + const openSearchModal = useCallback( + (event) => { + event.preventDefault(); + modal.open(); + }, + [modal] + ); + const closeSearchModal = useCallback(() => modal.close(), [modal]); + const gotoCreateLink = useCallback(() => { router.push(`/link/create?categoryId=${categoryActive.id}`); }, [categoryActive.id, router]); + const gotoCreateCategory = useCallback(() => { router.push("/category/create"); }, [router]); - useEffect(() => { - hotkeys("l", gotoCreateLink); - hotkeys("c", gotoCreateCategory); + useHotkeys(OPEN_SEARCH_KEY, openSearchModal, { enabled: !modal.isShowing }); + useHotkeys(CLOSE_SEARCH_KEY, closeSearchModal, { enabled: modal.isShowing }); - return () => { - hotkeys.unbind("l", gotoCreateLink); - hotkeys.unbind("c", gotoCreateCategory); - }; - }, [gotoCreateCategory, gotoCreateLink]); + useHotkeys(OPEN_CREATE_LINK_KEY, gotoCreateLink, { + enabled: !modal.isShowing, + }); + useHotkeys(OPEN_CREATE_CATEGORY_KEY, gotoCreateCategory, { + enabled: !modal.isShowing, + }); return (
    @@ -58,6 +79,15 @@ function Home({ categories, favorites, currentCategory }: HomeProps) { categoryActive={categoryActive} /> + {modal.isShowing && ( + + )}
    ); } @@ -69,10 +99,31 @@ export async function getServerSideProps({ query }) { include: { links: true }, }); + const items = [] as ItemComplete[]; + const favorites = [] as Link[]; const categories = categoriesDB.map((categoryDB) => { const category = BuildCategory(categoryDB); - category.links.map((link) => (link.favorite ? favorites.push(link) : null)); + + category.links.map((link) => { + if (link.favorite) { + favorites.push(link); + } + items.push({ + id: link.id, + name: link.name, + url: link.url, + type: "link", + }); + }); + + items.push({ + id: category.id, + name: category.name, + url: `/?categoryId=${category.id}`, + type: "category", + }); + return category; }); @@ -92,6 +143,7 @@ export async function getServerSideProps({ query }) { props: { categories: JSON.parse(JSON.stringify(categories)), favorites: JSON.parse(JSON.stringify(favorites)), + items: JSON.parse(JSON.stringify(items)), currentCategory: currentCategory ? JSON.parse(JSON.stringify(currentCategory)) : null, diff --git a/pages/search.tsx b/pages/search.tsx new file mode 100644 index 0000000..fde3361 --- /dev/null +++ b/pages/search.tsx @@ -0,0 +1,218 @@ +import LinkTag from "next/link"; +import { useRouter } from "next/router"; +import { ReactNode, useCallback, useMemo, useState } from "react"; +import { FcGoogle } from "react-icons/fc"; + +import FormLayout from "../components/FormLayout"; +import TextBox from "../components/TextBox"; + +import useAutoFocus from "../hooks/useAutoFocus"; + +import LinkFavicon from "../components/Links/LinkFavicon"; + +import { Link } from "../types"; +import { prisma } from "../utils/back"; +import { BuildCategory } from "../utils/front"; + +import styles from "../styles/search.module.scss"; + +interface ItemComplete { + id: number; + name: string; + url: string; + type: "category" | "link"; +} + +interface SearchPageProps { + favorites: Link[]; + items: ItemComplete[]; +} + +export default function SearchPage({ favorites, items }: SearchPageProps) { + const router = useRouter(); + const autoFocusRef = useAutoFocus(); + + const [search, setSearch] = useState(""); + const canSubmit = useMemo(() => search.length > 0, [search]); + + const itemsCompletion = useMemo( + () => + search.length > 0 + ? items.filter((item) => + item.name + .toLocaleLowerCase() + .includes(search.toLocaleLowerCase().trim()) + ) + : [], + [items, search] + ); + console.log("itemsCompletion", itemsCompletion); + + const handleSubmit = useCallback( + (event) => { + event.preventDefault(); + setSearch(""); + if (itemsCompletion.length > 0) { + const firstItem = itemsCompletion[0]; + if (firstItem.type === "link") { + window.open(firstItem.url); + } else { + router.push(firstItem.url); + } + } else { + window.open(`https://google.com/search?q=${encodeURI(search.trim())}`); + } + }, + [itemsCompletion, router, search] + ); + + return ( + <> + + setSearch(value)} + value={search} + placeholder="Rechercher" + innerRef={autoFocusRef} + fieldClass={styles["search-input-field"]} + /> + {search.length === 0 && favorites.length > 0 && ( + ({ + id: favorite.id, + name: favorite.name, + url: favorite.url, + type: "link", + }))} + noItem={

    ajouter un favoris

    } + /> + )} + {search.length > 0 && ( + ({ + id: item.id, + name: item.name, + url: item.url, + type: item.type, + }))} + noItem={ + + Recherche avec{" "} + + + oogle + + + } + /> + )} +
    + + ); +} + +function ListItemComponent({ + items, + noItem, +}: { + items: ItemComplete[]; + noItem?: ReactNode; +}) { + return ( +
      + {items.length > 0 ? ( + items.map((item) => ( + + )) + ) : noItem ? ( + noItem + ) : ( + no item found + )} +
    + ); +} + +function ItemComponent({ item }: { item: ItemComplete }) { + const { name, type, url } = item; + return ( +
  • + + {type === "link" ? ( + + ) : ( + category + )} + {name} + +
  • + ); +} + +export async function getServerSideProps() { + const categoriesDB = await prisma.category.findMany({ + include: { links: true }, + }); + + const items = [] as ItemComplete[]; + + const favorites = [] as Link[]; + categoriesDB.forEach((categoryDB) => { + const category = BuildCategory(categoryDB); + + category.links.map((link) => { + if (link.favorite) { + favorites.push(link); + } + items.push({ + id: link.id, + name: link.name, + url: link.url, + type: "link", + }); + }); + + items.push({ + id: category.id, + name: category.name, + url: `/?categoryId=${category.id}`, + type: "category", + }); + }); + + return { + props: { + favorites: JSON.parse(JSON.stringify(favorites)), + items: JSON.parse(JSON.stringify(items)), + }, + }; +} diff --git a/types.d.ts b/types.d.ts index 068e9af..91675e3 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1,28 +1,35 @@ export interface Category { - id: number; - name: string; + id: number; + name: string; - links: Link[]; - nextCategoryId: number; + links: Link[]; + nextCategoryId: number; - createdAt: Date; - updatedAt: Date; + createdAt: Date; + updatedAt: Date; } export interface Link { + id: number; + + name: string; + url: string; + + category: { id: number; - name: string; - url: string; + }; - category: { - id: number; - name: string; - } + nextLinkId: number; + favorite: boolean; - nextLinkId: number; - favorite: boolean; + createdAt: Date; + updatedAt: Date; +} - createdAt: Date; - updatedAt: Date; -} \ No newline at end of file +export interface ItemComplete { + id: number; + name: string; + url: string; + type: "category" | "link"; +}