feat: new Gallery UI (#414)

This commit is contained in:
Kaiyi
2023-04-24 17:14:50 +08:00
committed by GitHub
parent 1e76d11d8a
commit b29141c61c
38 changed files with 2073 additions and 1087 deletions

View File

@@ -18,6 +18,7 @@
"fortawesome",
"headlessui",
"heroicons",
"IELTS",
"immer",
"pako",
"romaji",

View File

@@ -9,8 +9,10 @@
"@headlessui/tailwindcss": "^0.1.2",
"@heroicons/react": "^2.0.17",
"@radix-ui/react-progress": "^1.0.2",
"@radix-ui/react-scroll-area": "^1.0.3",
"@radix-ui/react-slider": "^1.1.1",
"@tabler/icons-react": "^2.16.0",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/postcss7-compat": "^2.2.17",
"autoprefixer": "^10.4.13",
"classnames": "^2.3.2",
@@ -71,11 +73,11 @@
"@types/howler": "^2.2.3",
"@types/mixpanel-browser": "^2.38.1",
"@types/node": "18.14.6",
"@types/pako": "^2.0.0",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/react-router-dom": "^5.1.7",
"@vitejs/plugin-react": "^3.1.0",
"@types/pako": "^2.0.0",
"cross-env": "^7.0.3",
"eslint-config-prettier": "^8.7.0",
"eslint-config-react-app": "^7.0.1",

BIN
src/assets/book-cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
src/assets/flags/cn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
src/assets/flags/code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
src/assets/flags/de.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
src/assets/flags/en.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
src/assets/flags/ja.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,9 +1,11 @@
import alipay from '@/assets/alipay.jpg'
import cnFlag from '@/assets/flags/cn.png'
import redBookLogo from '@/assets/redBook-black-logo.svg'
import redBookCode from '@/assets/redBook-code.jpg'
import vscLogo from '@/assets/vsc-logo.svg'
import weChat from '@/assets/weChat.jpg'
import InfoPanel from '@/components/InfoPanel'
import Tooltip from '@/components/Tooltip'
import { infoPanelStateAtom } from '@/store'
import { InfoPanelType } from '@/typings'
import { recordOpenInfoPanelAction } from '@/utils'
@@ -150,10 +152,7 @@ const Footer: React.FC = () => {
</InfoPanel>
)}
<footer
className="mt-4 flex flex w-full items-center items-center justify-center justify-center pb-1 text-sm ease-in"
onClick={(e) => e.currentTarget.blur()}
>
<footer className="mt-4 flex w-full items-center justify-center pb-1 text-sm ease-in" onClick={(e) => e.currentTarget.blur()}>
<a href="https://github.com/Kaiyiwing/qwerty-learner" target="_blank" rel="noreferrer">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -221,15 +220,11 @@ const Footer: React.FC = () => {
<a href="mailto:me@kaiyi.cool" target="_blank" rel="noreferrer" onClick={(e) => e.currentTarget.blur()}>
<EnvelopeIcon className="mr-3 inline h-4 w-4 text-gray-500 dark:text-gray-400" />
</a>
<div className="group relative inline-block ">
<a href="https://kaiyiwing.gitee.io/qwerty-learner/" className="mr-3 text-gray-500 dark:text-gray-400" title="中国大陆节点">
🇨🇳
<Tooltip content="中国大陆镜像">
<a href="https://kaiyiwing.gitee.io/qwerty-learner" target="_self">
<img src={cnFlag} className="mr-2 h-5 w-5 cursor-pointer" />
</a>
<div className="invisible absolute bottom-full left-1/2 -ml-20 flex w-40 items-center justify-center pt-2 group-hover:visible">
<span className="px-3 py-1 text-xs text-gray-500 dark:text-gray-400"></span>
</div>
</div>
</Tooltip>
<button
className="cursor-pointer text-gray-500 no-underline hover:no-underline dark:text-gray-400 "
type="button"

View File

@@ -13,7 +13,7 @@ const Header: React.FC<PropsWithChildren> = ({ children }) => {
<img src={logo} className="mr-3 h-16 w-16" />
<h1>Qwerty Learner</h1>
</NavLink>
<nav className="card on element flex w-auto content-center items-center justify-end space-x-3 rounded-large bg-white p-4 transition-colors duration-300 dark:bg-gray-800">
<nav className="card on element flex w-auto content-center items-center justify-end space-x-3 rounded-xl bg-white p-4 transition-colors duration-300 dark:bg-gray-800">
{children}
</nav>
</div>

View File

@@ -1,13 +1,11 @@
import Footer from './Footer'
import React from 'react'
const Layout: React.FC<React.PropsWithChildren> = ({ children }) => (
<main className="flex h-screen w-full flex-col items-center pb-4">
{children}
<Footer />
</main>
)
Layout.displayName = 'Layout'
export default Layout
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<main className="flex h-screen w-full flex-col items-center pb-4">
{children}
<Footer />
</main>
)
}

View File

@@ -76,7 +76,7 @@ export default function StarCard() {
return (
<Transition
appear={true}
appear
show={isShow}
enter="transition ease-out duration-300 transform"
enterFrom="translate-x-full -translate-y-full"

View File

@@ -1,5 +1,6 @@
import './index.css'
import GalleryPage from './pages/Gallery'
// import GalleryPage from './pages/Gallery'
import GalleryPage from './pages/Gallery-N'
import TypingPage from './pages/Typing'
import { isOpenDarkModeAtom } from '@/store'
import dayjs from 'dayjs'
@@ -23,8 +24,6 @@ if (process.env.NODE_ENV === 'production') {
dayjs.extend(utc)
const container = document.getElementById('root')
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const root = createRoot(container!)
function Root() {
const darkMode = useAtomValue(isOpenDarkModeAtom)
@@ -45,4 +44,4 @@ function Root() {
)
}
root.render(<Root />)
container && createRoot(container).render(<Root />)

View File

@@ -0,0 +1,47 @@
import DictTagSwitcher from './DictTagSwitcher'
import Dictionary from './DictionaryWithoutCover'
import { GalleryContext } from './index'
import { currentDictInfoAtom } from '@/store'
import { DictionaryResource } from '@/typings'
import { findCommonValues } from '@/utils'
import { useAtomValue } from 'jotai'
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
export default function DictionaryGroup({ groupedDictsByTag }: { groupedDictsByTag: Record<string, DictionaryResource[]> }) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { setState } = useContext(GalleryContext)!
const tagList = useMemo(() => Object.keys(groupedDictsByTag), [groupedDictsByTag])
const [currentTag, setCurrentTag] = useState(tagList[0])
const currentDictInfo = useAtomValue(currentDictInfoAtom)
const onChangeCurrentTag = useCallback((tag: string) => {
setCurrentTag(tag)
}, [])
const onClickDict = useCallback(
(dict: DictionaryResource) => {
setState((state) => {
state.chapterListDict = dict
})
},
[setState],
)
useEffect(() => {
const commonTags = findCommonValues(tagList, currentDictInfo.tags)
if (commonTags.length > 0) {
setCurrentTag(commonTags[0])
}
}, [currentDictInfo.tags, tagList])
return (
<div>
<DictTagSwitcher tagList={tagList} currentTag={currentTag} onChangeCurrentTag={onChangeCurrentTag} />
<div className="mt-8 grid gap-x-5 gap-y-10 px-1 pb-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{groupedDictsByTag[currentTag].map((dict) => (
<Dictionary key={dict.id} dictionary={dict} onClick={() => onClickDict(dict)} />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { RadioGroup } from '@headlessui/react'
import { useState } from 'react'
interface Props {
titles?: string[]
}
export default function CategoryNavigation({ titles = ['中国考试', '留学考试', '代码练习'] }: Props) {
const [selectedTitle, setSelectedTitle] = useState(titles[0])
return (
<div className="mr-4 flex flex-col items-center justify-center pr-4">
<RadioGroup value={selectedTitle} onChange={setSelectedTitle} className="flex flex-col gap-y-3">
{titles.map((title) => (
<RadioGroup.Option
key={title}
value={title}
className={({ checked }) => `flex cursor-pointer items-center space-x-2 ${checked ? 'text-gray-800' : 'text-gray-500'}`}
>
{({ checked }) => (
<>
<div className={`mr-1 h-2.5 w-2.5 rounded-full ${checked ? 'bg-indigo-400' : 'bg-indigo-100'}`} />
<RadioGroup.Label className="text-lg ">{title}</RadioGroup.Label>
</>
)}
</RadioGroup.Option>
))}
</RadioGroup>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import useIntersectionObserver from '@/hooks/useIntersectionObserver'
import { useChapterStats } from '@/pages/Gallery-N/hooks/useChapterStats'
import noop from '@/utils/noop'
import { useEffect, useRef } from 'react'
type ChapterRowProps = {
index: number
checked: boolean
dictID: string
onChange: (index: number) => void
}
export default function ChapterRow({ index, dictID, checked, onChange }: ChapterRowProps) {
const rowRef = useRef<HTMLTableRowElement>(null)
const entry = useIntersectionObserver(rowRef, {})
const isVisible = !!entry?.isIntersecting
const chapterStatus = useChapterStats(index, dictID, isVisible)
useEffect(() => {
if (checked && rowRef.current !== null) {
const button = rowRef.current
const container = button.parentElement?.parentElement?.parentElement
container?.scroll({
top: button.offsetTop - container.offsetTop - 300,
behavior: 'smooth',
})
}
}, [checked])
return (
<tr className="flex cursor-pointer even:bg-gray-50 hover:bg-indigo-100" ref={rowRef} onClick={() => onChange(index)}>
<td className="flex w-15 items-center justify-center px-6 py-4">
<input
type="radio"
name="selectedChapter"
checked={checked}
onChange={noop}
className="mt-0.5 h-3.5 w-3.5 cursor-pointer rounded-full border-gray-300 text-indigo-600 outline-none focus:border-none focus:outline-none focus:ring-0 focus:ring-offset-0 "
/>
</td>
<td className="flex-1 px-6 py-4 text-center text-sm text-gray-700">{index + 1}</td>
<td className="flex-1 px-6 py-4 text-center text-sm text-gray-700">{chapterStatus ? chapterStatus.exerciseCount : 0}</td>
<td className="flex-1 px-6 py-4 text-center text-sm text-gray-700">{chapterStatus ? chapterStatus.avgWrongCount : 0}</td>
</tr>
)
}

View File

@@ -0,0 +1,128 @@
import { GalleryContext } from '..'
import ChapterRow from './ChapterRow'
import { currentChapterAtom, currentDictIdAtom } from '@/store'
import { calcChapterCount } from '@/utils'
import range from '@/utils/range'
import { Dialog, Transition } from '@headlessui/react'
import { IconX } from '@tabler/icons-react'
import { useAtom } from 'jotai'
import { Fragment, useCallback, useContext, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
export default function ChapterList() {
const {
state: { chapterListDict: dict },
setState,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
} = useContext(GalleryContext)!
const [currentChapter, setCurrentChapter] = useAtom(currentChapterAtom)
const [currentDictId, setCurrentDictId] = useAtom(currentDictIdAtom)
const [checkedChapter, setCheckedChapter] = useState(dict?.id === currentDictId ? currentChapter : 0)
const navigate = useNavigate()
const chapterCount = calcChapterCount(dict?.length ?? 0)
const showChapterList = dict !== null
useEffect(() => {
if (dict) {
setCheckedChapter(dict.id === currentDictId ? currentChapter : 0)
}
}, [currentChapter, currentDictId, dict])
const onChangeChapter = (index: number) => {
setCheckedChapter(index)
}
const onConfirm = useCallback(() => {
if (dict) {
setCurrentChapter(checkedChapter)
setCurrentDictId(dict.id)
setState((state) => {
state.chapterListDict = null
})
navigate('/')
}
}, [checkedChapter, dict, navigate, setCurrentChapter, setCurrentDictId, setState])
const onCloseDialog = () => {
setState((state) => {
state.chapterListDict = null
})
}
return (
<>
<Transition appear show={showChapterList} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onCloseDialog}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 h-full w-full">
<Transition.Child
as={Fragment}
enter="transition ease-out duration-300 transform"
enterFrom="translate-x-full "
enterTo=""
leave="transition ease-in duration-300 transform"
leaveFrom=""
leaveTo="translate-x-full "
>
<Dialog.Panel className="absolute right-0 flex h-full w-100 flex-col bg-white drop-shadow-2xl transition-all duration-300 ease-out">
{dict && (
<>
<div className="flex w-full items-end justify-between py-4 pl-5">
<span className="text-lg">{dict.name}</span>
<IconX className="mr-2 cursor-pointer text-gray-400" onClick={onCloseDialog} />
</div>
<div className="w-full flex-1 overflow-y-auto">
<table className="block min-w-full divide-y divide-gray-100">
<thead className="sticky top-0 block h-10 w-full bg-gray-50">
<tr className="flex">
<th scope="col" className="w-15 px-2 py-3 text-center text-sm font-bold tracking-wider text-gray-600"></th>
<th scope="col" className="flex-1 px-2 py-3 text-center text-sm font-bold tracking-wider text-gray-600">
Chapter
</th>
<th scope="col" className="flex-1 px-2 py-3 text-center text-sm font-bold tracking-wider text-gray-600">
</th>
<th scope="col" className="flex-1 px-2 py-3 text-center text-sm font-bold tracking-wider text-gray-600">
</th>
</tr>
</thead>
<tbody className="block h-full w-full divide-y divide-gray-100 overflow-y-scroll bg-white">
{range(0, chapterCount, 1).map((index) => (
<ChapterRow
key={`${dict.id}-${index}`}
index={index}
dictID={dict.id}
checked={checkedChapter === index}
onChange={onChangeChapter}
/>
))}
</tbody>
</table>
</div>
<button className="text-bold h-15 w-full bg-indigo-400 text-white" onClick={onConfirm}>
</button>
</>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
)
}

View File

@@ -0,0 +1,55 @@
import InfoPanel from '@/components/InfoPanel'
import { IconBook2 } from '@tabler/icons-react'
import { useCallback, useState } from 'react'
export default function DictRequest() {
const [showPanel, setShowPanel] = useState(false)
const onOpenPanel = useCallback(() => {
setShowPanel(true)
}, [])
const onClosePanel = useCallback(() => {
setShowPanel(false)
}, [])
return (
<>
{showPanel && (
<InfoPanel
openState={showPanel}
title="申请词典"
icon={IconBook2}
btnColor="bg-indigo-300"
iconColor="text-indigo-500"
iconBackgroundColor="bg-indigo-100"
onClose={onClosePanel}
>
<p className="text-sm text-gray-600 dark:text-gray-300">
<a
href="https://github.com/Kaiyiwing/qwerty-learner/blob/master/docs/toBuildDict.md"
className="px-2 text-blue-500"
target="_blank"
rel="noreferrer"
>
</a>
<br />
<br />
{' '}
<a href="mailto:me@kaiyi.cool" className="px-2 text-blue-500">
me@kaiyi.cool
</a>
</p>
<br />
</InfoPanel>
)}
<button className="cursor-pointer pr-6 text-sm text-indigo-500" onClick={onOpenPanel}>
</button>
</>
)
}

View File

@@ -0,0 +1,37 @@
import { RadioGroup } from '@headlessui/react'
import { useCallback } from 'react'
type Props = {
tagList: string[]
currentTag: string
onChangeCurrentTag: (tag: string) => void
}
export default function DictTagSwitcher({ tagList, currentTag, onChangeCurrentTag }: Props) {
const onChangeTag = useCallback(
(tag: string) => {
onChangeCurrentTag(tag)
},
[onChangeCurrentTag],
)
return (
<RadioGroup value={currentTag} onChange={onChangeTag}>
<div className="flex items-center space-x-4">
{tagList.map((option) => (
<RadioGroup.Option
key={option}
value={option}
className={({ checked }) =>
`cursor-pointer whitespace-nowrap rounded-[3rem] px-4 py-2 ${
checked ? 'bg-indigo-400 text-white' : 'bg-white text-gray-600 dark:bg-gray-800 dark:text-gray-200'
} ${!checked && 'hover:bg-indigo-100 dark:hover:bg-gray-600'}`
}
>
<p className={`font-normal `}>{option}</p>
</RadioGroup.Option>
))}
</div>
</RadioGroup>
)
}

View File

@@ -0,0 +1,31 @@
import { DictionaryResource } from '@/typings'
import * as Progress from '@radix-ui/react-progress'
interface Props {
dictionary: DictionaryResource
onClick?: () => void
}
function Dictionary({ dictionary, onClick }: Props) {
return (
<div className="flex h-40 w-80 items-center justify-center" onClick={onClick}>
<div className="h-full w-5/12 rounded-xl bg-gray-300">
<div className="bg-gray-700"></div>
</div>
<div className="flex h-full w-7/12 flex-col items-start justify-start px-6 pt-2">
<h1 className="mb-5 text-2xl font-normal">{dictionary.name}</h1>
<p className="mb-6 text-lg text-gray-600">{dictionary.length}</p>
<div className="mb-0 flex w-full items-center">
<Progress.Root value={10} max={100} className="mr-4 h-3.5 w-full rounded-full border-2 border-indigo-400 bg-white">
<Progress.Indicator
className="h-full -translate-x-px rounded-full bg-indigo-400 pl-0"
style={{ width: `calc(${10}% + 2px)` }}
/>
</Progress.Root>
</div>
</div>
</div>
)
}
export default Dictionary

View File

@@ -0,0 +1,66 @@
import { useDictStats } from './hooks/useDictStats'
import bookCover from '@/assets/book-cover.png'
import useIntersectionObserver from '@/hooks/useIntersectionObserver'
import { currentDictIdAtom } from '@/store'
import { DictionaryResource } from '@/typings'
import { calcChapterCount } from '@/utils'
import * as Progress from '@radix-ui/react-progress'
import { useAtomValue } from 'jotai'
import { useMemo, useRef } from 'react'
interface Props {
dictionary: DictionaryResource
onClick?: () => void
}
function Dictionary({ dictionary, onClick }: Props) {
const currentDictID = useAtomValue(currentDictIdAtom)
const divRef = useRef<HTMLDivElement>(null)
const entry = useIntersectionObserver(divRef, {})
const isVisible = !!entry?.isIntersecting
const dictStats = useDictStats(dictionary.id, isVisible)
const chapterCount = useMemo(() => calcChapterCount(dictionary.length), [dictionary.length])
const isSelected = currentDictID === dictionary.id
const progress = useMemo(() => (dictStats ? dictStats.exercisedChapterCount / chapterCount : 0), [dictStats, chapterCount])
return (
<div
ref={divRef}
className={`group flex h-36 w-80 cursor-pointer items-center justify-center overflow-hidden rounded-lg p-4 text-left shadow-lg focus:outline-none ${
isSelected ? 'bg-indigo-400' : 'bg-zinc-50 hover:bg-white dark:bg-gray-600 dark:hover:bg-gray-500'
}`}
role="button"
onClick={onClick}
>
<div className="relative ml-1 mt-2 flex h-full w-full flex-col items-start justify-start">
<h1
className={`mb-1.5 text-xl font-normal ${
isSelected ? 'text-white' : 'text-gray-800 group-hover:text-indigo-400 dark:text-gray-200'
}`}
>
{dictionary.name}
</h1>
<p className={`mb-1 truncate ${isSelected ? 'text-white' : 'text-gray-600 dark:text-gray-200'}`}>{dictionary.description}</p>
<p className={`mb-0.5 font-bold ${isSelected ? 'text-white' : 'text-gray-600 dark:text-gray-200'}`}>{dictionary.length} </p>
<div className=" flex w-full items-center pt-2">
{progress > 0 && (
<Progress.Root
value={progress}
max={100}
className={`mr-4 h-2 w-full rounded-full border bg-white ${isSelected ? 'border-indigo-600' : 'border-indigo-400'}`}
>
<Progress.Indicator
className={`h-full rounded-full pl-0 ${isSelected ? 'bg-indigo-600' : 'bg-indigo-400'}`}
style={{ width: `calc(${progress}% )` }}
/>
</Progress.Root>
)}
<img src={bookCover} className={`absolute right-3 top-3 w-16 ${isSelected ? 'opacity-50' : 'opacity-20'}`} />
</div>
</div>
</div>
)
}
export default Dictionary

View File

@@ -0,0 +1,52 @@
import { GalleryContext } from '.'
import codeFlag from '@/assets/flags/code.png'
import deFlag from '@/assets/flags/de.png'
import enFlag from '@/assets/flags/en.png'
import jpFlag from '@/assets/flags/ja.png'
import { LanguageCategoryType } from '@/typings'
import { RadioGroup } from '@headlessui/react'
import { useCallback, useContext } from 'react'
export type LanguageTabOption = {
id: LanguageCategoryType
name: string
flag: string
}
const options: LanguageTabOption[] = [
{ id: 'en', name: '英语', flag: enFlag },
{ id: 'ja', name: '日语', flag: jpFlag },
{ id: 'de', name: '德语', flag: deFlag },
{ id: 'code', name: 'Code', flag: codeFlag },
]
export function LanguageTabSwitcher() {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { state, setState } = useContext(GalleryContext)!
const onChangeTab = useCallback(
(tab: string) => {
setState((draft) => {
draft.currentLanguageTab = tab as LanguageCategoryType
})
},
[setState],
)
return (
<RadioGroup value={state.currentLanguageTab} onChange={onChangeTab}>
<div className="flex items-center space-x-4">
{options.map((option) => (
<RadioGroup.Option key={option.id} value={option.id} className="cursor-pointer">
{({ checked }) => (
<div className={`flex items-center border-b-2 px-2 pb-1 ${checked ? 'border-indigo-500' : 'border-transparent'}`}>
<img src={option.flag} className="mr-1.5 h-7 w-7" />
<p className={`text-lg font-medium text-gray-700 dark:text-gray-200`}>{option.name}</p>
</div>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
)
}

View File

@@ -0,0 +1,36 @@
import { db } from '@/utils/db'
import { IChapterRecord } from '@/utils/db/record'
import { useEffect, useState } from 'react'
export function useChapterStats(chapter: number, dictID: string, isStartLoad: boolean) {
const [chapterStats, setChapterStats] = useState<IChapterStats | null>(null)
useEffect(() => {
const fetchChapterStats = async () => {
const stats = await getChapterStats(dictID, chapter)
setChapterStats(stats)
}
if (isStartLoad && !chapterStats) {
fetchChapterStats()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dictID, chapter, isStartLoad])
return chapterStats
}
interface IChapterStats {
exerciseCount: number
avgWrongCount: number
}
async function getChapterStats(dict: string, chapter: number | null): Promise<IChapterStats> {
const records: IChapterRecord[] = await db.chapterRecords.where({ dict, chapter }).toArray()
const exerciseCount = records.length
const totalWrongCount = records.reduce((total, { wrongCount }) => total + (wrongCount ?? 0), 0)
const avgWrongCount = exerciseCount > 0 ? totalWrongCount / exerciseCount : 0
return { exerciseCount, avgWrongCount }
}

View File

@@ -0,0 +1,37 @@
import { db } from '@/utils/db'
import { IChapterRecord } from '@/utils/db/record'
import { useEffect, useState } from 'react'
export function useDictStats(dictID: string, isStartLoad: boolean) {
const [dictStats, setDictStats] = useState<IDictStats | null>(null)
useEffect(() => {
const fetchDictStats = async () => {
const stats = await getDictStats(dictID)
setDictStats(stats)
}
if (isStartLoad && !dictStats) {
fetchDictStats()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dictID, isStartLoad])
return dictStats
}
interface IDictStats {
exercisedChapterCount: number
}
async function getDictStats(dict: string): Promise<IDictStats> {
const records: IChapterRecord[] = await db.chapterRecords.where({ dict }).toArray()
const allChapter = records.map(({ chapter }) => chapter).filter((item) => item !== null) as number[]
const uniqueChapter = allChapter.filter((value, index, self) => {
return self.indexOf(value) === index
})
const exercisedChapterCount = uniqueChapter.length
return { exercisedChapterCount }
}

View File

@@ -0,0 +1,96 @@
import DictionaryGroup from './CategoryDicts'
import ChapterList from './ChapterList'
import DictRequest from './DictRequest'
import { LanguageTabSwitcher } from './LanguageTabSwitcher'
import Layout from '@/components/Layout'
import { dictionaries } from '@/resources/dictionary'
import { currentDictInfoAtom } from '@/store'
import { DictionaryResource, LanguageCategoryType } from '@/typings'
import groupBy, { groupByDictTags } from '@/utils/groupBy'
import * as ScrollArea from '@radix-ui/react-scroll-area'
import { IconX } from '@tabler/icons-react'
import { useAtomValue } from 'jotai'
import { createContext, useCallback, useEffect, useMemo } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useNavigate } from 'react-router-dom'
import { Updater, useImmer } from 'use-immer'
export type GalleryState = {
currentLanguageTab: LanguageCategoryType
chapterListDict: DictionaryResource | null
}
const initialGalleryState: GalleryState = {
currentLanguageTab: 'en',
chapterListDict: null,
}
export const GalleryContext = createContext<{
state: GalleryState
setState: Updater<GalleryState>
} | null>(null)
export default function GalleryPage() {
const [galleryState, setGalleryState] = useImmer<GalleryState>(initialGalleryState)
const navigate = useNavigate()
const currentDictInfo = useAtomValue(currentDictInfoAtom)
const { groupedByCategoryAndTag } = useMemo(() => {
const currentLanguageCategoryDicts = dictionaries.filter((dict) => dict.languageCategory === galleryState.currentLanguageTab)
const groupedByCategory = Object.entries(groupBy(currentLanguageCategoryDicts, (dict) => dict.category))
const groupedByCategoryAndTag = groupedByCategory.map(
([category, dicts]) => [category, groupByDictTags(dicts)] as [string, Record<string, DictionaryResource[]>],
)
return {
groupedByCategoryAndTag,
}
}, [galleryState.currentLanguageTab])
const onBack = useCallback(() => {
navigate('/')
}, [navigate])
useHotkeys('enter,esc', onBack, { preventDefault: true })
useEffect(() => {
if (currentDictInfo) {
setGalleryState((state) => {
state.currentLanguageTab = currentDictInfo.languageCategory
})
}
}, [currentDictInfo, setGalleryState])
return (
<Layout>
<GalleryContext.Provider value={{ state: galleryState, setState: setGalleryState }}>
<ChapterList />
<div className="relative mb-auto mt-auto flex w-full flex-1 flex-col overflow-y-auto pl-20">
<IconX className="absolute right-20 top-10 mr-2 h-7 w-7 cursor-pointer text-gray-400" onClick={onBack} />
<div className="mt-20 flex w-full flex-1 flex-col items-center justify-center overflow-y-auto">
<div className="flex h-full flex-col overflow-y-auto">
<div className="flex h-20 w-full items-center justify-between pb-6">
<LanguageTabSwitcher />
<DictRequest />
</div>
<ScrollArea.Root className="flex-1 overflow-y-auto">
<ScrollArea.Viewport className="h-full w-full pb-[20rem]">
<div className="mr-4 flex flex-1 flex-col items-start justify-start gap-14 overflow-y-auto">
{groupedByCategoryAndTag.map(([category, groupeByTag]) => (
<DictionaryGroup key={category} groupedDictsByTag={groupeByTag} />
))}
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar className="flex touch-none select-none bg-transparent " orientation="vertical"></ScrollArea.Scrollbar>
</ScrollArea.Root>
{/* todo: 增加导航 */}
{/* <div className="mt-20 h-40 w-40 text-center ">
<CategoryNavigation />
</div> */}
</div>
</div>
</div>
</GalleryContext.Provider>
</Layout>
)
}

View File

@@ -24,10 +24,10 @@ const Progress: React.FC<ProgressProps> = ({ order, wordsLength }) => {
return (
<div className="relative mt-auto w-1/4 pt-1">
<div className="mb-4 flex h-2 overflow-hidden rounded-large bg-indigo-100 text-xs transition-all duration-300 dark:bg-indigo-200">
<div className="mb-4 flex h-2 overflow-hidden rounded-xl bg-indigo-100 text-xs transition-all duration-300 dark:bg-indigo-200">
<div
style={{ width: `${progress}%` }}
className={`flex flex-col justify-center whitespace-nowrap rounded-large text-center text-white shadow-none transition-all duration-300 ${
className={`flex flex-col justify-center whitespace-nowrap rounded-xl text-center text-white shadow-none transition-all duration-300 ${
colorSwitcher[phase] ?? 'bg-indigo-200 dark:bg-indigo-300'
}`}
></div>

View File

@@ -117,7 +117,7 @@ export default function SharePicDialog({ showState, setShowState, randomChoose }
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-large bg-white text-left shadow-xl transition-all dark:bg-gray-700">
<Dialog.Panel className="relative transform overflow-hidden rounded-xl bg-white text-left shadow-xl transition-all dark:bg-gray-700">
<div className="flex flex-col items-center justify-center pb-10 pl-20 pr-14 pt-20">
<button className="absolute right-7 top-5" onClick={handleClose}>
<XMarkIcon className="h-6 w-6 text-gray-400" />
@@ -163,7 +163,7 @@ export default function SharePicDialog({ showState, setShowState, randomChoose }
<div className=" w-full ">
<KeyboardPanel description={promote.word} />
<div className="text-center text-xs text-gray-500">{promote.sentence}</div>
<div className="opacity-45 mx-4 mt-6 flex rounded-large bg-white px-4 py-3 shadow-xl">
<div className="opacity-45 mx-4 mt-6 flex rounded-xl bg-white px-4 py-3 shadow-xl">
<DataBox data={state.timerData.time + ''} description="用时" />
<DataBox data={state.timerData.accuracy + '%'} description="正确率" />
<DataBox data={state.timerData.wpm + ''} description="WPM" />

View File

@@ -12,7 +12,7 @@ export default function Speed() {
const inputNumber = state.chapterData.correctCount + state.chapterData.wrongCount
return (
<div className="card opacity-45 mt-auto flex w-3/5 rounded-large bg-white p-4 py-10 transition-colors duration-300 dark:bg-gray-800">
<div className="card opacity-45 mt-auto flex w-3/5 rounded-xl bg-white p-4 py-10 transition-colors duration-300 dark:bg-gray-800">
<InfoBox info={`${minutesString}:${secondsString}`} description="时间" />
<InfoBox info={inputNumber + ''} description="输入数" />
<InfoBox info={state.timerData.wpm + ''} description="WPM" />

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import { CHAPTER_LENGTH, DISMISS_START_CARD_DATE_KEY } from '@/constants'
import { DISMISS_START_CARD_DATE_KEY } from '@/constants'
import { idDictionaryMap } from '@/resources/dictionary'
import { keySoundResources, wrongSoundResources, correctSoundResources } from '@/resources/soundResource'
import { PronunciationType, PhoneticType, Dictionary, InfoPanelState } from '@/typings'
import { calcChapterCount } from '@/utils'
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
@@ -14,7 +15,7 @@ export const currentDictInfoAtom = atom<Dictionary>((get) => {
dict = idDictionaryMap.cet4
}
const dictionary = { ...dict, chapterCount: Math.ceil(dict.length / CHAPTER_LENGTH) }
const dictionary = { ...dict, chapterCount: calcChapterCount(dict.length) }
return dictionary || null
})

View File

@@ -3,6 +3,7 @@ export * from './resource'
export type PronunciationType = 'us' | 'uk' | 'romaji' | 'zh' | 'ja' | 'de'
export type PhoneticType = 'us' | 'uk' | 'romaji' | 'zh' | 'ja' | 'de'
export type LanguageType = 'en' | 'romaji' | 'zh' | 'ja' | 'code' | 'de'
export type LanguageCategoryType = 'en' | 'ja' | 'de' | 'code'
type Pronunciation2PhoneticMap = Record<PronunciationType, PhoneticType>

View File

@@ -1,13 +1,15 @@
import { LanguageType, PronunciationType } from '.'
import { LanguageCategoryType, LanguageType, PronunciationType } from '.'
export type DictionaryResource = {
id: string
name: string
description: string
category: string
tags: string[]
url: string
length: number
language: LanguageType
languageCategory: LanguageCategoryType
//override default pronunciation when not undefined
defaultPronIndex?: number
}
@@ -17,9 +19,11 @@ export type Dictionary = {
name: string
description: string
category: string
tags: string[]
url: string
length: number
language: LanguageType
languageCategory: LanguageCategoryType
// calculated in the store
chapterCount: number
//override default pronunciation when not undefined

View File

@@ -1,3 +1,5 @@
import { DictionaryResource } from '@/typings'
export default function groupBy<T>(elements: T[], iteratee: (value: T) => string) {
return elements.reduce<Record<string, T[]>>((result, value) => {
const key = iteratee(value)
@@ -9,3 +11,16 @@ export default function groupBy<T>(elements: T[], iteratee: (value: T) => string
return result
}, {})
}
export function groupByDictTags(dicts: DictionaryResource[]) {
return dicts.reduce<Record<string, DictionaryResource[]>>((result, dict) => {
dict.tags.forEach((tag) => {
if (Object.prototype.hasOwnProperty.call(result, tag)) {
result[tag].push(dict)
} else {
result[tag] = [dict]
}
})
return result
}, {})
}

View File

@@ -1,3 +1,4 @@
import { CHAPTER_LENGTH } from '@/constants'
import { Howl } from 'howler'
export * from './mixpanel'
@@ -70,3 +71,12 @@ export function getCurrentDate() {
return `${year}${month}${day}`
}
export function calcChapterCount(length: number) {
return Math.ceil(length / CHAPTER_LENGTH)
}
export function findCommonValues<T>(xs: T[], ys: T[]): T[] {
const set = new Set(ys)
return xs.filter((x) => set.has(x))
}

View File

@@ -9,11 +9,6 @@ module.exports = {
padding: {
0.8: '0.2rem',
},
borderRadius: {
large: '0.75rem',
lg: '0.5rem',
md: '0.375rem',
},
width: {
3.5: '0.875rem',
5.5: '1.375rem',
@@ -23,6 +18,7 @@ module.exports = {
75: '18.75rem',
84: '21rem',
85: '21.25rem',
100: '25rem',
116: '29rem',
150: '37.5rem',
160: '40rem',
@@ -50,5 +46,5 @@ module.exports = {
backgroundOpacity: ['dark'],
},
},
plugins: [require('@headlessui/tailwindcss')],
plugins: [require('@headlessui/tailwindcss'), require('@tailwindcss/forms')],
}

View File

@@ -1432,6 +1432,15 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-presence@1.0.0":
version "1.0.0"
resolved "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.0.0.tgz#814fe46df11f9a468808a6010e3f3ca7e0b2e84a"
integrity sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.0"
"@radix-ui/react-use-layout-effect" "1.0.0"
"@radix-ui/react-primitive@1.0.2":
version "1.0.2"
resolved "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-1.0.2.tgz#54e22f49ca59ba88d8143090276d50b93f8a7053"
@@ -1449,6 +1458,22 @@
"@radix-ui/react-context" "1.0.0"
"@radix-ui/react-primitive" "1.0.2"
"@radix-ui/react-scroll-area@^1.0.3":
version "1.0.3"
resolved "https://registry.npmmirror.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.3.tgz#01bbc4df59a166e4a21051a40f017903a0ce7004"
integrity sha512-sBX9j8Q+0/jReNObEAveKIGXJtk3xUoSIx4cMKygGtO128QJyVDn01XNOFsyvihKDCTcu7SINzQ2jPAZEhIQtw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/number" "1.0.0"
"@radix-ui/primitive" "1.0.0"
"@radix-ui/react-compose-refs" "1.0.0"
"@radix-ui/react-context" "1.0.0"
"@radix-ui/react-direction" "1.0.0"
"@radix-ui/react-presence" "1.0.0"
"@radix-ui/react-primitive" "1.0.2"
"@radix-ui/react-use-callback-ref" "1.0.0"
"@radix-ui/react-use-layout-effect" "1.0.0"
"@radix-ui/react-slider@^1.1.1":
version "1.1.1"
resolved "https://registry.npmmirror.com/@radix-ui/react-slider/-/react-slider-1.1.1.tgz#5685f23b6244804a5b1f55cee2d321a2acd1878e"
@@ -1535,6 +1560,13 @@
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-2.16.0.tgz#fd7eb6ab7c9578abd8883b018387db141e2289aa"
integrity sha512-1kaPH5APIWGtXe0W0eQ9g4MdfaQJ2gh95TAa94lNAqRR0JeC3fkD0yXGCcUiNK4GnGDv3UtPSCd3dbdKTe1b2A==
"@tailwindcss/forms@^0.5.3":
version "0.5.3"
resolved "https://registry.npmmirror.com/@tailwindcss/forms/-/forms-0.5.3.tgz#e4d7989686cbcaf416c53f1523df5225332a86e7"
integrity sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==
dependencies:
mini-svg-data-uri "^1.2.3"
"@tailwindcss/postcss7-compat@^2.2.17":
version "2.2.17"
resolved "https://registry.npmmirror.com/@tailwindcss/postcss7-compat/-/postcss7-compat-2.2.17.tgz#dc78f3880a2af84163150ff426a39e42b9ae8922"
@@ -3911,6 +3943,11 @@ mimic-fn@^4.0.0:
resolved "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc"
integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==
mini-svg-data-uri@^1.2.3:
version "1.4.4"
resolved "https://registry.npmmirror.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939"
integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==
minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"