Leo 3 лет назад
Родитель
Сommit
9850e61bfd

+ 3 - 2
.eslintrc.json

@@ -1,6 +1,7 @@
 {
 {
-  "extends": "next/core-web-vitals",
+  "extends": ["next/core-web-vitals", "plugin:jsx-a11y/recommended"],
   "rules": {
   "rules": {
-    "@next/next/no-img-element": "off"
+    "@next/next/no-img-element": "off",
+    "@typescript-eslint/no-unused-vars": "off"
   }
   }
 }
 }

+ 2 - 7
components/NovelCover/index.tsx

@@ -12,13 +12,8 @@ interface NovelCoverProps {
 }
 }
 
 
 export default function NovelCover(props: NovelCoverProps) {
 export default function NovelCover(props: NovelCoverProps) {
-  let {
-    component: Component = Link,
-    className,
-    alt = "",
-    src = "",
-    ...other
-  } = props;
+  let { component: Component = Link } = props;
+  const { className, alt = "", src = "", ...other } = props;
 
 
   if (!other.href) {
   if (!other.href) {
     Component = "div";
     Component = "div";

+ 98 - 0
components/Pagination/PaginationItem.tsx

@@ -0,0 +1,98 @@
+import clsx from "clsx";
+import { UsePaginationItem } from "libs/hooks/usePagination";
+import { ElementType, forwardRef, ReactNode } from "react";
+
+import styles from "./index.module.scss";
+
+export interface PaginationItemProps extends Docs {
+  className?: string;
+  component?: ElementType;
+  /**
+   * The components used for first, last, next & previous item type
+   * @default {
+   *   first: FirstPageIcon,
+   *   last: LastPageIcon,
+   *   next: NavigateNextIcon,
+   *   previous: NavigateBeforeIcon,
+   * }
+   */
+  components?: {
+    first?: string;
+    last?: string;
+    next?: string;
+    previous?: string;
+  };
+  /**
+   * If `true`, the component is disabled.
+   * @default false
+   */
+  disabled?: boolean;
+  /**
+   * The current page number.
+   */
+  page?: ReactNode;
+  /**
+   * If `true` the pagination item is selected.
+   * @default false
+   */
+  selected?: boolean;
+  /**
+   * The type of pagination item.
+   * @default 'page'
+   */
+  type?: UsePaginationItem["type"];
+}
+
+const PaginationItem = forwardRef<HTMLDivElement, PaginationItemProps>(
+  function PaginationItem(props, ref) {
+    const {
+      className,
+      component: PaginationItemPage = "button",
+      components = {
+        previous: "navigate-prev",
+        next: "navigate-next",
+        first: "first-page",
+        last: "last-page",
+      },
+      disabled = false,
+      page,
+      selected = false,
+      type = "page",
+      ...other
+    } = props;
+
+    const normalizedIcons = {
+      previous: components.previous || "navigate-prev",
+      next: components.next || "navigate-next",
+      first: components.first || "first-page",
+      last: components.last || "last-page",
+    };
+
+    const icon = normalizedIcons[type as keyof typeof normalizedIcons];
+
+    return type === "start-ellipsis" || type === "end-ellipsis" ? (
+      <div ref={ref} className={clsx(styles.text, className)}>
+        …
+      </div>
+    ) : (
+      <PaginationItemPage
+        ref={ref}
+        disabled={disabled}
+        className={clsx(styles.item, className, {
+          [styles.selected]: selected,
+          [styles.disabled]: disabled,
+        })}
+        {...other}
+      >
+        {type === "page" && page}
+        {icon ? (
+          <svg className={styles.icon}>
+            <use xlinkHref={`/icons.svg#${icon}`}></use>
+          </svg>
+        ) : null}
+      </PaginationItemPage>
+    );
+  }
+);
+
+export default PaginationItem;

+ 31 - 0
components/Pagination/index.module.scss

@@ -0,0 +1,31 @@
+.pages {
+  @apply flex justify-center;
+  .inner {
+    @apply flex flex-wrap items-center p-0 m-0 list-none;
+  }
+  .text,
+  .item {
+    @apply inline-flex items-center justify-center relative outline-0 border-0 select-none align-middle appearance-none no-underline text-sm rounded-2xl text-center box-border min-w-[32px] h-8 py-0 px-1.5 mx-1;
+  }
+  .item {
+    @apply cursor-pointer;
+    @apply hover:bg-black hover:bg-opacity-5;
+
+    &.selected {
+      @apply text-white bg-blue-600 hover:bg-blue-800 hover:bg-opacity-100;
+    }
+    &.disabled {
+      @apply cursor-default pointer-events-none opacity-40;
+    }
+    :global(.dark) & {
+      @apply hover:bg-white hover:bg-opacity-10;
+
+      &.selected {
+        @apply text-white bg-blue-700 hover:bg-blue-800 hover:bg-opacity-100;
+      }
+    }
+  }
+  .icon {
+    @apply select-none w-4 h-4 inline-block fill-current flex-shrink-0 text-xl -mx-2;
+  }
+}

+ 89 - 0
components/Pagination/index.tsx

@@ -0,0 +1,89 @@
+import clsx from "clsx";
+import usePagination, {
+  UsePaginationItem,
+  UsePaginationProps,
+} from "libs/hooks/usePagination";
+
+import PaginationItem, { PaginationItemProps } from "./PaginationItem";
+
+import styles from "./index.module.scss";
+import { forwardRef } from "react";
+
+export interface PaginationProps extends UsePaginationProps {
+  className?: string;
+  /**
+   * Accepts a function which returns a string value that provides a user-friendly name for the current page.
+   * This is important for screen reader users.
+   *
+   * For localization purposes, you can use the provided [translations](/material-ui/guides/localization/).
+   * @param {string} type The link or button type to format ('page' | 'first' | 'last' | 'next' | 'previous'). Defaults to 'page'.
+   * @param {number} page The page number to format.
+   * @param {bool} selected If true, the current page is selected.
+   * @returns {string}
+   */
+  getItemAriaLabel?: typeof defaultGetAriaLabel;
+  /**
+   * Render the item.
+   * @param {UsePaginationItem} params The props to spread on a PaginationItem.
+   * @returns {ReactNode}
+   * @default (item) => <PaginationItem {...item} />
+   */
+  renderItem?: (params: UsePaginationItem) => React.ReactNode;
+}
+
+function defaultGetAriaLabel(
+  type: UsePaginationItem["type"],
+  page: number | null,
+  selected: boolean
+) {
+  if (type === "page") {
+    return `${selected ? "" : "Go to "}page ${page}`;
+  }
+  return `Go to ${type} page`;
+}
+
+const Pagination = forwardRef<HTMLDivElement, PaginationProps>(
+  function Pagination(props, ref) {
+    const {
+      boundaryCount = 1,
+      className,
+      count = 1,
+      defaultPage = 1,
+      disabled = false,
+      getItemAriaLabel = defaultGetAriaLabel,
+      hideNextButton = false,
+      hidePrevButton = false,
+      onChange,
+      renderItem = (item: PaginationItemProps) => <PaginationItem {...item} />,
+
+      ...other
+    } = props;
+
+    const { items } = usePagination({ ...props, componentName: "Pagination" });
+
+    return (
+      <nav
+        aria-label="pagination navigation"
+        className={clsx(styles.pages, className)}
+        ref={ref}
+        {...other}
+      >
+        <ul className={styles.inner}>
+          {items.map((item, index) => (
+            <li key={index}>
+              {renderItem({
+                ...item,
+                "aria-label": getItemAriaLabel(
+                  item.type,
+                  item.page,
+                  item.selected
+                ),
+              })}
+            </li>
+          ))}
+        </ul>
+      </nav>
+    );
+  }
+);
+export default Pagination;

+ 0 - 3
components/VirtualScroll/index.tsx

@@ -5,7 +5,6 @@ import {
   useCallback,
   useCallback,
   useEffect,
   useEffect,
   useMemo,
   useMemo,
-  useRef,
   useState,
   useState,
 } from "react";
 } from "react";
 import styles from "./scroll.module.scss";
 import styles from "./scroll.module.scss";
@@ -52,7 +51,6 @@ export default function VirtualScroll(props: VirtualScrollProps) {
   const setWrapRef = useCallback((ele: HTMLDivElement) => {
   const setWrapRef = useCallback((ele: HTMLDivElement) => {
     if (ele) {
     if (ele) {
       setWrapElement(ele);
       setWrapElement(ele);
-      // ele.scrollTo(0, scrollY);
       if (ele) setWrapHeight(ele.clientHeight);
       if (ele) setWrapHeight(ele.clientHeight);
     } else {
     } else {
       setWrapElement(null);
       setWrapElement(null);
@@ -76,7 +74,6 @@ export default function VirtualScroll(props: VirtualScrollProps) {
       setScrollY(y);
       setScrollY(y);
       wrapElement.scrollTo({ left: 0, top: y, behavior: "auto" });
       wrapElement.scrollTo({ left: 0, top: y, behavior: "auto" });
     }
     }
-    return () => {};
   }, [itemHeight, startIndex, wrapElement]);
   }, [itemHeight, startIndex, wrapElement]);
 
 
   return (
   return (

+ 17 - 4
components/common/Header/index.tsx

@@ -43,7 +43,7 @@ export default function Header() {
                     onClick={closeMenu}
                     onClick={closeMenu}
                   >
                   >
                     <svg width="24" height="24">
                     <svg width="24" height="24">
-                      <use href="/icons.svg#browse"></use>
+                      <use xlinkHref="/icons.svg#books"></use>
                     </svg>
                     </svg>
                     <strong>Genre</strong>
                     <strong>Genre</strong>
                   </Link>
                   </Link>
@@ -59,11 +59,24 @@ export default function Header() {
                     </ul>
                     </ul>
                   </div>
                   </div>
                 </li>
                 </li>
+                <li>
+                  <Link
+                    className="menu-item"
+                    href="/library"
+                    title="Library"
+                    onClick={closeMenu}
+                  >
+                    <svg width="24" height="24">
+                      <use xlinkHref="/icons.svg#bookmark"></use>
+                    </svg>
+                    <strong>Library</strong>
+                  </Link>
+                </li>
               </ul>
               </ul>
             </nav>
             </nav>
             {/* <form action="./search" className="search-input">
             {/* <form action="./search" className="search-input">
               <svg className="icon-search">
               <svg className="icon-search">
-                <use href="/icons.svg#search"></use>
+                <use xlinkHref="/icons.svg#search"></use>
               </svg>
               </svg>
               <input type="search" name="q" placeholder="Search" />
               <input type="search" name="q" placeholder="Search" />
             </form> */}
             </form> */}
@@ -71,10 +84,10 @@ export default function Header() {
           <div className="buttons">
           <div className="buttons">
             <button className="btn" onClick={() => toggleTheme()}>
             <button className="btn" onClick={() => toggleTheme()}>
               <svg className="icon-sun" viewBox="0 0 16 16">
               <svg className="icon-sun" viewBox="0 0 16 16">
-                <use href="/icons.svg#sun"></use>
+                <use xlinkHref="/icons.svg#sun"></use>
               </svg>
               </svg>
               <svg className="icon-moon" viewBox="0 0 16 16">
               <svg className="icon-moon" viewBox="0 0 16 16">
-                <use href="/icons.svg#moon"></use>
+                <use xlinkHref="/icons.svg#moon"></use>
               </svg>
               </svg>
             </button>
             </button>
             <button className="btn menu-btn" onClick={toggleMenu}>
             <button className="btn menu-btn" onClick={toggleMenu}>

+ 73 - 0
components/library/LibrayList/Item.tsx

@@ -0,0 +1,73 @@
+import NovelCover from "components/NovelCover";
+import { delHistoryItem, putHistoryItem } from "libs/db/history";
+import Link from "next/link";
+
+import styles from "./index.module.scss";
+
+interface ItemProps {
+  type: string;
+  data: HistoryItem;
+  onRemoved: () => void;
+}
+
+export default function Item({ type, data, onRemoved }: ItemProps) {
+  const isHistory = type === "History";
+  const url = `/novel/${isHistory ? data.chapterUri : data.uri}`;
+
+  const handleRemoveItem = () => {
+    const item = { ...data };
+    if (isHistory) {
+      item.isReading = 0;
+    } else {
+      item.isFavorite = 0;
+    }
+
+    (!item.isReading && !item.isFavorite
+      ? delHistoryItem(data.uri)
+      : putHistoryItem(data)
+    ).then(onRemoved);
+  };
+
+  return (
+    <li key={data.uri} className={styles.item}>
+      <NovelCover
+        className={styles.cover}
+        component={Link}
+        href={url}
+        title={`${data.name} ${data.chapterName}`}
+        src={data.img}
+      />
+      <div className={styles.body}>
+        <div className={styles.title}>
+          <Link href={url}>{data.name}</Link>
+        </div>
+        <div className={styles.progressTitle}>
+          <Link href={url}>
+            You have read {data.chapterIndex}/{data.chapterCount}
+          </Link>
+        </div>
+        <div role="progressbar" className={styles.progressbar}>
+          <div
+            style={{
+              width: `${(data.chapterIndex / data.chapterCount) * 100}%`,
+            }}
+          ></div>
+        </div>
+        <div className={styles.btns}>
+          <Link className={styles.btn} href={url}>
+            Continue Reading
+          </Link>
+        </div>
+      </div>
+      <button
+        className={styles.close}
+        onClick={handleRemoveItem}
+        title={isHistory ? "Remove History" : "Remove Favorite"}
+      >
+        <svg>
+          <use xlinkHref="/icons.svg#close"></use>
+        </svg>
+      </button>
+    </li>
+  );
+}

+ 48 - 0
components/library/LibrayList/index.module.scss

@@ -0,0 +1,48 @@
+.list {
+  @apply grid grid-cols-1 gap-4 my-5 lg:grid-cols-2;
+  .item {
+    @apply flex flex-1 rounded-[8px] border-[0.5px] border-black border-opacity-[0.1] bg-white p-3 shadow-lg lg:p-4 relative;
+    :global(.dark) & {
+      //   @apply bg-[#131415];
+      @apply bg-zinc-900;
+    }
+    .cover {
+      @apply w-20 mr-2.5 lg:w-28 lg:mr-5;
+    }
+    .body {
+      @apply w-0 flex-1 flex flex-col pr-8;
+    }
+    .title {
+      @apply h-0 overflow-hidden flex-1 line-clamp-2 text-base font-extrabold lg:text-lg;
+    }
+    .progressTitle {
+      @apply text-xs opacity-80 lg:text-base;
+    }
+    .progressbar {
+      @apply w-full h-1 bg-neutral-200 mt-1 rounded-full;
+      :global(.dark) & {
+        @apply bg-neutral-700;
+      }
+      div {
+        @apply h-full bg-blue-500 rounded-full;
+      }
+    }
+    .btns {
+      @apply mt-2.5 lg:mt-4;
+    }
+    .btn {
+      @apply inline-block text-xs border border-blue-700 px-3 py-2 rounded hover:bg-blue-700/10 lg:text-base;
+    }
+    .close {
+      @apply absolute right-0 top-0 p-2;
+      svg {
+        @apply fill-current w-6 h-6;
+      }
+      &:hover {
+        svg {
+          @apply fill-blue-700;
+        }
+      }
+    }
+  }
+}

+ 58 - 0
components/library/LibrayList/index.tsx

@@ -0,0 +1,58 @@
+import Pagination from "components/Pagination";
+import useDbList from "libs/hooks/useDbList";
+
+import styles from "./index.module.scss";
+import Item from "./Item";
+
+interface LibrayListProps {
+  type: string;
+}
+
+export default function LibrayList({ type }: LibrayListProps) {
+  const isHistory = type === "History";
+  const [historyList, historyPages, fetchHistory] = useDbList(
+    "history",
+    isHistory ? "historyIsReading" : "historyIsFavorite",
+    "prev",
+    isHistory
+      ? [
+          [1, 0],
+          [1, new Date()],
+        ]
+      : [[1], [1]]
+  );
+
+  const handlePageChange = (
+    event: React.ChangeEvent<unknown>,
+    value: number
+  ) => {
+    fetchHistory(value - 1);
+  };
+
+  const refreshList = () => {
+    fetchHistory(historyPages.page);
+  };
+
+  return (
+    <>
+      <ul className={styles.list}>
+        {historyList.map((item) => (
+          <Item
+            key={item.uri}
+            data={item}
+            type={type}
+            onRemoved={refreshList}
+          />
+        ))}
+      </ul>
+      {historyPages.total > historyPages.pageSize ? (
+        <Pagination
+          className="mt-5"
+          count={Math.ceil(historyPages.total / historyPages.pageSize)}
+          page={historyPages.page}
+          onChange={handlePageChange}
+        />
+      ) : null}
+    </>
+  );
+}

+ 5 - 5
components/novel/Settings/index.tsx

@@ -74,7 +74,7 @@ const Settings = (props: SettingsProps) => {
           onClick={() => onChangeSettings("")}
           onClick={() => onChangeSettings("")}
         >
         >
           <svg className={styles["icon-close"]}>
           <svg className={styles["icon-close"]}>
-            <use href="/icons.svg#close"></use>
+            <use xlinkHref="/icons.svg#close"></use>
           </svg>
           </svg>
         </button>
         </button>
       </div>
       </div>
@@ -85,7 +85,7 @@ const Settings = (props: SettingsProps) => {
           onClick={() => toggleTheme("light")}
           onClick={() => toggleTheme("light")}
         >
         >
           <svg className="icon-sun  mr-2">
           <svg className="icon-sun  mr-2">
-            <use href="/icons.svg#sun"></use>
+            <use xlinkHref="/icons.svg#sun"></use>
           </svg>
           </svg>
           Light
           Light
         </button>
         </button>
@@ -94,7 +94,7 @@ const Settings = (props: SettingsProps) => {
           onClick={() => toggleTheme("dark")}
           onClick={() => toggleTheme("dark")}
         >
         >
           <svg className="icon-moon mr-2">
           <svg className="icon-moon mr-2">
-            <use href="/icons.svg#moon"></use>
+            <use xlinkHref="/icons.svg#moon"></use>
           </svg>
           </svg>
           Dark
           Dark
         </button>
         </button>
@@ -122,7 +122,7 @@ const Settings = (props: SettingsProps) => {
       <div className="flex items-center">
       <div className="flex items-center">
         <button className="mr-5" onClick={() => handleChangeFontSize(-1)}>
         <button className="mr-5" onClick={() => handleChangeFontSize(-1)}>
           <svg className="w-5 h-5">
           <svg className="w-5 h-5">
-            <use href="/icons.svg#font-dn"></use>
+            <use xlinkHref="/icons.svg#font-dn"></use>
           </svg>
           </svg>
         </button>
         </button>
         <SliderUnstyled
         <SliderUnstyled
@@ -139,7 +139,7 @@ const Settings = (props: SettingsProps) => {
         />
         />
         <button className="ml-5" onClick={() => handleChangeFontSize(1)}>
         <button className="ml-5" onClick={() => handleChangeFontSize(1)}>
           <svg className="w-5 h-5">
           <svg className="w-5 h-5">
-            <use href="/icons.svg#font-up"></use>
+            <use xlinkHref="/icons.svg#font-up"></use>
           </svg>
           </svg>
         </button>
         </button>
       </div>
       </div>

+ 13 - 18
components/novel/Toc/index.tsx

@@ -1,49 +1,44 @@
 import clsx from "clsx";
 import clsx from "clsx";
 import Link from "next/link";
 import Link from "next/link";
-import useGet from "libs/hooks/useGet";
 import { useCallback, useMemo } from "react";
 import { useCallback, useMemo } from "react";
 import VirtualScroll from "components/VirtualScroll";
 import VirtualScroll from "components/VirtualScroll";
 
 
 import styles from "styles/chapter.module.scss";
 import styles from "styles/chapter.module.scss";
 
 
 interface TocProps {
 interface TocProps {
-  novel: string;
-  chapter: string;
   className?: string;
   className?: string;
+  currentUri: string;
+  chapters: ChapterListData["chapters"];
   onChangeSettings: (type?: string) => void;
   onChangeSettings: (type?: string) => void;
 }
 }
 
 
 const Toc = (props: TocProps) => {
 const Toc = (props: TocProps) => {
-  const { novel, chapter, className, onChangeSettings } = props;
-
-  const { data: { data: chapters } = { data: null } } = useGet<ChapterListData>(
-    `/api/novel/${novel}/chapters`
-  );
+  const { currentUri, className, chapters, onChangeSettings } = props;
 
 
   const startIndex = useMemo(() => {
   const startIndex = useMemo(() => {
-    const uri = `${novel}/${chapter}`;
-    if (chapters && Array.isArray(chapters.chapters)) {
-      const idx = chapters.chapters.findIndex((item) => item.uri === uri);
+    const uri = currentUri;
+    if (chapters && Array.isArray(chapters)) {
+      const idx = chapters.findIndex((item) => item.uri === uri);
       if (idx > -1) {
       if (idx > -1) {
         return idx;
         return idx;
       }
       }
     }
     }
     return 0;
     return 0;
-  }, [novel, chapter, chapters]);
+  }, [currentUri, chapters]);
 
 
   const getTocItem = useCallback(
   const getTocItem = useCallback(
     (offset: number, limit: number) => {
     (offset: number, limit: number) => {
       if (!chapters) return [];
       if (!chapters) return [];
 
 
       const list: JSX.Element[] = [];
       const list: JSX.Element[] = [];
-      const len = Math.min(limit + offset, chapters.chapters.length);
+      const len = Math.min(limit + offset, chapters.length);
       for (let i = offset; i < len; i++) {
       for (let i = offset; i < len; i++) {
-        const item = chapters.chapters[i];
+        const item = chapters[i];
         list.push(
         list.push(
           <li
           <li
             key={item.id}
             key={item.id}
             className={clsx({
             className={clsx({
-              [styles["current"]]: item.uri === `${novel}/${chapter}`,
+              [styles["current"]]: item.uri === currentUri,
             })}
             })}
           >
           >
             <Link
             <Link
@@ -61,7 +56,7 @@ const Toc = (props: TocProps) => {
       }
       }
       return list;
       return list;
     },
     },
-    [chapter, chapters, novel, onChangeSettings]
+    [chapters, currentUri, onChangeSettings]
   );
   );
 
 
   return (
   return (
@@ -73,7 +68,7 @@ const Toc = (props: TocProps) => {
           onClick={() => onChangeSettings("")}
           onClick={() => onChangeSettings("")}
         >
         >
           <svg className={styles["icon-close"]}>
           <svg className={styles["icon-close"]}>
-            <use href="/icons.svg#close"></use>
+            <use xlinkHref="/icons.svg#close"></use>
           </svg>
           </svg>
         </button>
         </button>
       </div>
       </div>
@@ -81,7 +76,7 @@ const Toc = (props: TocProps) => {
         <VirtualScroll
         <VirtualScroll
           component="ol"
           component="ol"
           itemHeight={32}
           itemHeight={32}
-          total={chapters.chapters.length}
+          total={chapters.length}
           getItems={getTocItem}
           getItems={getTocItem}
           className={styles["toolbar-scrollbar"]}
           className={styles["toolbar-scrollbar"]}
           startIndex={startIndex}
           startIndex={startIndex}

+ 36 - 0
libs/db/getDb.ts

@@ -0,0 +1,36 @@
+import { openDB, DBSchema, StoreNames } from "idb";
+
+export interface NovelDb extends DBSchema {
+  history: {
+    value: HistoryItem;
+    key: string;
+    indexes: {
+      uri: string;
+      historyUpdateTime: Date;
+      historyIsReading: number;
+      historyIsFavorite: number;
+    };
+  };
+}
+
+export default function getDb() {
+  return openDB<NovelDb>("NovelDit", 1, {
+    upgrade(db) {
+      const historyStore = db.createObjectStore("history", {
+        keyPath: "uri",
+      });
+
+      historyStore.createIndex("historyUpdateTime", "readTime", {
+        unique: false,
+      });
+
+      historyStore.createIndex("historyIsReading", ["isReading", "readTime"], {
+        unique: false,
+      });
+
+      historyStore.createIndex("historyIsFavorite", ["isFavorite"], {
+        unique: false,
+      });
+    },
+  });
+}

+ 137 - 0
libs/db/history.ts

@@ -0,0 +1,137 @@
+import { openDB, IndexNames, StoreNames, StoreValue } from "idb";
+import getDb, { NovelDb } from "./getDb";
+
+export async function putItem<N extends StoreNames<NovelDb>>(
+  storeNames: N,
+  item: StoreValue<NovelDb, N>
+) {
+  const db = await getDb();
+
+  const tx = db.transaction(storeNames, "readwrite");
+
+  const store = tx.store;
+
+  store.put(item);
+
+  return tx.done;
+}
+
+export async function delItem<N extends StoreNames<NovelDb>>(
+  storeNames: N,
+  key: NovelDb[N]["key"]
+) {
+  const db = await getDb();
+
+  const tx = db.transaction(storeNames, "readwrite");
+
+  const store = tx.store;
+
+  store.delete(key);
+
+  return tx.done;
+}
+
+export async function getItem<N extends StoreNames<NovelDb>>(
+  storeNames: N,
+  key: NovelDb[N]["key"]
+) {
+  const db = await getDb();
+
+  const tx = db.transaction(storeNames, "readonly");
+
+  const store = tx.store;
+
+  return store.get(key);
+}
+
+export const putHistoryItem = (item: HistoryItem) => putItem("history", item);
+
+export const delHistoryItem = (key: string) => delItem("history", key);
+
+export const getHistoryItem = (key: string) => getItem("history", key);
+
+export async function getList<
+  N extends StoreNames<NovelDb>,
+  R extends [any, any, boolean?, boolean?]
+>(
+  storeName: N,
+  index?: IndexNames<NovelDb, N>,
+  page = 0,
+  pageSize = 20,
+  direction: IDBCursorDirection = "next",
+  rage?: R
+): Promise<PageList<NovelDb[N]["value"]>> {
+  const db = await getDb();
+
+  const tx = db.transaction(storeName, "readonly");
+
+  const store = tx.store;
+
+  const keyRangeValue = rage ? IDBKeyRange.bound.apply(void 0, rage) : null;
+  let [cursor, total] = await Promise.all([
+    index
+      ? store.index(index).openCursor(keyRangeValue, direction)
+      : store.openCursor(),
+    store.count(),
+  ]);
+  type ValueItem = StoreValue<NovelDb, N>;
+  const list: ValueItem[] = [];
+
+  const res: PageList<ValueItem> = {
+    list,
+    total,
+    page,
+    pageSize,
+  };
+
+  if (!cursor) return res;
+
+  if (page > 0) {
+    cursor = await cursor.advance(page * pageSize);
+  }
+
+  while (cursor) {
+    list.push(cursor.value);
+    if (pageSize === list.length) {
+      break;
+    }
+    cursor = await cursor.continue();
+  }
+
+  return res;
+}
+
+function genGetList<
+  N extends StoreNames<NovelDb>,
+  R extends [any, any, boolean?, boolean?]
+>(
+  storeName: N,
+  index?: IndexNames<NovelDb, N>,
+  direction: IDBCursorDirection = "next",
+  defaultRange?: R
+) {
+  return async function (
+    page = 0,
+    pageSize = 20,
+    rage = defaultRange
+  ): Promise<PageList<NovelDb[N]["value"]>> {
+    return getList(storeName, index, page, pageSize, direction, rage);
+  };
+}
+
+export const getHistoryList = genGetList(
+  "history",
+  "historyIsReading",
+  "prev",
+  [
+    [1, 0],
+    [1, new Date()],
+  ]
+);
+
+export const getFavoriteList = genGetList(
+  "history",
+  "historyIsFavorite",
+  "prev",
+  [1, 1]
+);

+ 51 - 0
libs/hooks/useDbItem.ts

@@ -0,0 +1,51 @@
+import { StoreNames } from "idb";
+import { NovelDb } from "libs/db/getDb";
+import { delItem, getItem, putItem } from "libs/db/history";
+import { useCallback, useEffect, useState } from "react";
+
+export default function useDbItem<N extends StoreNames<NovelDb>>(
+  name: N,
+  defaultKey?: string
+) {
+  const [key, setKey] = useState(defaultKey);
+  const [value, setValue] = useState<NovelDb[N]["value"]>();
+
+  const updateValue = useCallback(() => {
+    if (!key) {
+      setValue(void 0);
+      return;
+    }
+    getItem(name, key).then((value) => {
+      setValue(value);
+    });
+  }, [name, key]);
+
+  const put = useCallback(
+    (data: NovelDb[N]["value"]) =>
+      putItem(name, data)
+        .then(() => setValue(data))
+        .catch((e) => {}),
+    [name]
+  );
+
+  const del = useCallback(
+    (key: NovelDb[N]["key"]) =>
+      delItem(name, key)
+        .then(() => setValue(void 0))
+        .catch((e) => {}),
+    [name]
+  );
+
+  useEffect(() => {
+    updateValue();
+  }, [updateValue]);
+
+  return [value, put, del, setKey] as [
+    typeof value,
+    typeof put,
+    typeof del,
+    typeof setKey
+  ];
+}
+
+export const useHistory = (key?: string) => useDbItem("history", key);

+ 56 - 0
libs/hooks/useDbList.ts

@@ -0,0 +1,56 @@
+import { useCallback, useEffect, useState } from "react";
+import { openDB, IndexNames, StoreNames, StoreValue } from "idb";
+import { NovelDb } from "libs/db/getDb";
+import { getList } from "libs/db/history";
+
+export default function useDbList<
+  N extends StoreNames<NovelDb>,
+  R extends [any, any, boolean?, boolean?]
+>(
+  storeName: N,
+  index?: IndexNames<NovelDb, N>,
+  direction: IDBCursorDirection = "next",
+  rage?: R
+) {
+  const [isLoading, setIsLoading] = useState(false);
+  const [
+    { list = [], ...pages } = { page: 0, pageSize: 0, total: 0 } as PageList<
+      NovelDb[N]["value"]
+    >,
+    setData,
+  ] = useState<PageList<NovelDb[N]["value"]>>();
+
+  const handleGetList = useCallback(
+    (page: number) => {
+      if (isLoading) return;
+      setIsLoading(true);
+      getList(storeName, index, page, 20, direction, rage).then((data) => {
+        setData(data);
+        setIsLoading(false);
+      });
+    },
+    [storeName, direction, index, isLoading, rage]
+  );
+
+  useEffect(() => {
+    if (!isLoading && pages.pageSize === 0) {
+      handleGetList(0);
+    }
+  }, [handleGetList, isLoading, pages.pageSize]);
+
+  return [list, pages, handleGetList, isLoading] as [
+    typeof list,
+    typeof pages,
+    typeof handleGetList,
+    typeof isLoading
+  ];
+}
+
+export const useHistoryList = () =>
+  useDbList("history", "historyIsReading", "prev", [
+    [1, 0],
+    [1, new Date()],
+  ]);
+
+export const useFavoriteList = () =>
+  useDbList("history", "historyIsFavorite", "prev", [1, 1]);

+ 234 - 0
libs/hooks/usePagination.ts

@@ -0,0 +1,234 @@
+import { unstable_useControlled as useControlled } from "@mui/utils";
+import { ChangeEvent, ReactEventHandler } from "react";
+
+export interface UsePaginationProps {
+  /**
+   * Number of always visible pages at the beginning and end.
+   * @default 1
+   */
+  boundaryCount?: number;
+  /**
+   * The name of the component where this hook is used.
+   */
+  componentName?: string;
+  /**
+   * The total number of pages.
+   * @default 1
+   */
+  count?: number;
+  /**
+   * The page selected by default when the component is uncontrolled.
+   * @default 1
+   */
+  defaultPage?: number;
+  /**
+   * If `true`, the component is disabled.
+   * @default false
+   */
+  disabled?: boolean;
+  /**
+   * If `true`, hide the next-page button.
+   * @default false
+   */
+  hideNextButton?: boolean;
+  /**
+   * If `true`, hide the previous-page button.
+   * @default false
+   */
+  hidePrevButton?: boolean;
+  /**
+   * Callback fired when the page is changed.
+   *
+   * @param {React.ChangeEvent<unknown>} event The event source of the callback.
+   * @param {number} page The page selected.
+   */
+  onChange?: (event: React.ChangeEvent<unknown>, page: number) => void;
+  /**
+   * The current page.
+   */
+  page?: number;
+  /**
+   * If `true`, show the first-page button.
+   * @default false
+   */
+  showFirstButton?: boolean;
+  /**
+   * If `true`, show the last-page button.
+   * @default false
+   */
+  showLastButton?: boolean;
+  /**
+   * Number of always visible pages before and after the current page.
+   * @default 1
+   */
+  siblingCount?: number;
+}
+
+export interface UsePaginationItem {
+  onClick: ReactEventHandler;
+  type:
+    | "page"
+    | "first"
+    | "last"
+    | "next"
+    | "previous"
+    | "start-ellipsis"
+    | "end-ellipsis";
+  page: number | null;
+  selected: boolean;
+  disabled: boolean;
+}
+
+export interface UsePaginationResult {
+  items: UsePaginationItem[];
+}
+
+export default function usePagination(
+  props: UsePaginationProps = {}
+): UsePaginationResult {
+  // keep default values in sync with @default tags in Pagination.propTypes
+  const {
+    boundaryCount = 1,
+    componentName = "usePagination",
+    count = 1,
+    defaultPage = 1,
+    disabled = false,
+    hideNextButton = false,
+    hidePrevButton = false,
+    onChange: handleChange,
+    page: pageProp,
+    showFirstButton = false,
+    showLastButton = false,
+    siblingCount = 1,
+    ...other
+  } = props;
+
+  const [page, setPageState] = useControlled({
+    controlled: pageProp,
+    default: defaultPage,
+    name: componentName,
+    state: "page",
+  });
+
+  const handleClick = (event: ChangeEvent<unknown>, value: number) => {
+    if (!pageProp) {
+      setPageState(value);
+    }
+    if (handleChange) {
+      handleChange(event, value);
+    }
+  };
+
+  // https://dev.to/namirsab/comment/2050
+  const range = (start: number, end: number) => {
+    const length = end - start + 1;
+    return Array.from({ length }, (_, i) => start + i);
+  };
+
+  const startPages = range(1, Math.min(boundaryCount, count));
+  const endPages = range(
+    Math.max(count - boundaryCount + 1, boundaryCount + 1),
+    count
+  );
+
+  const siblingsStart = Math.max(
+    Math.min(
+      // Natural start
+      page - siblingCount,
+      // Lower boundary when page is high
+      count - boundaryCount - siblingCount * 2 - 1
+    ),
+    // Greater than startPages
+    boundaryCount + 2
+  );
+
+  const siblingsEnd = Math.min(
+    Math.max(
+      // Natural end
+      page + siblingCount,
+      // Upper boundary when page is low
+      boundaryCount + siblingCount * 2 + 2
+    ),
+    // Less than endPages
+    endPages.length > 0 ? endPages[0] - 2 : count - 1
+  );
+
+  // Basic list of items to render
+  // e.g. itemList = ['first', 'previous', 1, 'ellipsis', 4, 5, 6, 'ellipsis', 10, 'next', 'last']
+  const itemList = [
+    ...(showFirstButton ? ["first"] : []),
+    ...(hidePrevButton ? [] : ["previous"]),
+    ...startPages,
+
+    // Start ellipsis
+    // eslint-disable-next-line no-nested-ternary
+    ...(siblingsStart > boundaryCount + 2
+      ? ["start-ellipsis"]
+      : boundaryCount + 1 < count - boundaryCount
+      ? [boundaryCount + 1]
+      : []),
+
+    // Sibling pages
+    ...range(siblingsStart, siblingsEnd),
+
+    // End ellipsis
+    // eslint-disable-next-line no-nested-ternary
+    ...(siblingsEnd < count - boundaryCount - 1
+      ? ["end-ellipsis"]
+      : count - boundaryCount > boundaryCount
+      ? [count - boundaryCount]
+      : []),
+
+    ...endPages,
+    ...(hideNextButton ? [] : ["next"]),
+    ...(showLastButton ? ["last"] : []),
+  ] as (UsePaginationItem["type"] | number)[];
+
+  // Map the button type to its page number
+  const buttonPage = (type: UsePaginationItem["type"]) => {
+    switch (type) {
+      case "first":
+        return 1;
+      case "previous":
+        return page - 1;
+      case "next":
+        return page + 1;
+      case "last":
+        return count;
+      default:
+        return null;
+    }
+  };
+
+  // Convert the basic item list to PaginationItem props objects
+  const items: UsePaginationItem[] = itemList.map((item) => {
+    return typeof item === "number"
+      ? {
+          onClick: (event: any) => {
+            handleClick(event, item);
+          },
+          type: "page",
+          page: item,
+          selected: item === page,
+          disabled,
+          "aria-current": item === page ? "true" : undefined,
+        }
+      : {
+          onClick: (event: any) => {
+            handleClick(event, buttonPage(item) as unknown as number);
+          },
+          type: item,
+          page: buttonPage(item),
+          selected: false,
+          disabled:
+            disabled ||
+            (item.indexOf("ellipsis") === -1 &&
+              (item === "next" || item === "last" ? page >= count : page <= 1)),
+        };
+  });
+
+  return {
+    items,
+    ...other,
+  };
+}

+ 3 - 0
package.json

@@ -12,6 +12,7 @@
     "@mui/base": "^5.0.0-alpha.103",
     "@mui/base": "^5.0.0-alpha.103",
     "@next/font": "^13.0.1",
     "@next/font": "^13.0.1",
     "clsx": "^1.2.1",
     "clsx": "^1.2.1",
+    "idb": "^7.1.1",
     "moment": "^2.29.4",
     "moment": "^2.29.4",
     "next": "13.0.0",
     "next": "13.0.0",
     "next-pwa": "^5.6.0",
     "next-pwa": "^5.6.0",
@@ -30,9 +31,11 @@
     "@types/qs": "^6.9.7",
     "@types/qs": "^6.9.7",
     "@types/react": "18.0.21",
     "@types/react": "18.0.21",
     "@types/react-dom": "18.0.6",
     "@types/react-dom": "18.0.6",
+    "@typescript-eslint/eslint-plugin": "^5.43.0",
     "autoprefixer": "^10.4.12",
     "autoprefixer": "^10.4.12",
     "eslint": "8.25.0",
     "eslint": "8.25.0",
     "eslint-config-next": "13.0.0",
     "eslint-config-next": "13.0.0",
+    "eslint-plugin-jsx-a11y": "^6.6.1",
     "postcss": "^8.4.18",
     "postcss": "^8.4.18",
     "sass": "^1.55.0",
     "sass": "^1.55.0",
     "tailwindcss": "^3.2.0",
     "tailwindcss": "^3.2.0",

+ 10 - 5
pages/_app.tsx

@@ -23,11 +23,8 @@ export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
 type AppPropsWithLayout = AppProps & {
 type AppPropsWithLayout = AppProps & {
   Component: NextPageWithLayout;
   Component: NextPageWithLayout;
   pageProps: {
   pageProps: {
-    fallback?: {
-      [key: string]: any;
-    };
+    fallback?: Docs;
     siteConfig: SiteConfig;
     siteConfig: SiteConfig;
-    [key: string]: any;
   };
   };
 };
 };
 
 
@@ -46,7 +43,15 @@ const MyApp = ({ Component, pageProps }: AppPropsWithLayout) => {
       siteName: siteConfig.siteName,
       siteName: siteConfig.siteName,
       img: siteConfig.touchIcon,
       img: siteConfig.touchIcon,
     };
     };
-  }, [siteConfig.description, siteConfig.host, siteConfig.jsonLd, siteConfig.keywords, siteConfig.siteName, siteConfig.title, siteConfig.touchIcon]);
+  }, [
+    siteConfig.description,
+    siteConfig.host,
+    siteConfig.jsonLd,
+    siteConfig.keywords,
+    siteConfig.siteName,
+    siteConfig.title,
+    siteConfig.touchIcon,
+  ]);
 
 
   useEffect(() => {
   useEffect(() => {
     const handleRouteChange = (url: string) => {
     const handleRouteChange = (url: string) => {

+ 63 - 0
pages/library.tsx

@@ -0,0 +1,63 @@
+import { SyntheticEvent, useState } from "react";
+import Tab from "@mui/base/TabUnstyled";
+import Tabs from "@mui/base/TabsUnstyled";
+import TabsList from "@mui/base/TabsListUnstyled";
+import TabPanel from "@mui/base/TabPanelUnstyled";
+
+import LibrayList from "components/library/LibrayList";
+
+const tabs = ["History", "Favorite Books"];
+
+const Library = () => {
+  const [tab, setTab] = useState(tabs[0]);
+
+  const handleTabChange = (
+    event: SyntheticEvent<Element, Event>,
+    value: string | number | boolean
+  ) => {
+    setTab(value as string);
+  };
+
+  return (
+    <Tabs
+      component={"main"}
+      value={tab}
+      className="bg-paper py-3"
+      onChange={handleTabChange}
+    >
+      <div className="container">
+        <h1>{tab}</h1>
+        <TabsList className="tabs">
+          {tabs.map((item) => (
+            <Tab key={item} value={item} className="tab">
+              {item}
+            </Tab>
+          ))}
+        </TabsList>
+      </div>
+      <div className="container">
+        {tabs.map((item) => (
+          <TabPanel key={item} value={item}>
+            <LibrayList type={item} />
+          </TabPanel>
+        ))}
+      </div>
+    </Tabs>
+  );
+
+  // <main className="container">
+  //   <h2 className="novel-title">Popular This Week</h2>
+  //   <ul className="novel-list">
+  //     {/* {(data?.data || []).map((item) => (
+  //       <NovelItem
+  //         key={item.uri}
+  //         slug={item.uri}
+  //         img={item.img}
+  //         name={item.name}
+  //       />
+  //     ))} */}
+  //   </ul>
+  // </main>
+};
+
+export default Library;

+ 61 - 7
pages/novel/[slug]/[chapter].tsx

@@ -17,6 +17,9 @@ import { SeoHead, SeoHeadConfig } from "components/SeoHead";
 
 
 import type { NextPageWithLayout } from "pages/_app";
 import type { NextPageWithLayout } from "pages/_app";
 
 
+import { isServer } from "libs/config";
+import { useHistory } from "libs/hooks/useDbItem";
+
 import styles from "styles/chapter.module.scss";
 import styles from "styles/chapter.module.scss";
 
 
 const Chapter: NextPageWithLayout<NovelPageProps> = (props) => {
 const Chapter: NextPageWithLayout<NovelPageProps> = (props) => {
@@ -24,10 +27,21 @@ const Chapter: NextPageWithLayout<NovelPageProps> = (props) => {
   const { siteConfig } = useStore();
   const { siteConfig } = useStore();
 
 
   const { query } = useRouter();
   const { query } = useRouter();
+
+  const [history, putHistoryItem] = useHistory(query.slug as string);
+
+  const { data: { data: detail } = { data: null } } = useGet<Detail>(
+    `/api/novel/${query.slug}`
+  );
+
   const { data: { data: chapterData } = { data: null } } = useGet<ChapterData>(
   const { data: { data: chapterData } = { data: null } } = useGet<ChapterData>(
     `/api/novel/chapter/${query.slug}/${query.chapter}`
     `/api/novel/chapter/${query.slug}/${query.chapter}`
   );
   );
 
 
+  const { data: { data: chapters } = { data: null } } = useGet<ChapterListData>(
+    `/api/novel/${query.slug}/chapters`
+  );
+
   const [open, setOpen] = useState(false);
   const [open, setOpen] = useState(false);
   const [menu, setMenu] = useState("");
   const [menu, setMenu] = useState("");
   const [isSerif, setIsSerif] = useState(false);
   const [isSerif, setIsSerif] = useState(false);
@@ -52,8 +66,12 @@ const Chapter: NextPageWithLayout<NovelPageProps> = (props) => {
         .join(", ")}`,
         .join(", ")}`,
       url,
       url,
       canonical: url,
       canonical: url,
-      pre: chapterData?.pre ? `https://${siteConfig.host}/novel/${chapterData.pre}` : "",
-      next: chapterData?.next ? `https://${siteConfig.host}/novel/${chapterData.next}` : "",
+      pre: chapterData?.pre
+        ? `https://${siteConfig.host}/novel/${chapterData.pre}`
+        : "",
+      next: chapterData?.next
+        ? `https://${siteConfig.host}/novel/${chapterData.next}`
+        : "",
       siteName: siteConfig.siteName,
       siteName: siteConfig.siteName,
       // TODO: 图片
       // TODO: 图片
       img: "",
       img: "",
@@ -131,7 +149,7 @@ const Chapter: NextPageWithLayout<NovelPageProps> = (props) => {
     }
     }
   };
   };
 
 
-  const handleChangeSettings = (type: string = "") => {
+  const handleChangeSettings = (type = "") => {
     if (menu === type) {
     if (menu === type) {
       setMenu("");
       setMenu("");
     } else {
     } else {
@@ -167,6 +185,35 @@ const Chapter: NextPageWithLayout<NovelPageProps> = (props) => {
     }
     }
   }, []);
   }, []);
 
 
+  useEffect(() => {
+    if (!isServer && detail && chapterData && chapters) {
+      putHistoryItem({
+        readTime: new Date(),
+        chapterUri: `${query.slug}/${query.chapter}` as string,
+        chapterName: chapterData.chapter,
+        chapterCount: chapters.chapters.length,
+        chapterIndex:
+          chapters.chapters.findIndex(
+            (item) => item.uri === `${query.slug}/${query.chapter}`
+          ) + 1,
+        img: detail.img,
+        name: detail.name,
+        uri: query.slug as string,
+        status: detail.status,
+        isReading: 1,
+        isFavorite: history?.isFavorite ?? 0,
+      });
+    }
+  }, [
+    chapterData,
+    chapters,
+    detail,
+    history?.isFavorite,
+    putHistoryItem,
+    query.chapter,
+    query.slug,
+  ]);
+
   if (statusCode) {
   if (statusCode) {
     return <Error statusCode={statusCode} />;
     return <Error statusCode={statusCode} />;
   }
   }
@@ -198,7 +245,11 @@ const Chapter: NextPageWithLayout<NovelPageProps> = (props) => {
           </div>
           </div>
         </div>
         </div>
       </header>
       </header>
-      <main className={styles["chapter-main"]} onClick={handleOpenToolbar}>
+      <main
+        className={styles["chapter-main"]}
+        onClick={handleOpenToolbar}
+        aria-hidden="true"
+      >
         <div className={clsx(styles["chapter-page"])}>
         <div className={clsx(styles["chapter-page"])}>
           <article className={styles["novel"]}>
           <article className={styles["novel"]}>
             <header
             <header
@@ -251,6 +302,7 @@ const Chapter: NextPageWithLayout<NovelPageProps> = (props) => {
           <div
           <div
             className={styles["toolbar-display"]}
             className={styles["toolbar-display"]}
             onClick={handleStopPropagation}
             onClick={handleStopPropagation}
+            aria-hidden="true"
           >
           >
             {menu === "settings" ? (
             {menu === "settings" ? (
               <Settings
               <Settings
@@ -263,8 +315,8 @@ const Chapter: NextPageWithLayout<NovelPageProps> = (props) => {
             ) : null}
             ) : null}
             {menu === "toc" ? (
             {menu === "toc" ? (
               <Toc
               <Toc
-                novel={query.slug as string}
-                chapter={query.chapter as string}
+                currentUri={`${query.slug}/${query.chapter}`}
+                chapters={chapters ? chapters.chapters : []}
                 onChangeSettings={handleChangeSettings}
                 onChangeSettings={handleChangeSettings}
               />
               />
             ) : null}
             ) : null}
@@ -291,7 +343,8 @@ export const getServerSideProps: GetServerSideProps<
   }
   }
   try {
   try {
     const { slug, chapter } = context.params;
     const { slug, chapter } = context.params;
-    const [chapterData] = await Promise.all([
+    const [detail, chapterData] = await Promise.all([
+      get<Detail>(`/api/novel/${slug}`),
       get<ChapterData>(`/api/novel/chapter/${slug}/${chapter}`),
       get<ChapterData>(`/api/novel/chapter/${slug}/${chapter}`),
     ]);
     ]);
 
 
@@ -302,6 +355,7 @@ export const getServerSideProps: GetServerSideProps<
     return {
     return {
       props: {
       props: {
         fallback: {
         fallback: {
+          [`/api/novel/${slug}`]: detail,
           [`/api/novel/chapter/${slug}/${chapter}`]: chapterData,
           [`/api/novel/chapter/${slug}/${chapter}`]: chapterData,
         },
         },
       },
       },

+ 76 - 25
pages/novel/[slug]/index.tsx

@@ -2,10 +2,10 @@ import Link from "next/link";
 import moment from "moment";
 import moment from "moment";
 import { useMemo } from "react";
 import { useMemo } from "react";
 import { useRouter } from "next/router";
 import { useRouter } from "next/router";
-import TabUnstyled from "@mui/base/TabUnstyled";
-import TabsUnstyled from "@mui/base/TabsUnstyled";
-import TabsListUnstyled from "@mui/base/TabsListUnstyled";
-import TabPanelUnstyled from "@mui/base/TabPanelUnstyled";
+import Tab from "@mui/base/TabUnstyled";
+import Tabs from "@mui/base/TabsUnstyled";
+import TabsList from "@mui/base/TabsListUnstyled";
+import TabPanel from "@mui/base/TabPanelUnstyled";
 import type { GetServerSideProps, NextPage } from "next";
 import type { GetServerSideProps, NextPage } from "next";
 
 
 import { get } from "libs/http";
 import { get } from "libs/http";
@@ -18,16 +18,41 @@ import { SeoHead, SeoHeadConfig } from "components/SeoHead";
 
 
 import styles from "styles/novel-info.module.scss";
 import styles from "styles/novel-info.module.scss";
 
 
+import { useHistory } from "libs/hooks/useDbItem";
+
 interface NovelPageProps {
 interface NovelPageProps {
   detail?: Detail;
   detail?: Detail;
   chapters?: ChapterListData;
   chapters?: ChapterListData;
   statusCode?: number;
   statusCode?: number;
 }
 }
 
 
+function mergeHistory(
+  detail: Detail,
+  chapters: ChapterListData["chapters"],
+  history?: HistoryItem
+) {
+  return {
+    uri: detail.uri,
+    readTime: history?.readTime ?? new Date(),
+    img: detail.img,
+    name: detail.name,
+    status: detail.status,
+    chapterUri: history?.chapterUri ?? chapters[0].uri,
+    chapterName: history?.chapterName ?? chapters[0].name,
+    chapterCount: history?.chapterCount ?? chapters.length,
+    chapterIndex: history?.chapterIndex ?? 1,
+    isReading: history?.isReading ?? 0,
+    isFavorite: 1,
+  } as HistoryItem;
+}
+
 const Novel: NextPage<NovelPageProps> = (props) => {
 const Novel: NextPage<NovelPageProps> = (props) => {
   const { statusCode } = props;
   const { statusCode } = props;
   const { siteConfig } = useStore();
   const { siteConfig } = useStore();
   const { query } = useRouter();
   const { query } = useRouter();
+  const [history, putHistoryItem, delHistoryItem] = useHistory(
+    query.slug as string
+  );
 
 
   const { data: { data: detail } = { data: null } } = useGet<Detail>(
   const { data: { data: detail } = { data: null } } = useGet<Detail>(
     `/api/novel/${query.slug}`
     `/api/novel/${query.slug}`
@@ -161,6 +186,22 @@ const Novel: NextPage<NovelPageProps> = (props) => {
     return list;
     return list;
   }, [chapters]);
   }, [chapters]);
 
 
+  const handleTogleFavorite = () => {
+    if (!detail || !chapters) return;
+    const data = mergeHistory(detail, chapters.chapters, history);
+
+    if (history?.isFavorite === 1) {
+      const isReading = history?.isReading === 1;
+
+      if (isReading) data.isFavorite = 0;
+
+      isReading ? putHistoryItem(data) : delHistoryItem(detail.uri);
+    } else {
+      data.isFavorite = 1;
+      putHistoryItem(data);
+    }
+  };
+
   if (statusCode) {
   if (statusCode) {
     return <MyError statusCode={statusCode} />;
     return <MyError statusCode={statusCode} />;
   }
   }
@@ -262,21 +303,31 @@ const Novel: NextPage<NovelPageProps> = (props) => {
                   <strong>Start Reading</strong>
                   <strong>Start Reading</strong>
                   <span>{chapters.chapters[0].name}</span>
                   <span>{chapters.chapters[0].name}</span>
                 </Link>
                 </Link>
+                <button
+                  className={styles["button"]}
+                  onClick={handleTogleFavorite}
+                >
+                  <strong>
+                    {history && history.isFavorite
+                      ? "In Library"
+                      : "Add to Library"}
+                  </strong>
+                </button>
               </div>
               </div>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
-      <TabsUnstyled defaultValue={0} className="container bg-paper py-3">
-        <TabsListUnstyled className="tabs">
-          <TabUnstyled value={0} className="tab">
+      <Tabs defaultValue={0} className="container bg-paper py-3">
+        <TabsList className="tabs">
+          <Tab value={0} className="tab">
             About
             About
-          </TabUnstyled>
-          <TabUnstyled value={1} className="tab">
+          </Tab>
+          <Tab value={1} className="tab">
             Chapters
             Chapters
-          </TabUnstyled>
-        </TabsListUnstyled>
-        <TabPanelUnstyled value={0}>
+          </Tab>
+        </TabsList>
+        <TabPanel value={0}>
           <h2 className="sub-title">Genres</h2>
           <h2 className="sub-title">Genres</h2>
           <div className="tags">
           <div className="tags">
             {detail.genres.map((item) => (
             {detail.genres.map((item) => (
@@ -296,26 +347,26 @@ const Novel: NextPage<NovelPageProps> = (props) => {
             className={styles["novel-text"]}
             className={styles["novel-text"]}
             dangerouslySetInnerHTML={{ __html: detail.desc }}
             dangerouslySetInnerHTML={{ __html: detail.desc }}
           />
           />
-        </TabPanelUnstyled>
-        <TabPanelUnstyled value={1}>
+        </TabPanel>
+        <TabPanel value={1}>
           <h3 className="sub-title">{detail.name} Chapters</h3>
           <h3 className="sub-title">{detail.name} Chapters</h3>
-          <TabsUnstyled defaultValue={0}>
-            <TabsListUnstyled className="tabs">
+          <Tabs defaultValue={0}>
+            <TabsList className="tabs">
               {chapterLists.map((chapter, idx) => (
               {chapterLists.map((chapter, idx) => (
-                <TabUnstyled value={idx} className="tab" key={chapter.title}>
+                <Tab value={idx} className="tab" key={chapter.title}>
                   {chapter.title}
                   {chapter.title}
-                </TabUnstyled>
+                </Tab>
               ))}
               ))}
-            </TabsListUnstyled>
+            </TabsList>
             {chapterLists.map((chapter, idx) => (
             {chapterLists.map((chapter, idx) => (
-              <TabPanelUnstyled
+              <TabPanel
                 value={idx}
                 value={idx}
                 component="ol"
                 component="ol"
                 start={idx * 100 + 1}
                 start={idx * 100 + 1}
                 key={chapter.title}
                 key={chapter.title}
                 className={styles["chapter-list"]}
                 className={styles["chapter-list"]}
               >
               >
-                {chapter.list.map((item, i) => {
+                {chapter.list.map((item) => {
                   const dateFormNow = moment(item.create_time).fromNow(true);
                   const dateFormNow = moment(item.create_time).fromNow(true);
                   return (
                   return (
                     <li key={item.id}>
                     <li key={item.id}>
@@ -332,11 +383,11 @@ const Novel: NextPage<NovelPageProps> = (props) => {
                     </li>
                     </li>
                   );
                   );
                 })}
                 })}
-              </TabPanelUnstyled>
+              </TabPanel>
             ))}
             ))}
-          </TabsUnstyled>
-        </TabPanelUnstyled>
-      </TabsUnstyled>
+          </Tabs>
+        </TabPanel>
+      </Tabs>
     </main>
     </main>
   );
   );
 };
 };

+ 1 - 1
pages/novels/[genre].tsx

@@ -139,7 +139,7 @@ const Genre = () => {
 };
 };
 
 
 export const getServerSideProps: GetServerSideProps<
 export const getServerSideProps: GetServerSideProps<
-  { fallback: { [key: string]: any } },
+  { fallback: Docs },
   { genre: string }
   { genre: string }
 > = async ({ params }) => {
 > = async ({ params }) => {
   const key = params?.genre ? `/api/genre/${params.genre}` : `/api/list`;
   const key = params?.genre ? `/api/genre/${params.genre}` : `/api/list`;

+ 21 - 0
public/icons.svg

@@ -66,4 +66,25 @@
     <symbol id="person" viewBox="0 0 24 24">
     <symbol id="person" viewBox="0 0 24 24">
         <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"></path>
         <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"></path>
     </symbol>
     </symbol>
+
+    <symbol id="books" viewBox="0 0 24 24">
+        <path d="M21 5c-1.11-.35-2.33-.5-3.5-.5-1.95 0-4.05.4-5.5 1.5-1.45-1.1-3.55-1.5-5.5-1.5S2.45 4.9 1 6v14.65c0 .25.25.5.5.5.1 0 .15-.05.25-.05C3.1 20.45 5.05 20 6.5 20c1.95 0 4.05.4 5.5 1.5 1.35-.85 3.8-1.5 5.5-1.5 1.65 0 3.35.3 4.75 1.05.1.05.15.05.25.05.25 0 .5-.25.5-.5V6c-.6-.45-1.25-.75-2-1zm0 13.5c-1.1-.35-2.3-.5-3.5-.5-1.7 0-4.15.65-5.5 1.5V8c1.35-.85 3.8-1.5 5.5-1.5 1.2 0 2.4.15 3.5.5v11.5z"></path>
+        <path d="M17.5 10.5c.88 0 1.73.09 2.5.26V9.24c-.79-.15-1.64-.24-2.5-.24-1.7 0-3.24.29-4.5.83v1.66c1.13-.64 2.7-.99 4.5-.99zM13 12.49v1.66c1.13-.64 2.7-.99 4.5-.99.88 0 1.73.09 2.5.26V11.9c-.79-.15-1.64-.24-2.5-.24-1.7 0-3.24.3-4.5.83zm4.5 1.84c-1.7 0-3.24.29-4.5.83v1.66c1.13-.64 2.7-.99 4.5-.99.88 0 1.73.09 2.5.26v-1.52c-.79-.16-1.64-.24-2.5-.24z"></path>
+    </symbol>
+    <symbol id="bookmark" viewBox="0 0 24 24">
+        <path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4z"></path>
+    </symbol>
+
+    <symbol id="navigate-next" viewBox="0 0 24 24">
+        <path d="M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"></path>
+    </symbol>
+    <symbol id="navigate-prev" viewBox="0 0 24 24">
+        <path d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path>
+    </symbol>
+    <symbol id="first-page" viewBox="0 0 24 24">
+        <path d="M18.41 16.59 13.82 12l4.59-4.59L17 6l-6 6 6 6zM6 6h2v12H6z"></path>
+    </symbol>
+    <symbol id="last-page" viewBox="0 0 24 24">
+        <path d="M5.59 7.41 10.18 12l-4.59 4.59L7 18l6-6-6-6zM16 6h2v12h-2z"></path>
+    </symbol>
 </svg>
 </svg>

+ 1 - 1
styles/globals.scss

@@ -31,7 +31,7 @@
   .btn {
   .btn {
     @apply inline-flex px-4 py-3 font-medium rounded items-center justify-center text-sm whitespace-nowrap; // space-x-3;
     @apply inline-flex px-4 py-3 font-medium rounded items-center justify-center text-sm whitespace-nowrap; // space-x-3;
     &.btn-primary {
     &.btn-primary {
-      @apply bg-blue-500 border border-blue-500;
+      @apply bg-blue-500 border border-blue-500 text-white;
       &.btn-outline,
       &.btn-outline,
       &.btn-light-outline {
       &.btn-light-outline {
         @apply bg-blue-500/30;
         @apply bg-blue-500/30;

+ 1 - 1
styles/novel-info.module.scss

@@ -38,7 +38,7 @@
       }
       }
     }
     }
     .btns {
     .btns {
-      @apply flex;
+      @apply flex flex-wrap gap-5;
     }
     }
     .button {
     .button {
       @apply block text-white text-center px-4 py-3 rounded-md  lg:text-lg select-none bg-gradient-to-r from-blue-600 to-blue-800 transition-all;
       @apply block text-white text-center px-4 py-3 rounded-md  lg:text-lg select-none bg-gradient-to-r from-blue-600 to-blue-800 transition-all;

+ 23 - 0
types/db.d.ts

@@ -0,0 +1,23 @@
+declare interface PageList<T> {
+  list: T[];
+  total: number;
+  page: number;
+  pageSize: number;
+}
+
+declare interface HistoryItem {
+  readTime: Data;
+
+  img: string;
+  name: string;
+  uri: string;
+  status: string;
+
+  chapterUri: string;
+  chapterName: string;
+  chapterCount: number;
+  chapterIndex: number;
+
+  isReading: number;
+  isFavorite: number;
+}

+ 1 - 1
types/http.d.ts

@@ -20,7 +20,7 @@ declare interface Detail {
   desc: string;
   desc: string;
   genres: GenreItem[];
   genres: GenreItem[];
   host: string;
   host: string;
-  status: 0;
+  status: string;
   source: string;
   source: string;
   create_time: string;
   create_time: string;
   update_time: string;
   update_time: string;

+ 99 - 3
yarn.lock

@@ -1260,7 +1260,7 @@
   resolved "https://registry.npmmirror.com/@types/gtag.js/-/gtag.js-0.0.12.tgz#095122edca896689bdfcdd73b057e23064d23572"
   resolved "https://registry.npmmirror.com/@types/gtag.js/-/gtag.js-0.0.12.tgz#095122edca896689bdfcdd73b057e23064d23572"
   integrity sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==
   integrity sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==
 
 
-"@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8":
+"@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
   version "7.0.11"
   version "7.0.11"
   resolved "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
   resolved "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
   integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
   integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
@@ -1330,11 +1330,31 @@
   resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
   resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
   integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
   integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
 
 
+"@types/semver@^7.3.12":
+  version "7.3.13"
+  resolved "https://registry.npmmirror.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91"
+  integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==
+
 "@types/trusted-types@^2.0.2":
 "@types/trusted-types@^2.0.2":
   version "2.0.2"
   version "2.0.2"
   resolved "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
   resolved "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
   integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
   integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
 
 
+"@typescript-eslint/eslint-plugin@^5.43.0":
+  version "5.43.0"
+  resolved "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.43.0.tgz#4a5248eb31b454715ddfbf8cfbf497529a0a78bc"
+  integrity sha512-wNPzG+eDR6+hhW4yobEmpR36jrqqQv1vxBq5LJO3fBAktjkvekfr4BRl+3Fn1CM/A+s8/EiGUbOMDoYqWdbtXA==
+  dependencies:
+    "@typescript-eslint/scope-manager" "5.43.0"
+    "@typescript-eslint/type-utils" "5.43.0"
+    "@typescript-eslint/utils" "5.43.0"
+    debug "^4.3.4"
+    ignore "^5.2.0"
+    natural-compare-lite "^1.4.0"
+    regexpp "^3.2.0"
+    semver "^7.3.7"
+    tsutils "^3.21.0"
+
 "@typescript-eslint/parser@^5.21.0":
 "@typescript-eslint/parser@^5.21.0":
   version "5.40.1"
   version "5.40.1"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.40.1.tgz#e7f8295dd8154d0d37d661ddd8e2f0ecfdee28dd"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.40.1.tgz#e7f8295dd8154d0d37d661ddd8e2f0ecfdee28dd"
@@ -1353,11 +1373,34 @@
     "@typescript-eslint/types" "5.40.1"
     "@typescript-eslint/types" "5.40.1"
     "@typescript-eslint/visitor-keys" "5.40.1"
     "@typescript-eslint/visitor-keys" "5.40.1"
 
 
+"@typescript-eslint/scope-manager@5.43.0":
+  version "5.43.0"
+  resolved "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-5.43.0.tgz#566e46303392014d5d163704724872e1f2dd3c15"
+  integrity sha512-XNWnGaqAtTJsUiZaoiGIrdJYHsUOd3BZ3Qj5zKp9w6km6HsrjPk/TGZv0qMTWyWj0+1QOqpHQ2gZOLXaGA9Ekw==
+  dependencies:
+    "@typescript-eslint/types" "5.43.0"
+    "@typescript-eslint/visitor-keys" "5.43.0"
+
+"@typescript-eslint/type-utils@5.43.0":
+  version "5.43.0"
+  resolved "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-5.43.0.tgz#91110fb827df5161209ecca06f70d19a96030be6"
+  integrity sha512-K21f+KY2/VvYggLf5Pk4tgBOPs2otTaIHy2zjclo7UZGLyFH86VfUOm5iq+OtDtxq/Zwu2I3ujDBykVW4Xtmtg==
+  dependencies:
+    "@typescript-eslint/typescript-estree" "5.43.0"
+    "@typescript-eslint/utils" "5.43.0"
+    debug "^4.3.4"
+    tsutils "^3.21.0"
+
 "@typescript-eslint/types@5.40.1":
 "@typescript-eslint/types@5.40.1":
   version "5.40.1"
   version "5.40.1"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.40.1.tgz#de37f4f64de731ee454bb2085d71030aa832f749"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.40.1.tgz#de37f4f64de731ee454bb2085d71030aa832f749"
   integrity sha512-Icg9kiuVJSwdzSQvtdGspOlWNjVDnF3qVIKXdJ103o36yRprdl3Ge5cABQx+csx960nuMF21v8qvO31v9t3OHw==
   integrity sha512-Icg9kiuVJSwdzSQvtdGspOlWNjVDnF3qVIKXdJ103o36yRprdl3Ge5cABQx+csx960nuMF21v8qvO31v9t3OHw==
 
 
+"@typescript-eslint/types@5.43.0":
+  version "5.43.0"
+  resolved "https://registry.npmmirror.com/@typescript-eslint/types/-/types-5.43.0.tgz#e4ddd7846fcbc074325293515fa98e844d8d2578"
+  integrity sha512-jpsbcD0x6AUvV7tyOlyvon0aUsQpF8W+7TpJntfCUWU1qaIKu2K34pMwQKSzQH8ORgUrGYY6pVIh1Pi8TNeteg==
+
 "@typescript-eslint/typescript-estree@5.40.1":
 "@typescript-eslint/typescript-estree@5.40.1":
   version "5.40.1"
   version "5.40.1"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.40.1.tgz#9a7d25492f02c69882ce5e0cd1857b0c55645d72"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.40.1.tgz#9a7d25492f02c69882ce5e0cd1857b0c55645d72"
@@ -1371,6 +1414,33 @@
     semver "^7.3.7"
     semver "^7.3.7"
     tsutils "^3.21.0"
     tsutils "^3.21.0"
 
 
+"@typescript-eslint/typescript-estree@5.43.0":
+  version "5.43.0"
+  resolved "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.43.0.tgz#b6883e58ba236a602c334be116bfc00b58b3b9f2"
+  integrity sha512-BZ1WVe+QQ+igWal2tDbNg1j2HWUkAa+CVqdU79L4HP9izQY6CNhXfkNwd1SS4+sSZAP/EthI1uiCSY/+H0pROg==
+  dependencies:
+    "@typescript-eslint/types" "5.43.0"
+    "@typescript-eslint/visitor-keys" "5.43.0"
+    debug "^4.3.4"
+    globby "^11.1.0"
+    is-glob "^4.0.3"
+    semver "^7.3.7"
+    tsutils "^3.21.0"
+
+"@typescript-eslint/utils@5.43.0":
+  version "5.43.0"
+  resolved "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-5.43.0.tgz#00fdeea07811dbdf68774a6f6eacfee17fcc669f"
+  integrity sha512-8nVpA6yX0sCjf7v/NDfeaOlyaIIqL7OaIGOWSPFqUKK59Gnumd3Wa+2l8oAaYO2lk0sO+SbWFWRSvhu8gLGv4A==
+  dependencies:
+    "@types/json-schema" "^7.0.9"
+    "@types/semver" "^7.3.12"
+    "@typescript-eslint/scope-manager" "5.43.0"
+    "@typescript-eslint/types" "5.43.0"
+    "@typescript-eslint/typescript-estree" "5.43.0"
+    eslint-scope "^5.1.1"
+    eslint-utils "^3.0.0"
+    semver "^7.3.7"
+
 "@typescript-eslint/visitor-keys@5.40.1":
 "@typescript-eslint/visitor-keys@5.40.1":
   version "5.40.1"
   version "5.40.1"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.40.1.tgz#f3d2bf5af192f4432b84cec6fdcb387193518754"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.40.1.tgz#f3d2bf5af192f4432b84cec6fdcb387193518754"
@@ -1379,6 +1449,14 @@
     "@typescript-eslint/types" "5.40.1"
     "@typescript-eslint/types" "5.40.1"
     eslint-visitor-keys "^3.3.0"
     eslint-visitor-keys "^3.3.0"
 
 
+"@typescript-eslint/visitor-keys@5.43.0":
+  version "5.43.0"
+  resolved "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.43.0.tgz#cbbdadfdfea385310a20a962afda728ea106befa"
+  integrity sha512-icl1jNH/d18OVHLfcwdL3bWUKsBeIiKYTGxMJCoGe7xFht+E4QgzOqoWYrU8XSLJWhVw8nTacbm03v23J/hFTg==
+  dependencies:
+    "@typescript-eslint/types" "5.43.0"
+    eslint-visitor-keys "^3.3.0"
+
 acorn-jsx@^5.3.2:
 acorn-jsx@^5.3.2:
   version "5.3.2"
   version "5.3.2"
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
@@ -2061,7 +2139,7 @@ eslint-plugin-import@^2.26.0:
     resolve "^1.22.0"
     resolve "^1.22.0"
     tsconfig-paths "^3.14.1"
     tsconfig-paths "^3.14.1"
 
 
-eslint-plugin-jsx-a11y@^6.5.1:
+eslint-plugin-jsx-a11y@^6.5.1, eslint-plugin-jsx-a11y@^6.6.1:
   version "6.6.1"
   version "6.6.1"
   resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.6.1.tgz#93736fc91b83fdc38cc8d115deedfc3091aef1ff"
   resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.6.1.tgz#93736fc91b83fdc38cc8d115deedfc3091aef1ff"
   integrity sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==
   integrity sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==
@@ -2105,6 +2183,14 @@ eslint-plugin-react@^7.31.7:
     semver "^6.3.0"
     semver "^6.3.0"
     string.prototype.matchall "^4.0.7"
     string.prototype.matchall "^4.0.7"
 
 
+eslint-scope@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
+  integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
+  dependencies:
+    esrecurse "^4.3.0"
+    estraverse "^4.1.1"
+
 eslint-scope@^7.1.1:
 eslint-scope@^7.1.1:
   version "7.1.1"
   version "7.1.1"
   resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642"
   resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642"
@@ -2197,6 +2283,11 @@ esrecurse@^4.3.0:
   dependencies:
   dependencies:
     estraverse "^5.2.0"
     estraverse "^5.2.0"
 
 
+estraverse@^4.1.1:
+  version "4.3.0"
+  resolved "https://registry.npmmirror.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
+  integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
+
 estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0:
 estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0:
   version "5.3.0"
   version "5.3.0"
   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
@@ -2500,7 +2591,7 @@ has@^1.0.3:
   dependencies:
   dependencies:
     function-bind "^1.1.1"
     function-bind "^1.1.1"
 
 
-idb@^7.0.1:
+idb@^7.0.1, idb@^7.1.1:
   version "7.1.1"
   version "7.1.1"
   resolved "https://registry.npmmirror.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b"
   resolved "https://registry.npmmirror.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b"
   integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==
   integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==
@@ -2987,6 +3078,11 @@ nanoid@^3.3.4:
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
   integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
   integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
 
 
+natural-compare-lite@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.npmmirror.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4"
+  integrity sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==
+
 natural-compare@^1.4.0:
 natural-compare@^1.4.0:
   version "1.4.0"
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"