mirror of
https://github.com/RealKai42/qwerty-learner.git
synced 2026-04-04 22:09:04 +08:00
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:
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -20,6 +20,7 @@
|
||||
"heroicons",
|
||||
"IELTS",
|
||||
"immer",
|
||||
"Majesticons",
|
||||
"pako",
|
||||
"romaji",
|
||||
"svgr",
|
||||
|
||||
16
components.json
Normal file
16
components.json
Normal 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"
|
||||
}
|
||||
}
|
||||
14
package.json
14
package.json
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
43
src/components/ui/button.tsx
Normal file
43
src/components/ui/button.tsx
Normal 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 }
|
||||
91
src/components/ui/dialog.tsx
Normal file
91
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
38
src/components/ui/scroll-area.tsx
Normal file
38
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
69
src/components/ui/table.tsx
Normal file
69
src/components/ui/table.tsx
Normal 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 }
|
||||
43
src/components/ui/tabs.tsx
Normal file
43
src/components/ui/tabs.tsx
Normal 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 }
|
||||
49
src/components/ui/toggle-group.tsx
Normal file
49
src/components/ui/toggle-group.tsx
Normal 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 }
|
||||
37
src/components/ui/toggle.tsx
Normal file
37
src/components/ui/toggle.tsx
Normal 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 }
|
||||
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal 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 }
|
||||
@@ -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>
|
||||
|
||||
49
src/pages/Gallery-N/Chapter/index.tsx
Normal file
49
src/pages/Gallery-N/Chapter/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
107
src/pages/Gallery-N/DictDetail/index.tsx
Normal file
107
src/pages/Gallery-N/DictDetail/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,45 +30,63 @@ export default function DictionaryComponent({ dictionary, onClick }: Props) {
|
||||
)
|
||||
|
||||
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-800 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
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'
|
||||
<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}
|
||||
>
|
||||
{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'}`}>
|
||||
{dictionary.description}
|
||||
</p>
|
||||
</Tooltip>
|
||||
|
||||
<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'}`}
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<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'}`} />
|
||||
{dictionary.name}
|
||||
</h1>
|
||||
<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">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[60rem] max-w-none !rounded-[20px]">
|
||||
<DictDetail dictionary={dictionary} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
68
src/pages/Gallery-N/ErrorTable/columns.tsx
Normal file
68
src/pages/Gallery-N/ErrorTable/columns.tsx
Normal 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
81
src/pages/Gallery-N/ErrorTable/index.tsx
Normal file
81
src/pages/Gallery-N/ErrorTable/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
src/pages/Gallery-N/ReviewDetail/index.tsx
Normal file
85
src/pages/Gallery-N/ReviewDetail/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
88
src/pages/Gallery-N/hooks/useErrorWords.ts
Normal file
88
src/pages/Gallery-N/hooks/useErrorWords.ts
Normal 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 }
|
||||
}
|
||||
34
src/pages/Gallery-N/hooks/useRevisionWordCount.ts
Normal file
34
src/pages/Gallery-N/hooks/useRevisionWordCount.ts
Normal 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
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,34 +20,36 @@ 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>
|
||||
<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">
|
||||
第 {currentChapter + 1} 章
|
||||
</Listbox.Button>
|
||||
<Transition as={Fragment} leave="transition ease-in duration-100" leaveFrom="opacity-100" leaveTo="opacity-0">
|
||||
<Listbox.Options className="listbox-options z-10 w-32">
|
||||
{range(0, chapterCount, 1).map((index) => (
|
||||
<Listbox.Option key={index} value={index}>
|
||||
{({ selected }) => (
|
||||
<div className="group flex cursor-pointer items-center justify-between">
|
||||
{selected ? (
|
||||
<span className="listbox-options-icon">
|
||||
<IconCheck className="focus:outline-none" />
|
||||
</span>
|
||||
) : null}
|
||||
<span>第 {index + 1} 章</span>
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
</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">
|
||||
第 {currentChapter + 1} 章
|
||||
</Listbox.Button>
|
||||
<Transition as={Fragment} leave="transition ease-in duration-100" leaveFrom="opacity-100" leaveTo="opacity-0">
|
||||
<Listbox.Options className="listbox-options z-10 w-32">
|
||||
{range(0, chapterCount, 1).map((index) => (
|
||||
<Listbox.Option key={index} value={index}>
|
||||
{({ selected }) => (
|
||||
<div className="group flex cursor-pointer items-center justify-between">
|
||||
{selected ? (
|
||||
<span className="listbox-options-icon">
|
||||
<IconCheck className="focus:outline-none" />
|
||||
</span>
|
||||
) : null}
|
||||
<span>第 {index + 1} 章</span>
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
dispatch({ type: TypingStateActionType.REPEAT_CHAPTER, shouldShuffle: false })
|
||||
}, [dispatch])
|
||||
if (isReviewMode) {
|
||||
setCurrentChapter(0)
|
||||
setReviewModeInfo((old) => ({ ...old, isReviewMode: false }))
|
||||
} else {
|
||||
dispatch({ type: TypingStateActionType.REPEAT_CHAPTER, shouldShuffle: false })
|
||||
}
|
||||
}, [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">
|
||||
<ShareButton />
|
||||
<IexportWords fontSize={18} className="cursor-pointer text-gray-500" onClick={exportWords}></IexportWords>
|
||||
{!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,27 +286,31 @@ const ResultScreen = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10 flex w-full justify-center gap-5 px-5 text-xl">
|
||||
<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"
|
||||
type="button"
|
||||
onClick={dictationButtonHandler}
|
||||
title="默写本章节"
|
||||
>
|
||||
默写本章节
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="快捷键:space">
|
||||
<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"
|
||||
type="button"
|
||||
onClick={repeatButtonHandler}
|
||||
title="重复本章节"
|
||||
>
|
||||
重复本章节
|
||||
</button>
|
||||
</Tooltip>
|
||||
{!isLastChapter && (
|
||||
{!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"
|
||||
type="button"
|
||||
onClick={dictationButtonHandler}
|
||||
title="默写本章节"
|
||||
>
|
||||
默写本章节
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="快捷键:space">
|
||||
<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"
|
||||
type="button"
|
||||
onClick={repeatButtonHandler}
|
||||
title="重复本章节"
|
||||
>
|
||||
重复本章节
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{!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>
|
||||
|
||||
@@ -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) => {
|
||||
return `${get(currentDictInfoAtom).name} 第 ${get(currentChapterAtom) + 1} 章`
|
||||
const isReviewMode = get(isReviewModeAtom)
|
||||
|
||||
if (isReviewMode) {
|
||||
return `${get(currentDictInfoAtom).name} 错题复习`
|
||||
} else {
|
||||
return `${get(currentDictInfoAtom).name} 第 ${get(currentChapterAtom) + 1} 章`
|
||||
}
|
||||
})
|
||||
|
||||
export default function WordList() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
dispatch({ type: TypingStateActionType.NEXT_WORD })
|
||||
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(
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
src/store/reviewInfoAtom.ts
Normal file
28
src/store/reviewInfoAtom.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
68
src/utils/db/review-record.ts
Normal file
68
src/utils/db/review-record.ts
Normal 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)
|
||||
}
|
||||
@@ -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
6
src/utils/ui.ts
Normal 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))
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user