mirror of
https://github.com/RealKai42/qwerty-learner.git
synced 2026-04-05 14:29:04 +08:00
feat: new Gallery UI (#414)
This commit is contained in:
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -18,6 +18,7 @@
|
||||
"fortawesome",
|
||||
"headlessui",
|
||||
"heroicons",
|
||||
"IELTS",
|
||||
"immer",
|
||||
"pako",
|
||||
"romaji",
|
||||
|
||||
@@ -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
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
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
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
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
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
BIN
src/assets/flags/ja.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
47
src/pages/Gallery-N/CategoryDicts.tsx
Normal file
47
src/pages/Gallery-N/CategoryDicts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
src/pages/Gallery-N/CategoryNavigation.tsx
Normal file
31
src/pages/Gallery-N/CategoryNavigation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
src/pages/Gallery-N/ChapterList/ChapterRow.tsx
Normal file
46
src/pages/Gallery-N/ChapterList/ChapterRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
src/pages/Gallery-N/ChapterList/index.tsx
Normal file
128
src/pages/Gallery-N/ChapterList/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
55
src/pages/Gallery-N/DictRequest.tsx
Normal file
55
src/pages/Gallery-N/DictRequest.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
37
src/pages/Gallery-N/DictTagSwitcher.tsx
Normal file
37
src/pages/Gallery-N/DictTagSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
src/pages/Gallery-N/Dictionary.tsx
Normal file
31
src/pages/Gallery-N/Dictionary.tsx
Normal 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
|
||||
66
src/pages/Gallery-N/DictionaryWithoutCover.tsx
Normal file
66
src/pages/Gallery-N/DictionaryWithoutCover.tsx
Normal 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
|
||||
52
src/pages/Gallery-N/LanguageTabSwitcher.tsx
Normal file
52
src/pages/Gallery-N/LanguageTabSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/pages/Gallery-N/hooks/useChapterStats.ts
Normal file
36
src/pages/Gallery-N/hooks/useChapterStats.ts
Normal 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 }
|
||||
}
|
||||
37
src/pages/Gallery-N/hooks/useDictStats.ts
Normal file
37
src/pages/Gallery-N/hooks/useDictStats.ts
Normal 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 }
|
||||
}
|
||||
96
src/pages/Gallery-N/index.tsx
Normal file
96
src/pages/Gallery-N/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}, {})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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')],
|
||||
}
|
||||
|
||||
37
yarn.lock
37
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user