feat: add word review feature and new dict detail ui (#728)

* feat: wrong words revision (#717)

* fix: db error

* feat: add shadon ui

* feat: basic implement of review mode and new ui

* feat: update default tab of dict detail

* chore: add new line

* chore: polish detail

* feat: add dark mode

---------

Co-authored-by: William Yu <52456186+WilliamYPY@users.noreply.github.com>
This commit is contained in:
Kai
2023-12-25 23:03:39 +08:00
committed by GitHub
parent abcc9a8672
commit d5cf54e588
42 changed files with 2135 additions and 588 deletions

View File

@@ -20,6 +20,7 @@
"heroicons",
"IELTS",
"immer",
"Majesticons",
"pako",
"romaji",
"svgr",

16
components.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": false
},
"aliases": {
"components": "@/components",
"utils": "@/utils/ui"
}
}

View File

@@ -7,12 +7,21 @@
"@floating-ui/react": "^0.20.1",
"@headlessui/react": "^1.7.13",
"@headlessui/tailwindcss": "^0.1.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-progress": "^1.0.2",
"@radix-ui/react-radio-group": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.0.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-table": "^8.10.7",
"canvas-confetti": "^1.6.0",
"class-variance-authority": "^0.7.0",
"classnames": "^2.3.2",
"clsx": "^2.0.0",
"daisyui": "^3.5.1",
"dayjs": "^1.11.8",
"dexie": "^3.2.3",
@@ -24,6 +33,7 @@
"html-to-image": "^1.11.11",
"immer": "^9.0.21",
"jotai": "^2.0.3",
"lucide-react": "^0.294.0",
"mixpanel-browser": "^2.45.0",
"pako": "^2.1.0",
"react": "^18.2.0",
@@ -36,6 +46,8 @@
"react-tooltip": "^5.18.0",
"source-map-explorer": "^2.5.2",
"swr": "^2.0.4",
"tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "^4.0.3",
"use-immer": "^0.9.0",
"use-sound": "^4.0.1",

View File

@@ -21,13 +21,7 @@
"depends": []
},
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
"identifier": "com.litongjava.qwerty.learner",
"longDescription": "",
"macOS": {

View File

@@ -0,0 +1,43 @@
import { cn } from '@/utils/ui'
import { Slot } from '@radix-ui/react-slot'
import { type VariantProps, cva } from 'class-variance-authority'
import * as React from 'react'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300',
{
variants: {
variant: {
default: 'bg-indigo-500 shadow text-white hover:opacity-90 dark:text-opacity-80 focus:outline-none rounded-lg',
destructive: 'bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90',
outline:
'border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50',
secondary: 'bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80',
ghost: 'hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50',
link: 'text-slate-900 underline-offset-4 hover:underline dark:text-slate-50',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
})
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,91 @@
/* eslint-disable react/prop-types */
import { cn } from '@/utils/ui'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import * as React from 'react'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-white/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 dark:bg-slate-950/80',
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-200 bg-white p-6 shadow-lg duration-200 focus:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] dark:border-slate-800 dark:bg-slate-950 sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 data-[state=open]:text-slate-500 dark:ring-offset-slate-950 dark:focus:ring-slate-300 dark:data-[state=open]:bg-slate-800 dark:data-[state=open]:text-slate-400">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-slate-500 dark:text-slate-400', className)} {...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,38 @@
/* eslint-disable react/prop-types */
import { cn } from '@/utils/ui'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import * as React from 'react'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-slate-200 dark:bg-slate-800" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,69 @@
/* eslint-disable react/prop-types */
import { cn } from '@/utils/ui'
import * as React from 'react'
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(({ className, ...props }, ref) => (
<div className="relative h-full w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
))
Table.displayName = 'Table'
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />,
)
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />,
)
TableBody.displayName = 'TableBody'
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('border-t bg-slate-100/50 font-medium dark:bg-slate-800/50 [&>tr]:last:border-b-0', className)}
{...props}
/>
),
)
TableFooter.displayName = 'TableFooter'
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-slate-100/50 data-[state=selected]:bg-slate-100 dark:hover:bg-slate-800/50 dark:data-[state=selected]:bg-slate-800',
className,
)}
{...props}
/>
))
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-slate-500 dark:text-slate-400 [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
))
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(({ className, ...props }, ref) => (
<td ref={ref} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
))
TableCell.displayName = 'TableCell'
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption ref={ref} className={cn('mt-4 text-sm text-slate-500 dark:text-slate-400', className)} {...props} />
),
)
TableCaption.displayName = 'TableCaption'
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }

View File

@@ -0,0 +1,43 @@
/* eslint-disable react/prop-types */
import { cn } from '@/utils/ui'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import * as React from 'react'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<React.ElementRef<typeof TabsPrimitive.List>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>>(
({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-slate-100 p-1 text-slate-500 dark:bg-slate-800 dark:text-slate-400',
className,
)}
{...props}
/>
),
)
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-slate-950 data-[state=active]:shadow-sm dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300 dark:data-[state=active]:bg-slate-950 dark:data-[state=active]:text-slate-50',
className,
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => <TabsPrimitive.Content ref={ref} className={cn('mt-2 focus:outline-none', className)} {...props} />)
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,49 @@
import { toggleVariants } from '@/components/ui/toggle'
import { cn } from '@/utils/ui'
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'
import type { VariantProps } from 'class-variance-authority'
import * as React from 'react'
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
size: 'default',
variant: 'default',
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root ref={ref} className={cn('flex items-center justify-center gap-1', className)} {...props}>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
'focus:outline-none',
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,37 @@
import { cn } from '@/utils/ui'
import * as TogglePrimitive from '@radix-ui/react-toggle'
import { type VariantProps, cva } from 'class-variance-authority'
import * as React from 'react'
const toggleVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors hover:bg-slate-100 hover:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-slate-100 data-[state=on]:text-slate-900 dark:ring-offset-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-400 dark:focus-visible:ring-slate-300 dark:data-[state=on]:bg-slate-800 dark:data-[state=on]:text-slate-50',
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-slate-200 bg-transparent hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:hover:bg-slate-800 dark:hover:text-slate-50',
},
size: {
default: 'h-10 px-3',
sm: 'h-9 px-2.5',
lg: 'h-11 px-5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root ref={ref} className={cn(toggleVariants({ variant, size, className }))} {...props} />
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,28 @@
/* eslint-disable react/prop-types */
import { cn } from '@/utils/ui'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import * as React from 'react'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-2 py-[4px] text-sm text-slate-700 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-100',
className,
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,15 +1,12 @@
import DictTagSwitcher from './DictTagSwitcher'
import DictionaryComponent from './DictionaryWithoutCover'
import { GalleryContext } from './index'
import { currentDictInfoAtom } from '@/store'
import type { Dictionary } from '@/typings'
import { findCommonValues } from '@/utils'
import { useAtomValue } from 'jotai'
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
export default function DictionaryGroup({ groupedDictsByTag }: { groupedDictsByTag: Record<string, Dictionary[]> }) {
// 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)
@@ -18,15 +15,6 @@ export default function DictionaryGroup({ groupedDictsByTag }: { groupedDictsByT
setCurrentTag(tag)
}, [])
const onClickDict = useCallback(
(dict: Dictionary) => {
setState((state) => {
state.chapterListDict = dict
})
},
[setState],
)
useEffect(() => {
const commonTags = findCommonValues(tagList, currentDictInfo.tags)
if (commonTags.length > 0) {
@@ -39,7 +27,7 @@ export default function DictionaryGroup({ groupedDictsByTag }: { groupedDictsByT
<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 dic3:grid-cols-3 dic4:grid-cols-4">
{groupedDictsByTag[currentTag].map((dict) => (
<DictionaryComponent key={dict.id} dictionary={dict} onClick={() => onClickDict(dict)} />
<DictionaryComponent key={dict.id} dictionary={dict} />
))}
</div>
</div>

View File

@@ -0,0 +1,49 @@
import { useChapterStats } from '../hooks/useChapterStats'
import useIntersectionObserver from '@/hooks/useIntersectionObserver'
import { useEffect, useRef } from 'react'
import IconCheckCircle from '~icons/heroicons/check-circle-solid'
export default function Chapter({
index,
checked,
dictID,
onChange,
}: {
index: number
checked: boolean
dictID: string
onChange: (index: number) => void
}) {
const ref = useRef<HTMLTableRowElement>(null)
const entry = useIntersectionObserver(ref, {})
const isVisible = !!entry?.isIntersecting
const chapterStatus = useChapterStats(index, dictID, isVisible)
useEffect(() => {
if (checked && ref.current !== null) {
const button = ref.current
const container = button.parentElement?.parentElement?.parentElement
container?.scroll({
top: button.offsetTop - container.offsetTop - 300,
behavior: 'smooth',
})
}
}, [checked])
return (
<div
ref={ref}
className="relative flex h-16 w-40 cursor-pointer flex-col items-start justify-center overflow-hidden rounded-xl bg-slate-100 px-3 py-2 dark:bg-slate-800"
onClick={() => onChange(index)}
>
<h1> {index + 1} </h1>
<p className="pt-[2px] text-xs text-slate-600">
{chapterStatus ? (chapterStatus.exerciseCount > 0 ? `练习 ${chapterStatus.exerciseCount}` : '未练习') : '加载中...'}
</p>
{checked && (
<IconCheckCircle className="absolute -bottom-4 -right-4 h-18 w-18 text-6xl text-green-500 opacity-40 dark:text-green-300" />
)}
</div>
)
}

View File

@@ -1,57 +0,0 @@
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 dark:bg-gray-800 dark:even:bg-gray-700 dark:hover:bg-gray-600"
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: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 dark:text-gray-200">{index + 1}</td>
<td className="flex-1 px-6 py-4 text-center text-sm text-gray-700 dark:text-gray-200">
{chapterStatus ? chapterStatus.exerciseCount : 0}
</td>
<td className="flex-1 px-6 py-4 text-center text-sm text-gray-700 dark:text-gray-200">
{chapterStatus ? chapterStatus.avgWrongWordCount : 0}
</td>
<td className="flex-1 px-6 py-4 text-center text-sm text-gray-700 dark:text-gray-200">
{chapterStatus ? chapterStatus.avgWrongInputCount : 0}
</td>
</tr>
)
}

View File

@@ -1,146 +0,0 @@
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 { useAtom } from 'jotai'
import { Fragment, useCallback, useContext, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import IconX from '~icons/tabler/x'
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-[35rem] flex-col bg-white drop-shadow-2xl transition-all duration-300 ease-out dark:bg-gray-800">
{dict && (
<>
<div className="flex w-full items-end justify-between py-4 pl-5">
<span className="text-lg text-gray-700 dark:text-gray-200">{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 dark:divide-gray-800">
<thead className="sticky top-0 block h-10 w-full bg-gray-50 dark:bg-gray-700">
<tr className="flex">
<th
scope="col"
className="w-15 px-2 py-3 text-center text-sm font-bold tracking-wider text-gray-600 dark:text-gray-200"
></th>
<th
scope="col"
className="flex-1 px-2 py-3 text-center text-sm font-bold tracking-wider text-gray-600 dark:text-gray-200"
>
Chapter
</th>
<th
scope="col"
className="flex-1 px-2 py-3 text-center text-sm font-bold tracking-wider text-gray-600 dark:text-gray-200"
>
</th>
<th
scope="col"
className="flex-1 px-2 py-3 text-center text-sm font-bold tracking-wider text-gray-600 dark:text-gray-200"
>
</th>
<th
scope="col"
className="flex-1 px-2 py-3 text-center text-sm font-bold tracking-wider text-gray-600 dark:text-gray-200"
>
</th>
</tr>
</thead>
<tbody className="block h-full w-full divide-y divide-gray-100 overflow-y-scroll bg-white dark:divide-gray-800">
{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,107 @@
import Chapter from '../Chapter'
import { ErrorTable } from '../ErrorTable'
import { getRowsFromErrorWordData } from '../ErrorTable/columns'
import { ReviewDetail } from '../ReviewDetail'
import useErrorWordData from '../hooks/useErrorWords'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent } from '@/components/ui/tabs'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { currentChapterAtom, currentDictIdAtom, reviewModeInfoAtom } from '@/store'
import type { Dictionary } from '@/typings'
import range from '@/utils/range'
import { useAtom, useSetAtom } from 'jotai'
import { useCallback, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import IcOutlineCollectionsBookmark from '~icons/ic/outline-collections-bookmark'
import MajesticonsPaperFoldTextLine from '~icons/majesticons/paper-fold-text-line'
import PajamasReviewList from '~icons/pajamas/review-list'
enum Tab {
Chapters = 'chapters',
Errors = 'errors',
Review = 'review',
}
export default function DictDetail({ dictionary: dict }: { dictionary: Dictionary }) {
const [currentChapter, setCurrentChapter] = useAtom(currentChapterAtom)
const [currentDictId, setCurrentDictId] = useAtom(currentDictIdAtom)
const [curTab, setCurTab] = useState<Tab>(Tab.Chapters)
const setReviewModeInfo = useSetAtom(reviewModeInfoAtom)
const navigate = useNavigate()
const chapter = useMemo(() => (dict.id === currentDictId ? currentChapter : 0), [currentChapter, currentDictId, dict.id])
const { errorWordData, isLoading, error } = useErrorWordData(dict)
const tableData = useMemo(() => getRowsFromErrorWordData(errorWordData), [errorWordData])
const onChangeChapter = useCallback(
(index: number) => {
setCurrentDictId(dict.id)
setCurrentChapter(index)
setReviewModeInfo((old) => ({ ...old, isReviewMode: false }))
navigate('/')
},
[dict.id, navigate, setCurrentChapter, setCurrentDictId, setReviewModeInfo],
)
return (
<div className="flex flex-col rounded-[4rem] px-4 py-3 pl-5 text-gray-800 dark:text-gray-300">
<div className="text relative flex h-40 flex-col gap-2">
<h3 className="text-2xl font-semibold">{dict.name}</h3>
<p className="mt-1">{dict.chapterCount} </p>
<p> {dict.length} </p>
<p>{dict.description}</p>
<div className="absolute bottom-5 right-4">
<ToggleGroup
type="single"
value={curTab}
onValueChange={(value: Tab) => {
setCurTab(value)
}}
>
<ToggleGroupItem value="chapters">
<MajesticonsPaperFoldTextLine className="mr-1.5 text-gray-500" />
</ToggleGroupItem>
{errorWordData.length > 0 && (
<>
<ToggleGroupItem value="errors">
<IcOutlineCollectionsBookmark className="mr-1.5 text-gray-500" />
</ToggleGroupItem>
<ToggleGroupItem value="review">
<PajamasReviewList className="mr-1.5 text-gray-500" />
</ToggleGroupItem>
</>
)}
</ToggleGroup>
</div>
</div>
<div className="flex pl-0">
<Tabs value={curTab} className="h-[30rem] w-full ">
<TabsContent value={Tab.Chapters} className="h-full ">
<ScrollArea className="h-[30rem] ">
<div className="flex w-full flex-wrap gap-3">
{range(0, dict.chapterCount, 1).map((index) => (
<Chapter
key={`${dict.id}-${index}`}
index={index}
checked={chapter === index}
dictID={dict.id}
onChange={onChangeChapter}
/>
))}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value={Tab.Errors} className="h-full">
<ErrorTable data={tableData} isLoading={isLoading} error={error} />
</TabsContent>
<TabsContent value={Tab.Review} className="h-full">
<ReviewDetail errorData={errorWordData} dict={dict} />
</TabsContent>
</Tabs>
</div>
</div>
)
}

View File

@@ -1,6 +1,8 @@
import DictDetail from './DictDetail'
import { useDictStats } from './hooks/useDictStats'
import bookCover from '@/assets/book-cover.png'
import Tooltip from '@/components/Tooltip'
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import useIntersectionObserver from '@/hooks/useIntersectionObserver'
import { currentDictIdAtom } from '@/store'
import type { Dictionary } from '@/typings'
@@ -11,10 +13,9 @@ import { useMemo, useRef } from 'react'
interface Props {
dictionary: Dictionary
onClick?: () => void
}
export default function DictionaryComponent({ dictionary, onClick }: Props) {
export default function DictionaryComponent({ dictionary }: Props) {
const currentDictID = useAtomValue(currentDictIdAtom)
const divRef = useRef<HTMLDivElement>(null)
@@ -29,13 +30,15 @@ export default function DictionaryComponent({ dictionary, onClick }: Props) {
)
return (
<Dialog>
<DialogTrigger asChild>
<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-800 dark:hover:bg-gray-700'
}`}
role="button"
onClick={onClick}
// onClick={onClick}
>
<div className="relative ml-1 mt-2 flex h-full w-full flex-col items-start justify-start">
<h1
@@ -45,11 +48,22 @@ export default function DictionaryComponent({ dictionary, onClick }: Props) {
>
{dictionary.name}
</h1>
<Tooltip className="w-full" content={dictionary.description}>
<p className={`mb-1 w-full truncate ${isSelected ? 'text-white' : 'text-gray-600 dark:text-gray-200'}`}>
<TooltipProvider>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<p
className={`mb-1 max-w-full truncate ${
isSelected ? 'text-white' : 'textdelayDuration-gray-600 dark:text-gray-200'
} whitespace-nowrap`}
>
{dictionary.description}
</p>
</TooltipTrigger>
<TooltipContent>
<p>{`${dictionary.description}`}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<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">
@@ -69,5 +83,10 @@ export default function DictionaryComponent({ dictionary, onClick }: Props) {
</div>
</div>
</div>
</DialogTrigger>
<DialogContent className="w-[60rem] max-w-none !rounded-[20px]">
<DictDetail dictionary={dictionary} />
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,68 @@
import type { TErrorWordData } from '../hooks/useErrorWords'
import { Button } from '@/components/ui/button'
import type { ColumnDef } from '@tanstack/react-table'
import PhArrowsDownUpFill from '~icons/ph/arrows-down-up-fill'
export type ErrorColumn = {
word: string
trans: string
errorCount: number
errorChar: string[]
}
export const errorColumns: ColumnDef<ErrorColumn>[] = [
{
accessorKey: 'word',
size: 100,
header: ({ column }) => {
return (
<Button variant="ghost" className="p-0" onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}>
<PhArrowsDownUpFill className="ml-1.5 h-4 w-4" />
</Button>
)
},
},
{
accessorKey: 'trans',
size: 500,
header: '释义',
},
{
accessorKey: 'errorCount',
size: 40,
header: ({ column }) => {
return (
<Button variant="ghost" className="p-0" onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}>
<PhArrowsDownUpFill className="ml-1.5 h-4 w-4" />
</Button>
)
},
},
{
accessorKey: 'errorChar',
header: '易错字母',
size: 80,
cell: ({ row }) => {
return (
<p>
{(row.getValue('errorChar') as string[]).map((char, index) => (
<kbd key={`${char}-${index}`}>{char + ' '}</kbd>
))}
</p>
)
},
},
]
export function getRowsFromErrorWordData(data: TErrorWordData[]): ErrorColumn[] {
return data.map((item) => {
return {
word: item.word,
trans: item.originData.trans.join('') ?? '',
errorCount: item.errorCount,
errorChar: item.errorChar,
}
})
}

View File

@@ -0,0 +1,81 @@
import type { ErrorColumn } from './columns'
import { errorColumns } from './columns'
import { LoadingUI } from '@/components/Loading'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import type { SortingState } from '@tanstack/react-table'
import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'
import { useState } from 'react'
interface DataTableProps {
data: ErrorColumn[]
isLoading: boolean
error: unknown
}
export function ErrorTable({ data, isLoading, error }: DataTableProps) {
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns: errorColumns,
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
state: {
sorting,
},
})
return (
<div className="h-full w-full rounded-md border p-1">
<Table className="h-full w-full" {...{}}>
<TableHeader className="sticky top-0 bg-white dark:bg-slate-900">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
{...{
colSpan: header.colSpan,
style: {
width: header.getSize(),
},
}}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="w-full">
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
{...{
style: {
width: cell.column.getSize(),
},
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={errorColumns.length} className="h-[28rem] text-center">
{isLoading ? <LoadingUI /> : error ? '好像遇到错误啦!尝试刷新下' : '暂无数据, 快去练习吧!'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,85 @@
import type { TErrorWordData } from '../hooks/useErrorWords'
import { Button } from '@/components/ui/button'
import { currentChapterAtom, currentDictIdAtom, reviewModeInfoAtom } from '@/store'
import type { Dictionary } from '@/typings'
import { timeStamp2String } from '@/utils'
import { generateNewWordReviewRecord, useGetLatestReviewRecord } from '@/utils/db/review-record'
import * as Progress from '@radix-ui/react-progress'
import { useSetAtom } from 'jotai'
import { useNavigate } from 'react-router-dom'
import MdiRobotAngry from '~icons/mdi/robot-angry'
export function ReviewDetail({ errorData, dict }: { errorData: TErrorWordData[]; dict: Dictionary }) {
const latestReviewRecord = useGetLatestReviewRecord(dict.id)
const setReviewModeInfo = useSetAtom(reviewModeInfoAtom)
const setCurrentDictId = useSetAtom(currentDictIdAtom)
const navigate = useNavigate()
const setCurrentChapter = useSetAtom(currentChapterAtom)
const startReview = async () => {
setCurrentDictId(dict.id)
setCurrentChapter(-1)
const record = await generateNewWordReviewRecord(dict.id, errorData)
setReviewModeInfo({ isReviewMode: true, reviewRecord: record })
navigate('/')
}
const continueReview = () => {
setCurrentDictId(dict.id)
setCurrentChapter(-1)
setReviewModeInfo({ isReviewMode: true, reviewRecord: latestReviewRecord })
navigate('/')
}
return (
<div className="flex h-full flex-col items-center justify-around px-60">
<div>
<MdiRobotAngry fontSize={30} className="text-indigo-300 " />
<blockquote>
<p className="text-lg font-medium text-gray-600 dark:text-gray-300">
使
<br />
</p>
</blockquote>
</div>
<div className="flex w-full flex-col items-center">
{latestReviewRecord && (
<>
<div className=" ml-10 flex w-full items-center py-0">
<Progress.Root
value={latestReviewRecord.index + 1}
max={latestReviewRecord.words.length}
className="mr-4 h-2 w-full rounded-full border border-indigo-400 bg-white"
>
<Progress.Indicator
className="h-full rounded-full bg-indigo-400 pl-0"
style={{ width: `calc(${((latestReviewRecord.index + 1) / latestReviewRecord.words.length) * 100}% )` }}
/>
</Progress.Root>
<span className="p-0 text-xs">
{latestReviewRecord.index + 1}/{latestReviewRecord.words.length}
</span>
</div>
<div className="mt-1 text-sm font-normal text-gray-500">{`( 创建于 ${timeStamp2String(latestReviewRecord.createTime)} )`}</div>
</>
)}
{!latestReviewRecord && <div>: {errorData.length}</div>}
<div className="mt-6 flex gap-10">
{latestReviewRecord && (
<Button size="sm" onClick={continueReview}>
</Button>
)}
<Button size="sm" onClick={startReview}>
{latestReviewRecord && '新的'}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -30,7 +30,6 @@ async function getDictStats(dict: string): Promise<IDictStats> {
const uniqueChapter = allChapter.filter((value, index, self) => {
return self.indexOf(value) === index
})
const exercisedChapterCount = uniqueChapter.length
return { exercisedChapterCount }

View File

@@ -0,0 +1,88 @@
import type { Dictionary, Word } from '@/typings'
import { db } from '@/utils/db'
import type { WordRecord } from '@/utils/db/record'
import { wordListFetcher } from '@/utils/wordListFetcher'
import { useEffect, useState } from 'react'
import useSWR from 'swr'
type groupRecord = {
word: string
records: WordRecord[]
}
export type TErrorWordData = {
word: string
originData: Word
errorCount: number
errorLetters: Record<string, number>
errorChar: string[]
latestErrorTime: number
}
export default function useErrorWordData(dict: Dictionary) {
const { data: wordList, error, isLoading } = useSWR(dict?.url, wordListFetcher)
const [errorWordData, setErrorData] = useState<TErrorWordData[]>([])
useEffect(() => {
if (!wordList) return
db.wordRecords
.where('wrongCount')
.above(0)
.filter((record) => record.dict === dict.id)
.toArray()
.then((records) => {
const groupRecords: groupRecord[] = []
records.forEach((record) => {
let groupRecord = groupRecords.find((g) => g.word === record.word)
if (!groupRecord) {
groupRecord = { word: record.word, records: [] }
groupRecords.push(groupRecord)
}
groupRecord.records.push(record as WordRecord)
})
const res: TErrorWordData[] = []
groupRecords.forEach((groupRecord) => {
const errorLetters = {} as Record<string, number>
groupRecord.records.forEach((record) => {
for (const index in record.mistakes) {
const mistakes = record.mistakes[index]
if (mistakes.length > 0) {
errorLetters[index] = (errorLetters[index] ?? 0) + mistakes.length
}
}
})
const word = wordList.find((word) => word.name === groupRecord.word)
if (!word) return
const errorData: TErrorWordData = {
word: groupRecord.word,
originData: word,
errorCount: groupRecord.records.reduce((acc, cur) => {
acc += cur.wrongCount
return acc
}, 0),
errorLetters,
errorChar: Object.entries(errorLetters)
.sort((a, b) => b[1] - a[1])
.map(([index]) => groupRecord.word[Number(index)]),
latestErrorTime: groupRecord.records.reduce((acc, cur) => {
acc = Math.max(acc, cur.timeStamp)
return acc
}, 0),
}
res.push(errorData)
})
setErrorData(res)
})
}, [dict.id, wordList])
return { errorWordData, isLoading, error }
}

View File

@@ -0,0 +1,34 @@
import { db } from '@/utils/db'
import { useEffect, useState } from 'react'
export function useRevisionWordCount(dictID: string) {
const [wordCount, setWordCount] = useState<number>(0)
useEffect(() => {
const fetchWordCount = async () => {
const count = await getRevisionWordCount(dictID)
setWordCount(count)
}
if (dictID) {
fetchWordCount()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dictID])
return wordCount
}
async function getRevisionWordCount(dict: string): Promise<number> {
const wordCount = await db.wordRecords
.where('dict')
.equals(dict)
.and((wordRecord) => wordRecord.wrongCount > 0)
.toArray()
.then((wordRecords) => {
const res = new Map()
const reducedRecords = wordRecords.filter((item) => !res.has(item['word'] + item['dict']) && res.set(item['word'] + item['dict'], 1))
return reducedRecords.length
})
return wordCount
}

View File

@@ -1,5 +1,4 @@
import DictionaryGroup from './CategoryDicts'
import ChapterList from './ChapterList'
import DictRequest from './DictRequest'
import { LanguageTabSwitcher } from './LanguageTabSwitcher'
import Layout from '@/components/Layout'
@@ -19,12 +18,10 @@ import IconX from '~icons/tabler/x'
export type GalleryState = {
currentLanguageTab: LanguageCategoryType
chapterListDict: Dictionary | null
}
const initialGalleryState: GalleryState = {
currentLanguageTab: 'en',
chapterListDict: null,
}
export const GalleryContext = createContext<{
@@ -66,7 +63,6 @@ export default function GalleryPage() {
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">

View File

@@ -1,5 +1,5 @@
import Tooltip from '@/components/Tooltip'
import { currentChapterAtom, currentDictInfoAtom } from '@/store'
import { currentChapterAtom, currentDictInfoAtom, isReviewModeAtom } from '@/store'
import range from '@/utils/range'
import { Listbox, Transition } from '@headlessui/react'
import { useAtom, useAtomValue } from 'jotai'
@@ -11,6 +11,7 @@ export const DictChapterButton = () => {
const currentDictInfo = useAtomValue(currentDictInfoAtom)
const [currentChapter, setCurrentChapter] = useAtom(currentChapterAtom)
const chapterCount = currentDictInfo.chapterCount
const isReviewMode = useAtomValue(isReviewModeAtom)
return (
<>
@@ -19,9 +20,10 @@ export const DictChapterButton = () => {
className="block rounded-lg px-3 py-1 text-lg transition-colors duration-300 ease-in-out hover:bg-indigo-400 hover:text-white focus:outline-none dark:text-white dark:text-opacity-60 dark:hover:text-opacity-100"
to="/gallery"
>
{currentDictInfo.name}
{currentDictInfo.name} {isReviewMode && '错题复习'}
</NavLink>
</Tooltip>
{!isReviewMode && (
<Tooltip content="章节切换">
<Listbox value={currentChapter} onChange={setCurrentChapter}>
<Listbox.Button className="rounded-lg px-3 py-1 text-lg transition-colors duration-300 ease-in-out hover:bg-indigo-400 hover:text-white focus:outline-none dark:text-white dark:text-opacity-60 dark:hover:text-opacity-100">
@@ -47,6 +49,7 @@ export const DictChapterButton = () => {
</Transition>
</Listbox>
</Tooltip>
)}
</>
)
}

View File

@@ -5,13 +5,22 @@ import RemarkRing from './RemarkRing'
import WordChip from './WordChip'
import styles from './index.module.css'
import Tooltip from '@/components/Tooltip'
import { currentChapterAtom, currentDictInfoAtom, infoPanelStateAtom, randomConfigAtom, wordDictationConfigAtom } from '@/store'
import {
currentChapterAtom,
currentDictInfoAtom,
infoPanelStateAtom,
isReviewModeAtom,
randomConfigAtom,
reviewModeInfoAtom,
wordDictationConfigAtom,
} from '@/store'
import type { InfoPanelType } from '@/typings'
import { recordOpenInfoPanelAction } from '@/utils'
import { Transition } from '@headlessui/react'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useCallback, useContext, useEffect, useMemo } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useNavigate } from 'react-router-dom'
import IexportWords from '~icons/icon-park-outline/excel'
import IconCoffee from '~icons/mdi/coffee'
import IconXiaoHongShu from '~icons/my-icons/xiaohongshu'
@@ -28,6 +37,10 @@ const ResultScreen = () => {
const [currentChapter, setCurrentChapter] = useAtom(currentChapterAtom)
const setInfoPanelState = useSetAtom(infoPanelStateAtom)
const randomConfig = useAtomValue(randomConfigAtom)
const navigate = useNavigate()
const setReviewModeInfo = useSetAtom(reviewModeInfoAtom)
const isReviewMode = useAtomValue(isReviewModeAtom)
useEffect(() => {
// tick a zero timer to calc the stats
@@ -98,7 +111,11 @@ const ResultScreen = () => {
return `${minuteString}:${secondString}`
}, [state.timerData.time])
const repeatButtonHandler = useCallback(() => {
const repeatButtonHandler = useCallback(async () => {
if (isReviewMode) {
return
}
setWordDictationConfig((old) => {
if (old.isOpen) {
if (old.openBy === 'auto') {
@@ -108,15 +125,22 @@ const ResultScreen = () => {
return old
})
dispatch({ type: TypingStateActionType.REPEAT_CHAPTER, shouldShuffle: randomConfig.isOpen })
}, [dispatch, randomConfig.isOpen, setWordDictationConfig])
}, [isReviewMode, setWordDictationConfig, dispatch, randomConfig.isOpen])
const dictationButtonHandler = useCallback(async () => {
if (isReviewMode) {
return
}
const dictationButtonHandler = useCallback(() => {
setWordDictationConfig((old) => ({ ...old, isOpen: true, openBy: 'auto' }))
dispatch({ type: TypingStateActionType.REPEAT_CHAPTER, shouldShuffle: randomConfig.isOpen })
}, [dispatch, randomConfig.isOpen, setWordDictationConfig])
}, [isReviewMode, setWordDictationConfig, dispatch, randomConfig.isOpen])
const nextButtonHandler = useCallback(() => {
if (isReviewMode) {
return
}
setWordDictationConfig((old) => {
if (old.isOpen) {
if (old.openBy === 'auto') {
@@ -129,11 +153,23 @@ const ResultScreen = () => {
setCurrentChapter((old) => old + 1)
dispatch({ type: TypingStateActionType.NEXT_CHAPTER })
}
}, [dispatch, isLastChapter, setCurrentChapter, setWordDictationConfig])
}, [dispatch, isLastChapter, isReviewMode, setCurrentChapter, setWordDictationConfig])
const exitButtonHandler = useCallback(() => {
if (isReviewMode) {
setCurrentChapter(0)
setReviewModeInfo((old) => ({ ...old, isReviewMode: false }))
} else {
dispatch({ type: TypingStateActionType.REPEAT_CHAPTER, shouldShuffle: false })
}, [dispatch])
}
}, [dispatch, isReviewMode, setCurrentChapter, setReviewModeInfo])
const onNavigateToGallery = useCallback(() => {
setCurrentChapter(0)
setReviewModeInfo((old) => ({ ...old, isReviewMode: false }))
navigate('/gallery')
}, [navigate, setCurrentChapter, setReviewModeInfo])
useHotkeys(
'enter',
() => {
@@ -183,7 +219,7 @@ const ResultScreen = () => {
<div className="flex h-screen items-center justify-center">
<div className="my-card fixed flex w-[90vw] max-w-6xl flex-col overflow-hidden rounded-3xl bg-white pb-14 pl-10 pr-5 pt-10 shadow-lg dark:bg-gray-800 md:w-4/5 lg:w-3/5">
<div className="text-center font-sans text-xl font-normal text-gray-900 dark:text-gray-400 md:text-2xl">
{`${currentDictInfo.name} ${currentChapter + 1}`}
{`${currentDictInfo.name} ${isReviewMode ? '错题复习' : '第' + (currentChapter + 1) + '章'}`}
</div>
<button className="absolute right-7 top-5" onClick={exitButtonHandler}>
<IconX className="text-gray-400" />
@@ -205,8 +241,12 @@ const ResultScreen = () => {
</div>
</div>
<div className="ml-2 flex flex-col items-center justify-end gap-3.5 text-xl">
{!isReviewMode && (
<>
<ShareButton />
<IexportWords fontSize={18} className="cursor-pointer text-gray-500" onClick={exportWords}></IexportWords>
</>
)}
<IconXiaoHongShu
fontSize={15}
className="cursor-pointer text-gray-500 hover:text-red-500 focus:outline-none"
@@ -246,6 +286,8 @@ const ResultScreen = () => {
</div>
</div>
<div className="mt-10 flex w-full justify-center gap-5 px-5 text-xl">
{!isReviewMode && (
<>
<Tooltip content="快捷键shift + enter">
<button
className="my-btn-primary h-12 border-2 border-solid border-gray-300 bg-white text-base text-gray-700 dark:border-gray-700 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-700"
@@ -266,7 +308,9 @@ const ResultScreen = () => {
</button>
</Tooltip>
{!isLastChapter && (
</>
)}
{!isLastChapter && !isReviewMode && (
<Tooltip content="快捷键enter">
<button
className={`{ isLastChapter ? 'cursor-not-allowed opacity-50' : ''} my-btn-primary h-12 text-base font-bold `}
@@ -278,6 +322,17 @@ const ResultScreen = () => {
</button>
</Tooltip>
)}
{isReviewMode && (
<button
className="my-btn-primary h-12 text-base font-bold"
type="button"
onClick={onNavigateToGallery}
title="练习其他章节"
>
</button>
)}
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import { TypingContext, TypingStateActionType } from '../../store'
import WordCard from './WordCard'
import Drawer from '@/components/Drawer'
import Tooltip from '@/components/Tooltip'
import { currentChapterAtom, currentDictInfoAtom } from '@/store'
import { currentChapterAtom, currentDictInfoAtom, isReviewModeAtom } from '@/store'
import { Dialog } from '@headlessui/react'
import * as ScrollArea from '@radix-ui/react-scroll-area'
import { atom, useAtomValue } from 'jotai'
@@ -11,7 +11,13 @@ import ListIcon from '~icons/tabler/list'
import IconX from '~icons/tabler/x'
const currentDictTitle = atom((get) => {
const isReviewMode = get(isReviewModeAtom)
if (isReviewMode) {
return `${get(currentDictInfoAtom).name} 错题复习`
} else {
return `${get(currentDictInfoAtom).name}${get(currentChapterAtom) + 1}`
}
})
export default function WordList() {

View File

@@ -167,6 +167,12 @@ export default function WordComponent({ word, onFinish }: { word: Word; onFinish
useEffect(() => {
const inputLength = wordState.inputWord.length
/**
* TODO: 当用户输入错误时,会报错
* Cannot update a component (`App`) while rendering a different component (`WordComponent`). To locate the bad setState() call inside `WordComponent`, follow the stack trace as described in https://reactjs.org/link/setstate-in-render
* 目前不影响生产环境,猜测是因为开发环境下 react 会两次调用 useEffect 从而展示了这个 warning
* 但这终究是一个 bug需要修复
*/
if (wordState.hasWrong || inputLength === 0 || wordState.displayWord.length === 0) {
return
}

View File

@@ -1,13 +1,14 @@
import { TypingContext, TypingStateActionType } from '../../store'
import type { TypingState } from '../../store/type'
import PrevAndNextWord from '../PrevAndNextWord'
import Progress from '../Progress'
import Phonetic from './components/Phonetic'
import Translation from './components/Translation'
import WordComponent from './components/Word'
import { usePrefetchPronunciationSound } from '@/hooks/usePronunciation'
import { isShowPrevAndNextWordAtom, loopWordConfigAtom, phoneticConfigAtom } from '@/store'
import { isReviewModeAtom, isShowPrevAndNextWordAtom, loopWordConfigAtom, phoneticConfigAtom, reviewModeInfoAtom } from '@/store'
import type { Word } from '@/typings'
import { useAtomValue } from 'jotai'
import { useAtomValue, useSetAtom } from 'jotai'
import { useCallback, useContext, useMemo, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
@@ -22,6 +23,9 @@ export default function WordPanel() {
const currentWord = state.chapterData.words[state.chapterData.index]
const nextWord = state.chapterData.words[state.chapterData.index + 1] as Word | undefined
const setReviewModeInfo = useSetAtom(reviewModeInfoAtom)
const isReviewMode = useAtomValue(isReviewModeAtom)
const prevIndex = useMemo(() => {
const newIndex = state.chapterData.index - 1
return newIndex < 0 ? 0 : newIndex
@@ -37,6 +41,16 @@ export default function WordPanel() {
setWordComponentKey((old) => old + 1)
}, [])
const updateReviewRecord = useCallback(
(state: TypingState) => {
setReviewModeInfo((old) => ({
...old,
reviewRecord: old.reviewRecord ? { ...old.reviewRecord, index: state.chapterData.index } : undefined,
}))
},
[setReviewModeInfo],
)
const onFinish = useCallback(() => {
if (state.chapterData.index < state.chapterData.words.length - 1 || currentWordExerciseCount < loopWordTimes - 1) {
// 用户完成当前单词
@@ -46,11 +60,23 @@ export default function WordPanel() {
reloadCurrentWordComponent()
} else {
setCurrentWordExerciseCount(0)
if (isReviewMode) {
dispatch({
type: TypingStateActionType.NEXT_WORD,
payload: {
updateReviewRecord,
},
})
} else {
dispatch({ type: TypingStateActionType.NEXT_WORD })
}
}
} else {
// 用户完成当前章节
dispatch({ type: TypingStateActionType.FINISH_CHAPTER })
if (isReviewMode) {
setReviewModeInfo((old) => ({ ...old, reviewRecord: old.reviewRecord ? { ...old.reviewRecord, isFinished: true } : undefined }))
}
}
}, [
state.chapterData.index,
@@ -59,6 +85,9 @@ export default function WordPanel() {
loopWordTimes,
dispatch,
reloadCurrentWordComponent,
isReviewMode,
updateReviewRecord,
setReviewModeInfo,
])
const onSkipWord = useCallback(

View File

@@ -1,13 +1,13 @@
import { CHAPTER_LENGTH } from '@/constants'
import { currentChapterAtom, currentDictInfoAtom } from '@/store'
import type { WordWithIndex } from '@/typings/index'
import { currentChapterAtom, currentDictInfoAtom, reviewModeInfoAtom } from '@/store'
import type { Word, WordWithIndex } from '@/typings/index'
import { wordListFetcher } from '@/utils/wordListFetcher'
import { useAtom, useAtomValue } from 'jotai'
import { useMemo } from 'react'
import useSWR from 'swr'
export type UseWordListResult = {
words: WordWithIndex[] | undefined
words: WordWithIndex[]
isLoading: boolean
error: Error | undefined
}
@@ -18,23 +18,29 @@ export type UseWordListResult = {
export function useWordList(): UseWordListResult {
const currentDictInfo = useAtomValue(currentDictInfoAtom)
const [currentChapter, setCurrentChapter] = useAtom(currentChapterAtom)
const isFirstChapter = currentDictInfo.id === 'cet4' && currentChapter === 0
const { isReviewMode, reviewRecord } = useAtomValue(reviewModeInfoAtom)
// Reset current chapter to 0, when currentChapter is greater than chapterCount.
if (currentChapter >= currentDictInfo.chapterCount) {
setCurrentChapter(0)
}
const isFirstChapter = !isReviewMode && currentDictInfo.id === 'cet4' && currentChapter === 0
const { data: wordList, error, isLoading } = useSWR(currentDictInfo.url, wordListFetcher)
const words: WordWithIndex[] = useMemo(() => {
const newWords = isFirstChapter
? firstChapter
: wordList
? wordList.slice(currentChapter * CHAPTER_LENGTH, (currentChapter + 1) * CHAPTER_LENGTH)
: []
let newWords: Word[]
if (isFirstChapter) {
newWords = firstChapter
} else if (isReviewMode) {
newWords = reviewRecord?.words ?? []
} else if (wordList) {
newWords = wordList.slice(currentChapter * CHAPTER_LENGTH, (currentChapter + 1) * CHAPTER_LENGTH)
} else {
newWords = []
}
// 记录原始 index, 并对 word.trans 做兜底处理
return newWords.map((word, index) => {
let trans: string[]
if (Array.isArray(word.trans)) {
@@ -50,9 +56,9 @@ export function useWordList(): UseWordListResult {
trans,
}
})
}, [isFirstChapter, wordList, currentChapter])
}, [isFirstChapter, isReviewMode, wordList, reviewRecord?.words, currentChapter])
return { words: wordList === undefined ? undefined : words, isLoading, error }
return { words, isLoading, error }
}
const firstChapter = [

View File

@@ -15,11 +15,11 @@ import Header from '@/components/Header'
import StarCard from '@/components/StarCard'
import Tooltip from '@/components/Tooltip'
import { idDictionaryMap } from '@/resources/dictionary'
import { currentDictIdAtom, randomConfigAtom } from '@/store'
import { currentChapterAtom, currentDictIdAtom, isReviewModeAtom, randomConfigAtom, reviewModeInfoAtom } from '@/store'
import { IsDesktop, isLegal } from '@/utils'
import { useSaveChapterRecord } from '@/utils/db'
import { useMixPanelChapterLogUploader } from '@/utils/mixpanel'
import { useAtom, useAtomValue } from 'jotai'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import type React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useImmerReducer } from 'use-immer'
@@ -30,11 +30,14 @@ const App: React.FC = () => {
const { words } = useWordList()
const [currentDictId, setCurrentDictId] = useAtom(currentDictIdAtom)
const setCurrentChapter = useSetAtom(currentChapterAtom)
const randomConfig = useAtomValue(randomConfigAtom)
const chapterLogUploader = useMixPanelChapterLogUploader(state)
const saveChapterRecord = useSaveChapterRecord()
const reviewModeInfo = useAtomValue(reviewModeInfoAtom)
const isReviewMode = useAtomValue(isReviewModeAtom)
useEffect(() => {
// 检测用户设备
if (!IsDesktop()) {
@@ -51,8 +54,10 @@ const App: React.FC = () => {
const id = currentDictId
if (!(id in idDictionaryMap)) {
setCurrentDictId('cet4')
setCurrentChapter(0)
return
}
}, [currentDictId, setCurrentDictId])
}, [currentDictId, setCurrentChapter, setCurrentDictId])
const skipWord = useCallback(() => {
dispatch({ type: TypingStateActionType.SKIP_WORD })
@@ -89,9 +94,11 @@ const App: React.FC = () => {
useEffect(() => {
if (words !== undefined) {
const initialIndex = isReviewMode && reviewModeInfo.reviewRecord?.index ? reviewModeInfo.reviewRecord.index : 0
dispatch({
type: TypingStateActionType.SETUP_CHAPTER,
payload: { words, shouldShuffle: randomConfig.isOpen },
payload: { words, shouldShuffle: randomConfig.isOpen, initialIndex },
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -1,6 +1,7 @@
import type { TypingState, UserInputLog } from './type'
import type { WordWithIndex } from '@/typings'
import type { LetterMistakes } from '@/utils/db/record'
import '@/utils/db/review-record'
import { mergeLetterMistake } from '@/utils/db/utils'
import shuffle from '@/utils/shuffle'
import { createContext } from 'react'
@@ -57,16 +58,22 @@ export enum TypingStateActionType {
SET_IS_SAVING_RECORD = 'SET_IS_SAVING_RECORD',
SET_IS_LOOP_SINGLE_WORD = 'SET_IS_LOOP_SINGLE_WORD',
TOGGLE_IS_LOOP_SINGLE_WORD = 'TOGGLE_IS_LOOP_SINGLE_WORD',
SET_REVISION_INDEX = 'SET_REVISION_INDEX',
}
export type TypingStateAction =
| { type: TypingStateActionType.SETUP_CHAPTER; payload: { words: WordWithIndex[]; shouldShuffle: boolean } }
| { type: TypingStateActionType.SETUP_CHAPTER; payload: { words: WordWithIndex[]; shouldShuffle: boolean; initialIndex?: number } }
| { type: TypingStateActionType.SET_IS_SKIP; payload: boolean }
| { type: TypingStateActionType.SET_IS_TYPING; payload: boolean }
| { type: TypingStateActionType.TOGGLE_IS_TYPING }
| { type: TypingStateActionType.REPORT_WRONG_WORD; payload: { letterMistake: LetterMistakes } }
| { type: TypingStateActionType.REPORT_CORRECT_WORD }
| { type: TypingStateActionType.NEXT_WORD }
| {
type: TypingStateActionType.NEXT_WORD
payload?: {
updateReviewRecord?: (state: TypingState) => void
}
}
| { type: TypingStateActionType.LOOP_CURRENT_WORD }
| { type: TypingStateActionType.FINISH_CHAPTER }
| { type: TypingStateActionType.SKIP_WORD }
@@ -87,6 +94,11 @@ export const typingReducer = (state: TypingState, action: TypingStateAction) =>
case TypingStateActionType.SETUP_CHAPTER: {
const newState = structuredClone(initialState)
const words = action.payload.shouldShuffle ? shuffle(action.payload.words) : action.payload.words
let initialIndex = action.payload.initialIndex ?? 0
if (initialIndex >= words.length) {
initialIndex = 0
}
newState.chapterData.index = initialIndex
newState.chapterData.words = words
newState.chapterData.userInputLogs = words.map((_, index) => ({ ...structuredClone(initialUserInputLog), index }))
@@ -118,11 +130,16 @@ export const typingReducer = (state: TypingState, action: TypingStateAction) =>
wordLog.LetterMistakes = mergeLetterMistake(wordLog.LetterMistakes, letterMistake)
break
}
case TypingStateActionType.NEXT_WORD:
case TypingStateActionType.NEXT_WORD: {
state.chapterData.index += 1
state.chapterData.wordCount += 1
state.isShowSkip = false
if (action?.payload?.updateReviewRecord) {
action.payload.updateReviewRecord(state)
}
break
}
case TypingStateActionType.LOOP_CURRENT_WORD:
state.isShowSkip = false
state.chapterData.wordCount += 1

View File

@@ -1,4 +1,5 @@
import atomForConfig from './atomForConfig'
import { reviewInfoAtom } from './reviewInfoAtom'
import { DISMISS_START_CARD_DATE_KEY, defaultFontSizeConfig } from '@/constants'
import { idDictionaryMap } from '@/resources/dictionary'
import { correctSoundResources, keySoundResources, wrongSoundResources } from '@/resources/soundResource'
@@ -11,6 +12,7 @@ import type {
WordDictationOpenBy,
WordDictationType,
} from '@/typings'
import type { ReviewRecord } from '@/utils/db/record'
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
@@ -76,6 +78,12 @@ export const isShowAnswerOnHoverAtom = atomWithStorage('isShowAnswerOnHover', tr
export const isTextSelectableAtom = atomWithStorage('isTextSelectable', false)
export const reviewModeInfoAtom = reviewInfoAtom({
isReviewMode: false,
reviewRecord: undefined as ReviewRecord | undefined,
})
export const isReviewModeAtom = atom((get) => get(reviewModeInfoAtom).isReviewMode)
export const phoneticConfigAtom = atomForConfig('phoneticConfig', {
isOpen: true,
type: 'us' as PhoneticType,

View File

@@ -0,0 +1,28 @@
import type { ReviewRecord } from '@/utils/db/record'
import { putWordReviewRecord } from '@/utils/db/review-record'
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
type TReviewInfoAtomData = {
isReviewMode: boolean
reviewRecord: ReviewRecord | undefined
}
export function reviewInfoAtom(initialValue: TReviewInfoAtomData) {
const storageAtom = atomWithStorage('reviewModeInfo', initialValue)
return atom(
(get) => {
return get(storageAtom)
},
(get, set, updater: TReviewInfoAtomData | ((oldValue: TReviewInfoAtomData) => TReviewInfoAtomData)) => {
const newValue = typeof updater === 'function' ? updater(get(storageAtom)) : updater
// update reviewRecord to indexdb
if (newValue.reviewRecord?.id) {
putWordReviewRecord(newValue.reviewRecord)
}
set(storageAtom, newValue)
},
)
}

View File

@@ -1,8 +1,8 @@
import type { IChapterRecord, IWordRecord, LetterMistakes } from './record'
import { ChapterRecord, WordRecord } from './record'
import type { IChapterRecord, IReviewRecord, IRevisionDictRecord, IWordRecord, LetterMistakes } from './record'
import { ChapterRecord, ReviewRecord, WordRecord } from './record'
import { TypingContext, TypingStateActionType } from '@/pages/Typing/store'
import type { TypingState } from '@/pages/Typing/store/type'
import { currentChapterAtom, currentDictIdAtom } from '@/store'
import { currentChapterAtom, currentDictIdAtom, isReviewModeAtom } from '@/store'
import type { Table } from 'dexie'
import Dexie from 'dexie'
import { useAtomValue } from 'jotai'
@@ -11,6 +11,10 @@ import { useCallback, useContext } from 'react'
class RecordDB extends Dexie {
wordRecords!: Table<IWordRecord, number>
chapterRecords!: Table<IChapterRecord, number>
reviewRecords!: Table<IReviewRecord, number>
revisionDictRecords!: Table<IRevisionDictRecord, number>
revisionWordRecords!: Table<IWordRecord, number>
constructor() {
super('RecordDB')
@@ -22,6 +26,11 @@ class RecordDB extends Dexie {
wordRecords: '++id,word,timeStamp,dict,chapter,wrongCount,[dict+chapter]',
chapterRecords: '++id,timeStamp,dict,chapter,time,[dict+chapter]',
})
this.version(3).stores({
wordRecords: '++id,word,timeStamp,dict,chapter,wrongCount,[dict+chapter]',
chapterRecords: '++id,timeStamp,dict,chapter,time,[dict+chapter]',
reviewRecords: '++id,dict,createTime,isFinished',
})
}
}
@@ -29,9 +38,11 @@ export const db = new RecordDB()
db.wordRecords.mapToClass(WordRecord)
db.chapterRecords.mapToClass(ChapterRecord)
db.reviewRecords.mapToClass(ReviewRecord)
export function useSaveChapterRecord() {
const currentChapter = useAtomValue(currentChapterAtom)
const isRevision = useAtomValue(isReviewModeAtom)
const dictID = useAtomValue(currentDictIdAtom)
const saveChapterRecord = useCallback(
@@ -44,7 +55,7 @@ export function useSaveChapterRecord() {
const chapterRecord = new ChapterRecord(
dictID,
currentChapter,
isRevision ? -1 : currentChapter,
time,
correctCount,
wrongCount,
@@ -55,7 +66,7 @@ export function useSaveChapterRecord() {
)
db.chapterRecords.add(chapterRecord)
},
[currentChapter, dictID],
[currentChapter, dictID, isRevision],
)
return saveChapterRecord
@@ -67,6 +78,7 @@ export type WordKeyLogger = {
}
export function useSaveWordRecord() {
const isRevision = useAtomValue(isReviewModeAtom)
const currentChapter = useAtomValue(currentChapterAtom)
const dictID = useAtomValue(currentDictIdAtom)
@@ -90,7 +102,7 @@ export function useSaveWordRecord() {
timing.push(diff)
}
const wordRecord = new WordRecord(word, dictID, currentChapter, timing, wrongCount, letterMistake)
const wordRecord = new WordRecord(word, dictID, isRevision ? -1 : currentChapter, timing, wrongCount, letterMistake)
let dbID = -1
try {
@@ -103,7 +115,7 @@ export function useSaveWordRecord() {
dispatch({ type: TypingStateActionType.SET_IS_SAVING_RECORD, payload: false })
}
},
[currentChapter, dictID, dispatch],
[currentChapter, dictID, dispatch, isRevision],
)
return saveWordRecord

View File

@@ -1,4 +1,5 @@
import { getUTCUnixTimestamp } from '../index'
import type { Word } from '@/typings'
export interface IWordRecord {
word: string
@@ -47,7 +48,7 @@ export class WordRecord implements IWordRecord {
export interface IChapterRecord {
// 正常章节为 dictKey, 其他功能则为对应的类型
dict: string
// 用户可能是在 错题/其他类似组件中 进行的练习则为 null
// 在错题场景中为 -1
chapter: number | null
timeStamp: number
// 单位为 s章节的记录没必要到毫秒级
@@ -113,3 +114,72 @@ export class ChapterRecord implements IChapterRecord {
return Math.round((this.correctWordIndexes.length / this.wordNumber) * 100)
}
}
export interface IReviewRecord {
id?: number
dict: string
// 当前练习进度
index: number
// 创建时间
createTime: number
// 是否已经完成
isFinished: boolean
// 单词列表, 根据复习算法生成和修改,可能会有重复值
words: Word[]
}
export class ReviewRecord implements IReviewRecord {
id?: number
dict: string
index: number
createTime: number
isFinished: boolean
words: Word[]
constructor(dict: string, words: Word[]) {
this.dict = dict
this.index = 0
this.createTime = getUTCUnixTimestamp()
this.words = words
this.isFinished = false
}
}
export interface IRevisionDictRecord {
dict: string
revisionIndex: number
createdTime: number
}
export class RevisionDictRecord implements IRevisionDictRecord {
dict: string
revisionIndex: number
createdTime: number
constructor(dict: string, revisionIndex: number, createdTime: number) {
this.dict = dict
this.revisionIndex = revisionIndex
this.createdTime = createdTime
}
}
export interface IRevisionWordRecord {
word: string
timeStamp: number
dict: string
errorCount: number
}
export class RevisionWordRecord implements IRevisionWordRecord {
word: string
timeStamp: number
dict: string
errorCount: number
constructor(word: string, dict: string, errorCount: number) {
this.word = word
this.timeStamp = getUTCUnixTimestamp()
this.dict = dict
this.errorCount = errorCount
}
}

View File

@@ -0,0 +1,68 @@
import { db } from '.'
import { ReviewRecord } from './record'
import type { TErrorWordData } from '@/pages/Gallery-N/hooks/useErrorWords'
import type { Word } from '@/typings'
import { useEffect, useState } from 'react'
export function useGetLatestReviewRecord(dictID: string) {
const [wordReviewRecord, setWordReviewRecord] = useState<ReviewRecord | undefined>(undefined)
useEffect(() => {
const fetchWordReviewRecords = async () => {
const record = await getReviewRecords(dictID)
setWordReviewRecord(record)
}
if (dictID) {
fetchWordReviewRecords()
}
}, [dictID])
return wordReviewRecord
}
async function getReviewRecords(dictID: string): Promise<ReviewRecord | undefined> {
const records = await db.reviewRecords.where('dict').equals(dictID).toArray()
const latestRecord = records.sort((a, b) => a.createTime - b.createTime).pop()
return latestRecord && (latestRecord.isFinished ? undefined : latestRecord)
}
type TRankedErrorWordData = TErrorWordData & {
errorCountScore: number
latestErrorTimeScore: number
}
export async function generateNewWordReviewRecord(dictID: string, errorData: TErrorWordData[]) {
const errorCountRankings = [...errorData].sort((a, b) => a.errorCount - b.errorCount)
const latestErrorTimeRankings = [...errorData].sort((a, b) => a.latestErrorTime - b.latestErrorTime)
// 计算每个对象的排名得分
const errorDataWithRank: TRankedErrorWordData[] = errorData.map((item) => ({
...item,
errorCountScore: errorCountRankings.indexOf(item) + 1,
latestErrorTimeScore: latestErrorTimeRankings.indexOf(item) + 1,
}))
// 根据加权排名进行排序
const errorCountWeight = 0.6
const latestErrorTimeWeight = 0.4
const sortedWords: Word[] = errorDataWithRank
.sort((a, b) => {
// 计算 a 和 b 的得分
const scoreA = a.errorCountScore * errorCountWeight + a.latestErrorTimeScore * latestErrorTimeWeight
const scoreB = b.errorCountScore * errorCountWeight + b.latestErrorTimeScore * latestErrorTimeWeight
// 根据得分进行排序
return scoreA - scoreB
})
.map((item) => item.originData)
const record = new ReviewRecord(dictID, sortedWords)
await db.reviewRecords.put(record)
return record
}
export async function putWordReviewRecord(record: ReviewRecord) {
db.reviewRecords.put(record)
}

View File

@@ -118,3 +118,12 @@ export function getUTCUnixTimestamp() {
) / 1000,
)
}
export function timeStamp2String(timestamp: number) {
const date = new Date(timestamp * 1000)
const dateString = date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
const timeString = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
return `${dateString} ${timeString}`
}

6
src/utils/ui.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,8 +1,23 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'],
darkMode: 'class',
theme: {
extend: {
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
transitionDuration: {
0: '0ms',
},
@@ -55,7 +70,7 @@ module.exports = {
backgroundOpacity: ['dark'],
},
},
plugins: [require('@headlessui/tailwindcss'), require('@tailwindcss/forms'), require('daisyui')],
plugins: [require('@headlessui/tailwindcss'), require('@tailwindcss/forms'), require('daisyui'), require('tailwindcss-animate')],
daisyui: {
themes: [
{

918
yarn.lock

File diff suppressed because it is too large Load Diff