diff --git a/components/ProblemCatetory/ProblemCategoryList/index.tsx b/components/ProblemCatetory/ProblemCategoryList/index.tsx new file mode 100644 index 0000000..44aa080 --- /dev/null +++ b/components/ProblemCatetory/ProblemCategoryList/index.tsx @@ -0,0 +1,157 @@ +import { ShareIcon } from "@components/icons"; +import RatingCircle, { ColorRating } from "@components/RatingCircle"; +import { + OptionEntry, + ProgressKeyType, + useProgressOptions, + useQuestProgress, +} from "@hooks/useProgress"; +import { hashCode } from "@utils/hash"; +import Form from "react-bootstrap/esm/Form"; + +const getCols = (l: number) => { + if (l < 12) { + return ""; + } + if (l < 20) { + return "col2"; + } + return "col3"; +}; + +const title2id = (title: string) => { + // title: number. title + return title.split(". ")[0]; +}; + +interface ProblemCategory { + title: string; + summary?: string; + src?: string; + original_src?: string; + sort?: Number; + isLeaf?: boolean; + solution?: string | null; + score?: Number | null; + child?: ProblemCategory[]; + isPremium?: boolean; + last_update?: string; +} + +interface ProblemCategoryListProps { + optionKeys: ProgressKeyType[]; + getOption: (key?: ProgressKeyType) => OptionEntry; + allProgress: Record; + updateProgress: (questID: string, progress: ProgressKeyType) => void; + removeProgress: (questID: string) => void; + data: ProblemCategory; + showEn?: boolean; + showRating?: boolean; + showPremium?: boolean; +} + +function ProblemCategoryList({ + optionKeys, + getOption, + allProgress, + updateProgress, + removeProgress, + data, + showEn, + showRating, + showPremium, +}: ProblemCategoryListProps) { + // Event handlers + const handleProgressSelectChange = ( + questID: string, + progress: ProgressKeyType + ) => { + if (progress === getOption().key) { + removeProgress(questID); + } else { + updateProgress(questID, progress); + } + }; + + return ( +
+

+ {data.title} +

+ {data.summary && ( +

+ )} + +
+ ); +} + +export default ProblemCategoryList; diff --git a/components/ProblemCatetory/TableOfContent.tsx b/components/ProblemCatetory/TableOfContent/index.tsx similarity index 100% rename from components/ProblemCatetory/TableOfContent.tsx rename to components/ProblemCatetory/TableOfContent/index.tsx diff --git a/components/ProblemCatetory/index.tsx b/components/ProblemCatetory/index.tsx index 61af9ba..374c8a9 100644 --- a/components/ProblemCatetory/index.tsx +++ b/components/ProblemCatetory/index.tsx @@ -1,15 +1,6 @@ -import { ShareIcon } from "@components/icons"; -import RatingCircle, { ColorRating } from "@components/RatingCircle"; -import { ProgressKeyType, useProgressOptions } from "@hooks/useProgress"; import { hashCode } from "@utils/hash"; -import { useCallback, useState } from "react"; -import Form from "react-bootstrap/esm/Form"; - -const LC_RATING_PROGRESS_KEY = (questionID: string) => - `lc-rating-zen-progress-${questionID}`; - -// Progress Related -type ProgressData = Record; +import ProblemCategoryList from "./ProblemCategoryList"; +import { useProgressOptions, useQuestProgress } from "@hooks/useProgress"; interface ProblemCategory { title: string; @@ -36,22 +27,6 @@ interface ProblemCategoryProps { showPremium?: boolean; } -function count(data: ProblemCategory[] | undefined) { - if (!data) { - return 0; - } - - let tot = 0; - for (let i = 0; i < data.length; i++) { - if (!data[i].isLeaf) { - tot += count(data[i].child); - } else { - tot += data[i].child?.length || 0; - } - } - return tot; -} - function ProblemCategory({ title, summary, @@ -62,6 +37,9 @@ function ProblemCategory({ showRating, showPremium, }: ProblemCategoryProps) { + const { optionKeys, getOption } = useProgressOptions(); + const { allProgress, updateProgress, removeProgress } = useQuestProgress(); + return (
{ @@ -85,11 +63,15 @@ function ProblemCategory({ > {item.isLeaf ? ( ) : ( item.child && @@ -114,128 +96,4 @@ function ProblemCategory({ ); } -function ProblemCategoryList({ - data, - className = "", - showEn, - showRating, - showPremium, -}: { - data: ProblemCategory; - className?: string; - showEn?: boolean; - showRating?: boolean; - showPremium?: boolean; -}) { - const getCols = (l: number) => { - if (l < 12) { - return ""; - } - if (l < 20) { - return "col2"; - } - return "col3"; - }; - const { optionKeys, getOption } = useProgressOptions(); - // trigger page to refresh - const [localStorageProgressData, setLocalStorageProgressData] = - useState({}); - - // Event handlers - const handleProgressSelectChange = useCallback( - (questionId: string, value: ProgressKeyType) => { - localStorage.setItem(LC_RATING_PROGRESS_KEY(questionId), value); - setLocalStorageProgressData((prevData) => ({ - ...prevData, - [questionId]: value, - })); - }, - [] - ); - - const title2id = (title: string) => { - // title: number. title - return title.split(". ")[0]; - }; - - const progress = (title: string) => { - const localtemp = localStorage.getItem( - LC_RATING_PROGRESS_KEY(title2id(title)) - ) as ProgressKeyType; - return localtemp; - }; - - return ( -
-

- {data.title} -

- {data.summary && ( -

- )} -
    - {data.child && - data.child - .filter((item) => !item.isPremium || showPremium) - .map((item) => ( -
  • - - {item.score && showRating ? ( -
    - - - {Number(item.score).toFixed(0)} - -
    - ) : null} -
    - - handleProgressSelectChange( - title2id(item.title), - e.target.value - ) - } - > - {optionKeys.map((p) => ( - - ))} - -
    -
  • - ))} -
-
- ); -} - export default ProblemCategory; diff --git a/components/SettingsPanel/Sidebar.tsx b/components/SettingsPanel/Sidebar.tsx new file mode 100644 index 0000000..c936e7a --- /dev/null +++ b/components/SettingsPanel/Sidebar.tsx @@ -0,0 +1,29 @@ +import { Nav } from "react-bootstrap"; +import { SettingTabType } from "./config"; + +interface SidebarProps { + tabs: SettingTabType[]; + activeTab: string; + onTabChange: (key: string) => void; +} + +const Sidebar = ({ tabs, activeTab, onTabChange }: SidebarProps) => { + return ( + + ); +}; + +export default Sidebar; diff --git a/components/SettingsPanel/config.tsx b/components/SettingsPanel/config.tsx new file mode 100644 index 0000000..cb0834e --- /dev/null +++ b/components/SettingsPanel/config.tsx @@ -0,0 +1,26 @@ +import { BiSolidCustomize } from "react-icons/bi"; +import { LuArrowUpDown } from "react-icons/lu"; +import CustomizeOptions from "./settingPages/CustomizeOptions"; +import SyncProgress from "./settingPages/SyncProgress"; + +export type SettingTabType = { + key: string; + title: string; + icon: React.ReactNode; + component: React.ReactNode; +}; + +export const setting_tabs: SettingTabType[] = [ + { + key: "SyncProgress", + title: "同步题目进度", + icon: , + component: , + }, + { + key: "CustomizeOptions", + title: "自定义进度选项", + icon: , + component: , + }, +]; diff --git a/components/SettingsPanel/index.tsx b/components/SettingsPanel/index.tsx new file mode 100644 index 0000000..876fd95 --- /dev/null +++ b/components/SettingsPanel/index.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { Button, Col, Container, Modal, Row } from "react-bootstrap"; +import { setting_tabs } from "./config"; +import Sidebar from "./Sidebar"; + +interface SettingsPanelProps { + show: boolean; + onHide: () => void; +} + +const SettingsPanel = ({ show, onHide }: SettingsPanelProps) => { + const [activeTab, setActiveTab] = useState(setting_tabs[0].key); + + const ActiveComponent = setting_tabs.find( + (tab) => tab.key === activeTab + )?.component; + + return ( + + + 站点设置 + + + + + + + + + + +
+ {ActiveComponent ? ActiveComponent : "页面配置错误"} +
+ +
+
+
+ + + + +
+ ); +}; + +export default SettingsPanel; diff --git a/components/SettingsPanel/settingPages/CustomizeOptions/OptionsForm.tsx b/components/SettingsPanel/settingPages/CustomizeOptions/OptionsForm.tsx new file mode 100644 index 0000000..d6c12a2 --- /dev/null +++ b/components/SettingsPanel/settingPages/CustomizeOptions/OptionsForm.tsx @@ -0,0 +1,160 @@ +import { defaultOptions, OptionEntry } from "@hooks/useProgress"; +import { useMemo, useState } from "react"; +import { Button, Col, Form, Row, Stack } from "react-bootstrap"; + +function partition(array: T[], filter: (item: T) => boolean): [T[], T[]] { + return array.reduce( + (acc, item) => { + acc[Number(filter(item))].push(item); + return acc; + }, + [[], []] as [T[], T[]] + ); +} + +interface OptionsFormProps { + formData: OptionEntry[]; + onChange: (formData: OptionEntry[]) => void; + onSubmit: () => void; +} + +function OptionsForm({ formData, onChange, onSubmit }: OptionsFormProps) { + const [submitErrors, setSubmitErrors] = useState([]); + + const sortedFormData = useMemo(() => { + const [customEntries, defaultEntries] = partition( + formData, + (item) => item.key in defaultOptions + ); + return [...defaultEntries, ...customEntries]; + }, [formData]); + + const handleKeyChange = ( + e: React.ChangeEvent, + idx: number + ) => { + const newData = [...sortedFormData]; + newData[idx] = { ...newData[idx], key: e.target.value.trim() }; + onChange(newData); + setSubmitErrors([]); + }; + + const handleLabelChange = ( + e: React.ChangeEvent, + idx: number + ) => { + const newData = [...sortedFormData]; + newData[idx] = { ...newData[idx], label: e.target.value.trim() }; + onChange(newData); + }; + + const handleColorChange = ( + e: React.ChangeEvent, + idx: number + ) => { + const newData = [...sortedFormData]; + newData[idx] = { ...newData[idx], color: e.target.value }; + onChange(newData); + }; + + const handleRemove = (idx: number) => { + const newData = sortedFormData.filter((item, i) => i !== idx); + onChange(newData); + }; + + const addFormRow = () => { + const newEntry: OptionEntry = { + key: "", + label: "", + color: "#000000", + }; + onChange([...sortedFormData, newEntry]); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const keys = sortedFormData.map((item) => item.key); + const keyCounts: { [key: string]: number } = {}; + + keys.forEach((key) => { + keyCounts[key] = (keyCounts[key] || 0) + 1; + }); + + const duplicates = Object.keys(keyCounts).filter( + (key) => keyCounts[key] > 1 + ); + + if (duplicates.length > 0) { + setSubmitErrors(duplicates); + } else { + setSubmitErrors([]); + onSubmit(); + } + }; + + return ( +
+ + {sortedFormData.map((item, i) => ( + + + + handleKeyChange(e, i)} + isInvalid={submitErrors.includes(item.key)} + disabled={i < Object.keys(defaultOptions).length} + /> + {submitErrors.includes(item.key) && ( + + Key不能重复 + + )} + + + + + handleLabelChange(e, i)} + /> + + + + + handleColorChange(e, i)} + /> + + + + + ))} + + + + + + +
+ ); +} + +export default OptionsForm; diff --git a/components/SettingsPanel/settingPages/CustomizeOptions/Preview.tsx b/components/SettingsPanel/settingPages/CustomizeOptions/Preview.tsx new file mode 100644 index 0000000..3f7798f --- /dev/null +++ b/components/SettingsPanel/settingPages/CustomizeOptions/Preview.tsx @@ -0,0 +1,29 @@ +import { OptionEntry } from "@hooks/useProgress"; +import Form from "react-bootstrap/Form"; +import FormLabel from "react-bootstrap/FormLabel"; + +interface PreviewProps { + options: OptionEntry[]; +} + +function Preview({ options }: PreviewProps) { + return ( +
+ 预览 + {options.map((option) => ( + + + + ))} +
+ ); +} + +export default Preview; diff --git a/components/SettingsPanel/settingPages/CustomizeOptions/index.tsx b/components/SettingsPanel/settingPages/CustomizeOptions/index.tsx new file mode 100644 index 0000000..0617d40 --- /dev/null +++ b/components/SettingsPanel/settingPages/CustomizeOptions/index.tsx @@ -0,0 +1,58 @@ +import { + CustomOptionsType, + OptionEntry, + useProgressOptions, +} from "@hooks/useProgress"; +import { useMemo, useState } from "react"; +import { Col, Container, Row } from "react-bootstrap"; +import OptionsFrom from "./OptionsForm"; +import Preview from "./Preview"; + +function CustomizeOptions() { + const { optionKeys, getOption, updateOptions } = useProgressOptions(); + + const savedFormData = useMemo( + () => optionKeys.map(getOption), + [optionKeys, getOption] + ); + + const [newFormData, setNewFormData] = useState(() => { + return savedFormData.map((option) => ({ + key: option.key, + label: option.label, + color: option.color, + })); + }); + + const onChange = (formData: OptionEntry[]) => { + setNewFormData(formData); + }; + + const onSubmit = () => { + const newOptions = newFormData.reduce((acc: CustomOptionsType, item) => { + acc[item.key] = { key: item.key, label: item.label, color: item.color }; + return acc; + }, {}); + updateOptions(newOptions); + }; + + return ( + + + + + + + + + + + + ); +} + +export default CustomizeOptions; diff --git a/components/SettingsPanel/settingPages/SyncProgress.tsx b/components/SettingsPanel/settingPages/SyncProgress.tsx new file mode 100644 index 0000000..5c532c8 --- /dev/null +++ b/components/SettingsPanel/settingPages/SyncProgress.tsx @@ -0,0 +1,85 @@ +import { ProgressKeyType, useQuestProgress } from "@hooks/useProgress"; +import React, { useMemo, useState } from "react"; +import { Alert, Button, Form } from "react-bootstrap"; + +export default function SyncProgress() { + const [syncStatus, setSyncStatus] = useState< + "idle" | "fetched" | "set" | "error" + >("idle"); + const [inputData, setInputData] = useState(""); + const { allProgress, setAllProgress } = useQuestProgress(); + + const allProgressStr = useMemo( + () => JSON.stringify(allProgress, null, 2), + [allProgress] + ); + + const onFetchClick = () => { + setInputData(allProgressStr); + setSyncStatus("fetched"); + }; + + const onSaveClick = () => { + try { + const parsedData = JSON.parse(inputData) as Record< + string, + ProgressKeyType + >; + setAllProgress(parsedData); + setSyncStatus("set"); + } catch (error) { + console.error( + `Error handling Set AllProgress: ` + + (error instanceof Error ? error.message : error) + ); + setSyncStatus("error"); + } + }; + + const onCopyClick = () => { + navigator.clipboard.writeText(allProgressStr); + }; + + return ( +
+ + {syncStatus === "fetched" && ( +
+
+            {allProgressStr}
+          
+ +
+ )} + + Input Progress Data: + setInputData(e.target.value)} + /> + + + {syncStatus === "set" && ( + + 题目进度上传成功 + + )} + {syncStatus === "error" && ( + + 题目进度上传失败 + + )} +
+ ); +} diff --git a/components/SyncProgressModal/index.tsx b/components/SyncProgressModal/index.tsx deleted file mode 100644 index f447de8..0000000 --- a/components/SyncProgressModal/index.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { ProgressKeyType, useQuestProgress } from "@hooks/useProgress"; -import React, { useState } from "react"; -import { Alert, Button, Form, Modal } from "react-bootstrap"; - -interface SyncProgressModalProps { - show: boolean; - onHide: (update: boolean) => void; -} - -export default function SyncProgressModal({ - show, - onHide, -}: SyncProgressModalProps) { - const [syncStatus, setSyncStatus] = useState< - "idle" | "fetched" | "set" | "error" - >("idle"); - const [inputData, setInputData] = useState(""); - const [allProgress, setAllProgress] = useQuestProgress(); - const fetchedData = JSON.stringify(allProgress, null, 2); - - const handleFetch = () => { - setInputData(fetchedData); - setSyncStatus("fetched"); - }; - - const handleSet = () => { - try { - const parsedData = JSON.parse(inputData) as Record< - string, - ProgressKeyType - >; - setAllProgress(parsedData); - setSyncStatus("set"); - } catch (error) { - console.error( - `Error handling Set AllProgress: ` + - (error instanceof Error ? error.message : error) - ); - setSyncStatus("error"); - } - }; - - const handleCopy = () => { - navigator.clipboard.writeText(fetchedData); - }; - - return ( - { - onHide(syncStatus === "set"); - }} - > - - Sync Progress - - - - {syncStatus === "fetched" && ( -
-
-              {fetchedData}
-            
- -
- )} - - Input Progress Data: - setInputData(e.target.value)} - /> - - - {syncStatus === "set" && ( - - Progress set successfully! - - )} - {syncStatus === "error" && ( - - Error setting progress. Please check the input. - - )} -
-
- ); -} diff --git a/components/containers/ContestList/index.tsx b/components/containers/ContestList/index.tsx index 8d932f3..5b47fb4 100644 --- a/components/containers/ContestList/index.tsx +++ b/components/containers/ContestList/index.tsx @@ -80,7 +80,7 @@ function ContestList() { const [{ pageIndex, pageSize }, setPagination] = React.useState({ pageIndex: 0, - pageSize: parseInt(size), + pageSize: parseInt(size || "100"), }); const [contestsInPage, pageCount] = useMemo(() => { @@ -98,7 +98,7 @@ function ContestList() { defaultValue: "", }); - const [selectedRow, setSelectedRow] = React.useState(mark); + const [selectedRow, setSelectedRow] = React.useState(mark || ""); const [columnResizeMode, setColumnResizeMode] = React.useState("onChange"); const [globalFilter, setGlobalFilter] = React.useState(""); diff --git a/components/containers/List/index.tsx b/components/containers/List/index.tsx index 50e1fec..27d610b 100644 --- a/components/containers/List/index.tsx +++ b/components/containers/List/index.tsx @@ -52,13 +52,18 @@ export default function ({ data }: { data: ProblemCategory }) { useEffect(() => scrollToComponent(), []); - const [setting, setSetting] = useStorage("lc-rating-list-settings", { - defaultValue: { - showEn: true, - showRating: true, - showPremium: true, - }, - }); + const settingDefault = { + showEn: true, + showRating: true, + showPremium: true, + }; + + const [setting = settingDefault, setSetting] = useStorage( + "lc-rating-list-settings", + { + defaultValue: settingDefault, + } + ); const buttons = [ { diff --git a/components/containers/Zen/index.tsx b/components/containers/Zen/index.tsx index 741928e..98f2860 100644 --- a/components/containers/Zen/index.tsx +++ b/components/containers/Zen/index.tsx @@ -17,7 +17,6 @@ import { SolutionType, useSolutions } from "@hooks/useSolutions"; import useStorage from "@hooks/useStorage"; import { Tags, useTags } from "@hooks/useTags"; import { useZen } from "@hooks/useZen"; -import { Options } from "@node_modules/next/dist/server/base-server"; import { Column, @@ -34,22 +33,22 @@ import { getSortedRowModel, useReactTable, } from "@tanstack/react-table"; -import React, { useCallback, useMemo, useState, useTransition } from "react"; -import Button from "react-bootstrap/Button"; -import ButtonGroup from "react-bootstrap/ButtonGroup"; -import Container from "react-bootstrap/Container"; -import Dropdown from "react-bootstrap/Dropdown"; -import Form from "react-bootstrap/Form"; -import FormLabel from "react-bootstrap/FormLabel"; -import Modal from "react-bootstrap/Modal"; -import Pagination from "react-bootstrap/Pagination"; -import Table from "react-bootstrap/Table"; +import React, { useCallback, useMemo, useState } from "react"; +import { + Button, + ButtonGroup, + Container, + Dropdown, + Form, + FormLabel, + Modal, + Pagination, + Table, +} from "react-bootstrap"; // Constants and Enums const LC_HOST = `https://leetcode.cn`; const LC_HOST_EN = `https://leetcode.com`; -const LC_RATING_PROGRESS_KEY = (questionID: string) => - `lc-rating-zen-progress-${questionID}`; const LC_RATING_ZEN_LAST_USED_FILTER_KEY = `lc-rating-zen-last-used-filter`; const LC_RATING_ZEN_SETTINGS_KEY = `lc-rating-zen-settings`; @@ -128,9 +127,11 @@ function buildTagFilterFn( interface FilterSettingsProps { handleClose: () => void; - onSettingsSaved: React.Dispatch>; + onSettingsSaved: React.Dispatch< + React.SetStateAction + >; optionKeys: ProgressKeyType[]; - getOption: (key: ProgressKeyType) => OptionEntry; + getOption: (key?: ProgressKeyType) => OptionEntry; tags: Tags; lang: "zh" | "en"; settings: SettingsType; @@ -266,10 +267,12 @@ const FilterSettings: React.FunctionComponent = ({ }} style={{ color: getOption(curSetting.selectedProgress).color }} > - + {optionKeys.map((p) => ( - ))} @@ -310,11 +313,14 @@ export default function Zenk() { const [showFilter, setShowFilter] = useState(false); - const [settings, setSettings] = useStorage(LC_RATING_ZEN_SETTINGS_KEY, { - defaultValue: defaultSettings, - }); + const [settings = defaultSettings, setSettings] = useStorage( + LC_RATING_ZEN_SETTINGS_KEY, + { + defaultValue: defaultSettings, + } + ); - const [allProgress, _, setProgress] = useQuestProgress(); + const { allProgress, updateProgress, removeProgress } = useQuestProgress(); const { optionKeys, getOption } = useProgressOptions(); const [currentFilterKey, setCurrentFilterKey] = useStorage( @@ -353,9 +359,12 @@ export default function Zenk() { // Event handlers const handleProgressSelectChange = useCallback( - (questID: string, value: ProgressKeyType) => { - const newValue = value; - setProgress(questID, newValue); + (questID: string, progress: ProgressKeyType) => { + if (progress === getOption().key) { + removeProgress(questID); + } else { + updateProgress(questID, progress); + } }, [] ); @@ -547,22 +556,37 @@ const ZenTableComp = React.memo( enableSorting: false, cell: (info) => { const item = info.row.original; + const curOption = getOption(quest2progress(item)); + return ( handleProgressSelectChange( item.question_id, e.target.value as ProgressKeyType ) } - style={{ color: getOption(quest2progress(item)).color }} + style={{ color: curOption.color }} > {optionKeys.map((p) => ( - ))} + {!(curOption.key in optionKeys) && ( + + )} ); }, diff --git a/components/layouts/Navbar/index.tsx b/components/layouts/Navbar/index.tsx index 0fc9d52..96cbe8a 100644 --- a/components/layouts/Navbar/index.tsx +++ b/components/layouts/Navbar/index.tsx @@ -1,7 +1,7 @@ "use client"; import { GithubBasicBadge as GithubBadge } from "@components/GithubBadge"; -import SyncProgressModal from "@components/SyncProgressModal"; +import SettingsPanel from "@components/SettingsPanel"; import ThemeSwitchButton from "@components/ThemeSwitchButton"; import { useTheme } from "@hooks/useTheme"; import Link from "next/dist/client/link"; @@ -69,11 +69,8 @@ export default function () { const handleOpenModal = () => { setShowModal(true); }; - const handleCloseModal = (update: boolean) => { + const handleCloseModal = () => { setShowModal(false); - if (update) { - window.location.reload(); - } }; return ( @@ -150,10 +147,10 @@ export default function () { className="fw-bold fs-6 p-1" onClick={handleOpenModal} > - 同步进度 + 站点设置 - + ; +export type CustomOptionsType = Record; export type ProgressOptionsType = DefaultOptionsType & CustomOptionsType; export type ProgressKeyType = keyof ProgressOptionsType; @@ -57,23 +57,43 @@ export function useProgressOptions() { const optionKeys = useMemo(() => Object.keys(fullConfig), [fullConfig]); - const getOption = (key: ProgressKeyType | null | undefined) => { - if (!key) { - return defaultOptions.TODO; - } - if (!(key in fullConfig)) { - console.error(`Invalid progress key: ${key}`); - return { - key, - label: `"${key}" 未定义`, - color: "#dc3545", - }; - } - return fullConfig[key]; - }; + const getOption = useCallback( + (key?: ProgressKeyType | null) => { + if (!key) { + return defaultOptions.TODO; + } + if (!(key in fullConfig)) { + console.error(`Invalid progress key: ${key}`); + return { + key, + label: `"${key}" 未定义`, + color: "#dc3545", + }; + } + return fullConfig[key]; + }, + [fullConfig] + ); - const updateOptions = (newOptions: ProgressOptionsType) => { - setCustomOptions((prev) => ({ ...prev, ...newOptions })); + const updateOptions = (newOptions: CustomOptionsType) => { + const filteredOptions = Object.keys(newOptions).reduce( + (acc: CustomOptionsType, key) => { + if (!key) { + console.error("Key cannot be empty: ", key); + } else if (key in acc) { + console.error("Key cannot be duplicated: ", key); + } else { + acc[key] = newOptions[key]; + } + return acc; + }, + {} + ); + if ( + Object.keys(filteredOptions).length !== Object.keys(newOptions).length + ) { + } + setCustomOptions({ ...defaultOptions, ...filteredOptions }); }; return { diff --git a/hooks/useProgress/useQuestProgress.ts b/hooks/useProgress/useQuestProgress.ts index 1f70d12..d4a7a61 100644 --- a/hooks/useProgress/useQuestProgress.ts +++ b/hooks/useProgress/useQuestProgress.ts @@ -1,92 +1,137 @@ -import { useEffect, useState } from "react"; +import { useEffect, useSyncExternalStore } from "react"; import { ProgressKeyType } from "./useProgressOption"; -const getStorageKey = (questID: string) => `lc-rating-zen-progress-${questID}`; +const storageKeyPrefix = "lc-rating-zen-progress-"; +const getStorageKey = (questID: string) => `${storageKeyPrefix}${questID}`; type QuestProgressType = Record; -function useQuestProgress(): [ - QuestProgressType, - (newProgress: QuestProgressType) => void, - (questID: string, progress: ProgressKeyType) => void, - (questID: string) => void -] { - const [questProgress, setQuestProgress] = useState({}); +const isBrowser = () => typeof window !== "undefined"; + +const getQuestProgressKeys = () => { + const keys = Object.keys(localStorage).filter((key) => + key.startsWith(storageKeyPrefix) + ); + return keys; +}; + +interface StoreType { + allProgress: QuestProgressType; + setAllProgress: (newProgress: QuestProgressType) => void; + updateProgress: (questID: string, progress: ProgressKeyType) => void; + removeProgress: (questID: string) => void; + + listeners: Set<() => void>; + subscribe: (listener: () => void) => () => void; + getSnapshot: () => QuestProgressType; + notifyListeners: () => void; +} - if (typeof window === "undefined") { - return [questProgress, () => {}, () => {}, () => {}]; +class Store implements StoreType { + allProgress: QuestProgressType; + listeners: Set<() => void>; + + constructor() { + this.allProgress = {}; + this.listeners = new Set(); + + if (isBrowser()) { + const keys = getQuestProgressKeys(); + keys.forEach((key) => { + const value = localStorage.getItem(key); + const questID = key.replace(storageKeyPrefix, ""); + if (value) { + this.allProgress[questID] = value as ProgressKeyType; + } + }); + } } - const getQuestProgressKeys = () => { - const keys = Object.keys(localStorage).filter((key) => - key.startsWith("lc-rating-zen-progress-") - ); - return keys; - }; + setAllProgress = (newProgress: QuestProgressType) => { + if (isBrowser()) { + Object.entries(newProgress).forEach(([questID, progress]) => { + const key = getStorageKey(questID); + localStorage.setItem(key, progress); + }); + } - const getQuestProgress = () => { - const keys = getQuestProgressKeys(); - const newProgressMap: QuestProgressType = {}; - keys.forEach((key) => { - const value = localStorage.getItem(key); - const questID = key.replace("lc-rating-zen-progress-", ""); - if (value) { - newProgressMap[questID] = value as ProgressKeyType; - } - }); - setQuestProgress(newProgressMap); + this.allProgress = { ...this.allProgress, ...newProgress }; + this.notifyListeners(); }; - useEffect(() => { - getQuestProgress(); - }, []); - - const set = (next: QuestProgressType) => { - Object.entries(next).forEach(([questID, progress]) => { + updateProgress = (questID: string, progress: ProgressKeyType) => { + if (isBrowser()) { const key = getStorageKey(questID); localStorage.setItem(key, progress); - }); + } + + this.allProgress = { ...this.allProgress, [questID]: progress }; + this.notifyListeners(); + }; - setQuestProgress(next); + removeProgress = (questID: string) => { + if (isBrowser()) { + const key = getStorageKey(questID); + localStorage.removeItem(key); + } + + const { [questID]: _, ...rest } = this.allProgress; + this.allProgress = rest; + this.notifyListeners(); }; - const updateProgress = (questID: string, progress: ProgressKeyType) => { - const key = getStorageKey(questID); - localStorage.setItem(key, progress); - setQuestProgress((prev) => ({ ...prev, [questID]: progress })); + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); }; - const deleteProgress = (questID: string) => { - const key = getStorageKey(questID); - localStorage.removeItem(key); - setQuestProgress((prev) => { - const { [questID]: _, ...rest } = prev; - return rest; - }); + getSnapshot = () => this.allProgress; + + notifyListeners = () => { + this.listeners.forEach((listener) => listener()); }; +} + +const store = new Store(); + +function useQuestProgress(): { + allProgress: QuestProgressType; + setAllProgress: (newProgress: QuestProgressType) => void; + updateProgress: (questID: string, progress: ProgressKeyType) => void; + removeProgress: (questID: string) => void; +} { + const allProgress = useSyncExternalStore(store.subscribe, store.getSnapshot); useEffect(() => { - if (typeof window === "undefined") { + if (!isBrowser()) { return; } const handleStorageChange = (e: StorageEvent) => { if ( - e.key?.startsWith("lc-rating-zen-progress-") && + e.key?.startsWith(storageKeyPrefix) && e.storageArea === localStorage ) { - const questID = e.key.replace("lc-rating-zen-progress-", ""); + const questID = e.key.replace(storageKeyPrefix, ""); const newProgress = e.newValue as ProgressKeyType; - setQuestProgress((prev) => ({ ...prev, [questID]: newProgress })); + if (newProgress) { + store.updateProgress(questID, newProgress); + } else { + store.removeProgress(questID); + } } }; window.addEventListener("storage", handleStorageChange); - return () => window.removeEventListener("storage", handleStorageChange); }, []); - return [questProgress, set, updateProgress, deleteProgress]; + return { + allProgress, + setAllProgress: store.setAllProgress.bind(store), + updateProgress: store.updateProgress.bind(store), + removeProgress: store.removeProgress.bind(store), + }; } export default useQuestProgress; diff --git a/hooks/useStorage.ts b/hooks/useStorage.ts index 306865b..77730b9 100644 --- a/hooks/useStorage.ts +++ b/hooks/useStorage.ts @@ -2,10 +2,7 @@ import { Dispatch, SetStateAction, useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, + useSyncExternalStore, } from "react"; type StorageType = "local" | "session"; @@ -30,131 +27,138 @@ type Options = { } & Serialization & Encryption; -type Return = [ - T | undefined, - Dispatch>, - () => void -]; +// 全局存储 StorageStore 实例 +const globalStores: Record>> = { + local: {}, + session: {}, +}; + +class StorageStore { + private key: string; + private options: Options; + private listeners: Set<() => void> = new Set(); + private cachedValue: T | undefined; // Cache the last snapshot value + + constructor(key: string, options: Options) { + this.key = key; + this.options = options; + this.cachedValue = this.getValue(); // Initialize the cached value + } -type Options_D = { - type?: StorageType; - defaultValue: T; -} & Serialization & - Encryption; + public getStorage(): Storage | undefined { + if (typeof window === "undefined") { + return undefined; + } + return this.options.type === "session" + ? window.sessionStorage + : window.localStorage; + } -type Return_D = [T, Dispatch>, () => void]; + private getValue(): T | undefined { + const storage = this.getStorage(); + const value = storage?.getItem(this.key); + if (value !== undefined && value !== null) { + const decryptedValue = + "decrypt" in this.options ? this.options.decrypt(value) : value; + const deserializedValue = + "deserializer" in this.options + ? this.options.deserializer(decryptedValue) + : JSON.parse(decryptedValue); + return deserializedValue; + } + return this.options.defaultValue; + } -function useStorage(key: string, options: Options_D): Return_D; -function useStorage(key: string, options?: Options): Return; -function useStorage(key: string, options?: Options): Return { - const defaultValue = options?.defaultValue; + private setValue(value: T | undefined) { + const storage = this.getStorage(); + if (value === undefined || value === null) { + storage?.removeItem(this.key); + } else { + const serializedValue = + "serializer" in this.options + ? this.options.serializer(value) + : JSON.stringify(value); + const encryptedValue = + "encrypt" in this.options + ? this.options.encrypt(serializedValue) + : serializedValue; + storage?.setItem(this.key, encryptedValue); + } + this.cachedValue = value; + this.notifyListeners(); + } + + subscribe(listener: () => void): () => void { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === null) return; + if (e.key === this.key && e.storageArea === this.getStorage()) { + this.cachedValue = this.getValue(); + this.notifyListeners(); + } + }; + this.listeners.add(listener); + window.addEventListener("storage", handleStorageChange); - if (!key) { - throw new Error("useLocalStorage key may not be falsy"); + return () => { + window.removeEventListener("storage", handleStorageChange); + this.listeners.delete(listener); + }; } - if (typeof window === "undefined") { - return [defaultValue, () => {}, () => {}]; + getSnapshot(): T | undefined { + return this.cachedValue; } - const type = options?.type ?? "local"; - const encryption = - options && "encrypt" in options ? options.encrypt : (data: string) => data; - const decryption = - options && "decrypt" in options ? options.decrypt : (data: string) => data; - const serializer = - options && "serializer" in options ? options.serializer : JSON.stringify; - const deserializer = - options && "deserializer" in options ? options.deserializer : JSON.parse; - - const storage = - type === "session" ? window.sessionStorage : window.localStorage; - - const initializer = useRef((key: string) => { - try { - const localStorageValue = storage.getItem(key); - if (localStorageValue !== null) { - return deserializer(decryption(localStorageValue)); - } else { - defaultValue && - storage.setItem(key, encryption(serializer(defaultValue))); - return defaultValue; - } - } catch (error) { - if (error instanceof Error) { - console.error( - `Error handling storage getItem for key "${key}": ` + - (error instanceof Error ? error.message : error) - ); - return defaultValue; - } - } - }); + getServerSnapshot(): T | undefined { + return this.options.defaultValue; + } - const [state, setState] = useState(() => - initializer.current(key) - ); + notifyListeners() { + this.listeners.forEach((listener) => listener()); + } - useLayoutEffect(() => setState(initializer.current(key)), [key]); - - const setItem: Dispatch> = useCallback( - (valOrFunc: SetStateAction) => { - try { - const newState = - valOrFunc instanceof Function - ? valOrFunc(initializer.current(key)) - : valOrFunc; - - storage.setItem(key, encryption(serializer(newState))); - setState(newState); - } catch (error) { - console.error( - `Error handling storage setItem for key "${key}": ` + - (error instanceof Error ? error.message : error) - ); - } - }, - [key, setState] - ); + setItem(value: SetStateAction) { + const newValue = + typeof value === "function" + ? (value as (prevState: T | undefined) => T | undefined)( + this.getSnapshot() + ) + : value; + this.setValue(newValue); + } +} - const removeItem = useCallback(() => { - try { - localStorage.removeItem(key); - setState(undefined); - } catch (error) { - console.error( - `Error handling storage removeItem for key "${key}": ` + - (error instanceof Error ? error.message : error) - ); - } - }, [key, setState]); +function getOrCreateStore( + key: string, + options: Options +): StorageStore { + const storeType = options.type || "local"; + let store = globalStores[storeType][key]; + if (!store) { + store = globalStores[storeType][key] = new StorageStore(key, options); + } + return store; +} - useEffect(() => { - if (type !== "local" || !storage) return; +type Return = [T | undefined, Dispatch>]; - const handleStorageChange = (e: StorageEvent) => { - if (e.key === key && e.storageArea === storage) { - try { - if (e.newValue !== null) { - setState(deserializer(decryption(e.newValue))); - } else { - setState(defaultValue); - } - } catch (error) { - console.error( - `Error handling storage change for key "${key}": ` + - (error instanceof Error ? error.message : error) - ); - } - } - }; +function useStorage(key: string, options?: Options): Return { + const store = getOrCreateStore(key, options || {}); - window.addEventListener("storage", handleStorageChange); + const state: T | undefined = useSyncExternalStore( + store.subscribe.bind(store), + store.getSnapshot.bind(store), + store.getServerSnapshot.bind(store) + ); - return () => window.removeEventListener("storage", handleStorageChange); - }, [key, setState]); + const setItem = useCallback( + (value: SetStateAction) => { + store.setItem(value); + }, + [store] + ); - return [state, setItem, removeItem]; + return [state, setItem]; } export default useStorage; diff --git a/hooks/useTheme.tsx b/hooks/useTheme.tsx index 0f1c7e5..f70d58e 100644 --- a/hooks/useTheme.tsx +++ b/hooks/useTheme.tsx @@ -18,7 +18,7 @@ interface ThemeProviderProps { } function ThemeProvider({ children }: ThemeProviderProps) { - const [theme, setTheme] = useStorage("theme", { + const [theme = Theme.Light, setTheme] = useStorage("theme", { defaultValue: Theme.Light, }); diff --git a/package.json b/package.json index 93ea866..ecf5c4c 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@tanstack/match-sorter-utils": "^8.8.4", "@tanstack/react-query": "^5.45.1", "@tanstack/react-table": "^8.15.3", + "axios": "1.7.7", "bootstrap": "^5.3.0", "clsx": "^2.1.1", "github-star-badge": "^1.1.6", @@ -24,6 +25,7 @@ "react-bootstrap": "^2.7.4", "react-dom": "^18.2.0", "react-draggable": "^4.4.6", + "react-icons": "^5.2.1", "react-query": "^3.39.3", "react-router-dom": "^6.11.2", "react-syntax-highlighter": "^15.5.0", @@ -34,9 +36,7 @@ "remark-math": "^6.0.0", "remark-mdx-frontmatter": "^4.0.0", "shiki": "^1.3.0", - "axios": "1.7.7", - "web-vitals": "^3.3.1", - "react-icons": "^5.2.1" + "web-vitals": "^3.3.1" }, "devDependencies": { "@types/mdx": "^2.0.13", @@ -44,6 +44,8 @@ "@types/react": "^18.0.28", "@types/react-dom": "^18.0.11", "@types/react-syntax-highlighter": "^15.5.13", + "eslint": "8.57.1", + "eslint-config-next": "15.2.1", "sass": "^1.62.1", "typescript": "^5.0.2" }