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",
|
"heroicons",
|
||||||
"IELTS",
|
"IELTS",
|
||||||
"immer",
|
"immer",
|
||||||
|
"Majesticons",
|
||||||
"pako",
|
"pako",
|
||||||
"romaji",
|
"romaji",
|
||||||
"svgr",
|
"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",
|
"@floating-ui/react": "^0.20.1",
|
||||||
"@headlessui/react": "^1.7.13",
|
"@headlessui/react": "^1.7.13",
|
||||||
"@headlessui/tailwindcss": "^0.1.2",
|
"@headlessui/tailwindcss": "^0.1.2",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-progress": "^1.0.2",
|
"@radix-ui/react-progress": "^1.0.2",
|
||||||
"@radix-ui/react-radio-group": "^1.1.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-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",
|
"canvas-confetti": "^1.6.0",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
"daisyui": "^3.5.1",
|
"daisyui": "^3.5.1",
|
||||||
"dayjs": "^1.11.8",
|
"dayjs": "^1.11.8",
|
||||||
"dexie": "^3.2.3",
|
"dexie": "^3.2.3",
|
||||||
@@ -24,6 +33,7 @@
|
|||||||
"html-to-image": "^1.11.11",
|
"html-to-image": "^1.11.11",
|
||||||
"immer": "^9.0.21",
|
"immer": "^9.0.21",
|
||||||
"jotai": "^2.0.3",
|
"jotai": "^2.0.3",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
"mixpanel-browser": "^2.45.0",
|
"mixpanel-browser": "^2.45.0",
|
||||||
"pako": "^2.1.0",
|
"pako": "^2.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -36,6 +46,8 @@
|
|||||||
"react-tooltip": "^5.18.0",
|
"react-tooltip": "^5.18.0",
|
||||||
"source-map-explorer": "^2.5.2",
|
"source-map-explorer": "^2.5.2",
|
||||||
"swr": "^2.0.4",
|
"swr": "^2.0.4",
|
||||||
|
"tailwind-merge": "^2.1.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^4.0.3",
|
"typescript": "^4.0.3",
|
||||||
"use-immer": "^0.9.0",
|
"use-immer": "^0.9.0",
|
||||||
"use-sound": "^4.0.1",
|
"use-sound": "^4.0.1",
|
||||||
|
|||||||
@@ -21,13 +21,7 @@
|
|||||||
"depends": []
|
"depends": []
|
||||||
},
|
},
|
||||||
"externalBin": [],
|
"externalBin": [],
|
||||||
"icon": [
|
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||||
"icons/32x32.png",
|
|
||||||
"icons/128x128.png",
|
|
||||||
"icons/128x128@2x.png",
|
|
||||||
"icons/icon.icns",
|
|
||||||
"icons/icon.ico"
|
|
||||||
],
|
|
||||||
"identifier": "com.litongjava.qwerty.learner",
|
"identifier": "com.litongjava.qwerty.learner",
|
||||||
"longDescription": "",
|
"longDescription": "",
|
||||||
"macOS": {
|
"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 DictTagSwitcher from './DictTagSwitcher'
|
||||||
import DictionaryComponent from './DictionaryWithoutCover'
|
import DictionaryComponent from './DictionaryWithoutCover'
|
||||||
import { GalleryContext } from './index'
|
|
||||||
import { currentDictInfoAtom } from '@/store'
|
import { currentDictInfoAtom } from '@/store'
|
||||||
import type { Dictionary } from '@/typings'
|
import type { Dictionary } from '@/typings'
|
||||||
import { findCommonValues } from '@/utils'
|
import { findCommonValues } from '@/utils'
|
||||||
import { useAtomValue } from 'jotai'
|
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[]> }) {
|
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 tagList = useMemo(() => Object.keys(groupedDictsByTag), [groupedDictsByTag])
|
||||||
const [currentTag, setCurrentTag] = useState(tagList[0])
|
const [currentTag, setCurrentTag] = useState(tagList[0])
|
||||||
const currentDictInfo = useAtomValue(currentDictInfoAtom)
|
const currentDictInfo = useAtomValue(currentDictInfoAtom)
|
||||||
@@ -18,15 +15,6 @@ export default function DictionaryGroup({ groupedDictsByTag }: { groupedDictsByT
|
|||||||
setCurrentTag(tag)
|
setCurrentTag(tag)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onClickDict = useCallback(
|
|
||||||
(dict: Dictionary) => {
|
|
||||||
setState((state) => {
|
|
||||||
state.chapterListDict = dict
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[setState],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const commonTags = findCommonValues(tagList, currentDictInfo.tags)
|
const commonTags = findCommonValues(tagList, currentDictInfo.tags)
|
||||||
if (commonTags.length > 0) {
|
if (commonTags.length > 0) {
|
||||||
@@ -39,7 +27,7 @@ export default function DictionaryGroup({ groupedDictsByTag }: { groupedDictsByT
|
|||||||
<DictTagSwitcher tagList={tagList} currentTag={currentTag} onChangeCurrentTag={onChangeCurrentTag} />
|
<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">
|
<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) => (
|
{groupedDictsByTag[currentTag].map((dict) => (
|
||||||
<DictionaryComponent key={dict.id} dictionary={dict} onClick={() => onClickDict(dict)} />
|
<DictionaryComponent key={dict.id} dictionary={dict} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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 { useDictStats } from './hooks/useDictStats'
|
||||||
import bookCover from '@/assets/book-cover.png'
|
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 useIntersectionObserver from '@/hooks/useIntersectionObserver'
|
||||||
import { currentDictIdAtom } from '@/store'
|
import { currentDictIdAtom } from '@/store'
|
||||||
import type { Dictionary } from '@/typings'
|
import type { Dictionary } from '@/typings'
|
||||||
@@ -11,10 +13,9 @@ import { useMemo, useRef } from 'react'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dictionary: Dictionary
|
dictionary: Dictionary
|
||||||
onClick?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DictionaryComponent({ dictionary, onClick }: Props) {
|
export default function DictionaryComponent({ dictionary }: Props) {
|
||||||
const currentDictID = useAtomValue(currentDictIdAtom)
|
const currentDictID = useAtomValue(currentDictIdAtom)
|
||||||
|
|
||||||
const divRef = useRef<HTMLDivElement>(null)
|
const divRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -29,45 +30,63 @@ export default function DictionaryComponent({ dictionary, onClick }: Props) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog>
|
||||||
ref={divRef}
|
<DialogTrigger asChild>
|
||||||
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 ${
|
<div
|
||||||
isSelected ? 'bg-indigo-400' : 'bg-zinc-50 hover:bg-white dark:bg-gray-800 dark:hover:bg-gray-700'
|
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 ${
|
||||||
role="button"
|
isSelected ? 'bg-indigo-400' : 'bg-zinc-50 hover:bg-white dark:bg-gray-800 dark:hover:bg-gray-700'
|
||||||
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'
|
|
||||||
}`}
|
}`}
|
||||||
|
role="button"
|
||||||
|
// onClick={onClick}
|
||||||
>
|
>
|
||||||
{dictionary.name}
|
<div className="relative ml-1 mt-2 flex h-full w-full flex-col items-start justify-start">
|
||||||
</h1>
|
<h1
|
||||||
<Tooltip className="w-full" content={dictionary.description}>
|
className={`mb-1.5 text-xl font-normal ${
|
||||||
<p className={`mb-1 w-full truncate ${isSelected ? 'text-white' : 'text-gray-600 dark:text-gray-200'}`}>
|
isSelected ? 'text-white' : 'text-gray-800 group-hover:text-indigo-400 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'}`}
|
|
||||||
>
|
>
|
||||||
<Progress.Indicator
|
{dictionary.name}
|
||||||
className={`h-full rounded-full pl-0 ${isSelected ? 'bg-indigo-600' : 'bg-indigo-400'}`}
|
</h1>
|
||||||
style={{ width: `calc(${progress}% )` }}
|
<TooltipProvider>
|
||||||
/>
|
<Tooltip delayDuration={400}>
|
||||||
</Progress.Root>
|
<TooltipTrigger asChild>
|
||||||
)}
|
<p
|
||||||
<img src={bookCover} className={`absolute right-3 top-3 w-16 ${isSelected ? 'opacity-50' : 'opacity-20'}`} />
|
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>
|
||||||
</div>
|
<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) => {
|
const uniqueChapter = allChapter.filter((value, index, self) => {
|
||||||
return self.indexOf(value) === index
|
return self.indexOf(value) === index
|
||||||
})
|
})
|
||||||
|
|
||||||
const exercisedChapterCount = uniqueChapter.length
|
const exercisedChapterCount = uniqueChapter.length
|
||||||
|
|
||||||
return { exercisedChapterCount }
|
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 DictionaryGroup from './CategoryDicts'
|
||||||
import ChapterList from './ChapterList'
|
|
||||||
import DictRequest from './DictRequest'
|
import DictRequest from './DictRequest'
|
||||||
import { LanguageTabSwitcher } from './LanguageTabSwitcher'
|
import { LanguageTabSwitcher } from './LanguageTabSwitcher'
|
||||||
import Layout from '@/components/Layout'
|
import Layout from '@/components/Layout'
|
||||||
@@ -19,12 +18,10 @@ import IconX from '~icons/tabler/x'
|
|||||||
|
|
||||||
export type GalleryState = {
|
export type GalleryState = {
|
||||||
currentLanguageTab: LanguageCategoryType
|
currentLanguageTab: LanguageCategoryType
|
||||||
chapterListDict: Dictionary | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialGalleryState: GalleryState = {
|
const initialGalleryState: GalleryState = {
|
||||||
currentLanguageTab: 'en',
|
currentLanguageTab: 'en',
|
||||||
chapterListDict: null,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GalleryContext = createContext<{
|
export const GalleryContext = createContext<{
|
||||||
@@ -66,7 +63,6 @@ export default function GalleryPage() {
|
|||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<GalleryContext.Provider value={{ state: galleryState, setState: setGalleryState }}>
|
<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">
|
<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} />
|
<IconX className="absolute right-20 top-10 mr-2 h-7 w-7 cursor-pointer text-gray-400" onClick={onBack} />
|
||||||
<div className="mt-20 flex w-full flex-1 flex-col items-center justify-center overflow-y-auto">
|
<div className="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 Tooltip from '@/components/Tooltip'
|
||||||
import { currentChapterAtom, currentDictInfoAtom } from '@/store'
|
import { currentChapterAtom, currentDictInfoAtom, isReviewModeAtom } from '@/store'
|
||||||
import range from '@/utils/range'
|
import range from '@/utils/range'
|
||||||
import { Listbox, Transition } from '@headlessui/react'
|
import { Listbox, Transition } from '@headlessui/react'
|
||||||
import { useAtom, useAtomValue } from 'jotai'
|
import { useAtom, useAtomValue } from 'jotai'
|
||||||
@@ -11,6 +11,7 @@ export const DictChapterButton = () => {
|
|||||||
const currentDictInfo = useAtomValue(currentDictInfoAtom)
|
const currentDictInfo = useAtomValue(currentDictInfoAtom)
|
||||||
const [currentChapter, setCurrentChapter] = useAtom(currentChapterAtom)
|
const [currentChapter, setCurrentChapter] = useAtom(currentChapterAtom)
|
||||||
const chapterCount = currentDictInfo.chapterCount
|
const chapterCount = currentDictInfo.chapterCount
|
||||||
|
const isReviewMode = useAtomValue(isReviewModeAtom)
|
||||||
|
|
||||||
return (
|
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"
|
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"
|
to="/gallery"
|
||||||
>
|
>
|
||||||
{currentDictInfo.name}
|
{currentDictInfo.name} {isReviewMode && '错题复习'}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="章节切换">
|
{!isReviewMode && (
|
||||||
<Listbox value={currentChapter} onChange={setCurrentChapter}>
|
<Tooltip content="章节切换">
|
||||||
<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">
|
<Listbox value={currentChapter} onChange={setCurrentChapter}>
|
||||||
第 {currentChapter + 1} 章
|
<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">
|
||||||
</Listbox.Button>
|
第 {currentChapter + 1} 章
|
||||||
<Transition as={Fragment} leave="transition ease-in duration-100" leaveFrom="opacity-100" leaveTo="opacity-0">
|
</Listbox.Button>
|
||||||
<Listbox.Options className="listbox-options z-10 w-32">
|
<Transition as={Fragment} leave="transition ease-in duration-100" leaveFrom="opacity-100" leaveTo="opacity-0">
|
||||||
{range(0, chapterCount, 1).map((index) => (
|
<Listbox.Options className="listbox-options z-10 w-32">
|
||||||
<Listbox.Option key={index} value={index}>
|
{range(0, chapterCount, 1).map((index) => (
|
||||||
{({ selected }) => (
|
<Listbox.Option key={index} value={index}>
|
||||||
<div className="group flex cursor-pointer items-center justify-between">
|
{({ selected }) => (
|
||||||
{selected ? (
|
<div className="group flex cursor-pointer items-center justify-between">
|
||||||
<span className="listbox-options-icon">
|
{selected ? (
|
||||||
<IconCheck className="focus:outline-none" />
|
<span className="listbox-options-icon">
|
||||||
</span>
|
<IconCheck className="focus:outline-none" />
|
||||||
) : null}
|
</span>
|
||||||
<span>第 {index + 1} 章</span>
|
) : null}
|
||||||
</div>
|
<span>第 {index + 1} 章</span>
|
||||||
)}
|
</div>
|
||||||
</Listbox.Option>
|
)}
|
||||||
))}
|
</Listbox.Option>
|
||||||
</Listbox.Options>
|
))}
|
||||||
</Transition>
|
</Listbox.Options>
|
||||||
</Listbox>
|
</Transition>
|
||||||
</Tooltip>
|
</Listbox>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,22 @@ import RemarkRing from './RemarkRing'
|
|||||||
import WordChip from './WordChip'
|
import WordChip from './WordChip'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
import Tooltip from '@/components/Tooltip'
|
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 type { InfoPanelType } from '@/typings'
|
||||||
import { recordOpenInfoPanelAction } from '@/utils'
|
import { recordOpenInfoPanelAction } from '@/utils'
|
||||||
import { Transition } from '@headlessui/react'
|
import { Transition } from '@headlessui/react'
|
||||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||||
import { useCallback, useContext, useEffect, useMemo } from 'react'
|
import { useCallback, useContext, useEffect, useMemo } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import IexportWords from '~icons/icon-park-outline/excel'
|
import IexportWords from '~icons/icon-park-outline/excel'
|
||||||
import IconCoffee from '~icons/mdi/coffee'
|
import IconCoffee from '~icons/mdi/coffee'
|
||||||
import IconXiaoHongShu from '~icons/my-icons/xiaohongshu'
|
import IconXiaoHongShu from '~icons/my-icons/xiaohongshu'
|
||||||
@@ -28,6 +37,10 @@ const ResultScreen = () => {
|
|||||||
const [currentChapter, setCurrentChapter] = useAtom(currentChapterAtom)
|
const [currentChapter, setCurrentChapter] = useAtom(currentChapterAtom)
|
||||||
const setInfoPanelState = useSetAtom(infoPanelStateAtom)
|
const setInfoPanelState = useSetAtom(infoPanelStateAtom)
|
||||||
const randomConfig = useAtomValue(randomConfigAtom)
|
const randomConfig = useAtomValue(randomConfigAtom)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const setReviewModeInfo = useSetAtom(reviewModeInfoAtom)
|
||||||
|
const isReviewMode = useAtomValue(isReviewModeAtom)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// tick a zero timer to calc the stats
|
// tick a zero timer to calc the stats
|
||||||
@@ -98,7 +111,11 @@ const ResultScreen = () => {
|
|||||||
return `${minuteString}:${secondString}`
|
return `${minuteString}:${secondString}`
|
||||||
}, [state.timerData.time])
|
}, [state.timerData.time])
|
||||||
|
|
||||||
const repeatButtonHandler = useCallback(() => {
|
const repeatButtonHandler = useCallback(async () => {
|
||||||
|
if (isReviewMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setWordDictationConfig((old) => {
|
setWordDictationConfig((old) => {
|
||||||
if (old.isOpen) {
|
if (old.isOpen) {
|
||||||
if (old.openBy === 'auto') {
|
if (old.openBy === 'auto') {
|
||||||
@@ -108,15 +125,22 @@ const ResultScreen = () => {
|
|||||||
return old
|
return old
|
||||||
})
|
})
|
||||||
dispatch({ type: TypingStateActionType.REPEAT_CHAPTER, shouldShuffle: randomConfig.isOpen })
|
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' }))
|
setWordDictationConfig((old) => ({ ...old, isOpen: true, openBy: 'auto' }))
|
||||||
|
|
||||||
dispatch({ type: TypingStateActionType.REPEAT_CHAPTER, shouldShuffle: randomConfig.isOpen })
|
dispatch({ type: TypingStateActionType.REPEAT_CHAPTER, shouldShuffle: randomConfig.isOpen })
|
||||||
}, [dispatch, randomConfig.isOpen, setWordDictationConfig])
|
}, [isReviewMode, setWordDictationConfig, dispatch, randomConfig.isOpen])
|
||||||
|
|
||||||
const nextButtonHandler = useCallback(() => {
|
const nextButtonHandler = useCallback(() => {
|
||||||
|
if (isReviewMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setWordDictationConfig((old) => {
|
setWordDictationConfig((old) => {
|
||||||
if (old.isOpen) {
|
if (old.isOpen) {
|
||||||
if (old.openBy === 'auto') {
|
if (old.openBy === 'auto') {
|
||||||
@@ -129,11 +153,23 @@ const ResultScreen = () => {
|
|||||||
setCurrentChapter((old) => old + 1)
|
setCurrentChapter((old) => old + 1)
|
||||||
dispatch({ type: TypingStateActionType.NEXT_CHAPTER })
|
dispatch({ type: TypingStateActionType.NEXT_CHAPTER })
|
||||||
}
|
}
|
||||||
}, [dispatch, isLastChapter, setCurrentChapter, setWordDictationConfig])
|
}, [dispatch, isLastChapter, isReviewMode, setCurrentChapter, setWordDictationConfig])
|
||||||
|
|
||||||
const exitButtonHandler = useCallback(() => {
|
const exitButtonHandler = useCallback(() => {
|
||||||
dispatch({ type: TypingStateActionType.REPEAT_CHAPTER, shouldShuffle: false })
|
if (isReviewMode) {
|
||||||
}, [dispatch])
|
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(
|
useHotkeys(
|
||||||
'enter',
|
'enter',
|
||||||
() => {
|
() => {
|
||||||
@@ -183,7 +219,7 @@ const ResultScreen = () => {
|
|||||||
<div className="flex h-screen items-center justify-center">
|
<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="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">
|
<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>
|
</div>
|
||||||
<button className="absolute right-7 top-5" onClick={exitButtonHandler}>
|
<button className="absolute right-7 top-5" onClick={exitButtonHandler}>
|
||||||
<IconX className="text-gray-400" />
|
<IconX className="text-gray-400" />
|
||||||
@@ -205,8 +241,12 @@ const ResultScreen = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-2 flex flex-col items-center justify-end gap-3.5 text-xl">
|
<div className="ml-2 flex flex-col items-center justify-end gap-3.5 text-xl">
|
||||||
<ShareButton />
|
{!isReviewMode && (
|
||||||
<IexportWords fontSize={18} className="cursor-pointer text-gray-500" onClick={exportWords}></IexportWords>
|
<>
|
||||||
|
<ShareButton />
|
||||||
|
<IexportWords fontSize={18} className="cursor-pointer text-gray-500" onClick={exportWords}></IexportWords>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<IconXiaoHongShu
|
<IconXiaoHongShu
|
||||||
fontSize={15}
|
fontSize={15}
|
||||||
className="cursor-pointer text-gray-500 hover:text-red-500 focus:outline-none"
|
className="cursor-pointer text-gray-500 hover:text-red-500 focus:outline-none"
|
||||||
@@ -246,27 +286,31 @@ const ResultScreen = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10 flex w-full justify-center gap-5 px-5 text-xl">
|
<div className="mt-10 flex w-full justify-center gap-5 px-5 text-xl">
|
||||||
<Tooltip content="快捷键:shift + enter">
|
{!isReviewMode && (
|
||||||
<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"
|
<Tooltip content="快捷键:shift + enter">
|
||||||
type="button"
|
<button
|
||||||
onClick={dictationButtonHandler}
|
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"
|
||||||
title="默写本章节"
|
type="button"
|
||||||
>
|
onClick={dictationButtonHandler}
|
||||||
默写本章节
|
title="默写本章节"
|
||||||
</button>
|
>
|
||||||
</Tooltip>
|
默写本章节
|
||||||
<Tooltip content="快捷键:space">
|
</button>
|
||||||
<button
|
</Tooltip>
|
||||||
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"
|
<Tooltip content="快捷键:space">
|
||||||
type="button"
|
<button
|
||||||
onClick={repeatButtonHandler}
|
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"
|
||||||
title="重复本章节"
|
type="button"
|
||||||
>
|
onClick={repeatButtonHandler}
|
||||||
重复本章节
|
title="重复本章节"
|
||||||
</button>
|
>
|
||||||
</Tooltip>
|
重复本章节
|
||||||
{!isLastChapter && (
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isLastChapter && !isReviewMode && (
|
||||||
<Tooltip content="快捷键:enter">
|
<Tooltip content="快捷键:enter">
|
||||||
<button
|
<button
|
||||||
className={`{ isLastChapter ? 'cursor-not-allowed opacity-50' : ''} my-btn-primary h-12 text-base font-bold `}
|
className={`{ isLastChapter ? 'cursor-not-allowed opacity-50' : ''} my-btn-primary h-12 text-base font-bold `}
|
||||||
@@ -278,6 +322,17 @@ const ResultScreen = () => {
|
|||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isReviewMode && (
|
||||||
|
<button
|
||||||
|
className="my-btn-primary h-12 text-base font-bold"
|
||||||
|
type="button"
|
||||||
|
onClick={onNavigateToGallery}
|
||||||
|
title="练习其他章节"
|
||||||
|
>
|
||||||
|
练习其他章节
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TypingContext, TypingStateActionType } from '../../store'
|
|||||||
import WordCard from './WordCard'
|
import WordCard from './WordCard'
|
||||||
import Drawer from '@/components/Drawer'
|
import Drawer from '@/components/Drawer'
|
||||||
import Tooltip from '@/components/Tooltip'
|
import Tooltip from '@/components/Tooltip'
|
||||||
import { currentChapterAtom, currentDictInfoAtom } from '@/store'
|
import { currentChapterAtom, currentDictInfoAtom, isReviewModeAtom } from '@/store'
|
||||||
import { Dialog } from '@headlessui/react'
|
import { Dialog } from '@headlessui/react'
|
||||||
import * as ScrollArea from '@radix-ui/react-scroll-area'
|
import * as ScrollArea from '@radix-ui/react-scroll-area'
|
||||||
import { atom, useAtomValue } from 'jotai'
|
import { atom, useAtomValue } from 'jotai'
|
||||||
@@ -11,7 +11,13 @@ import ListIcon from '~icons/tabler/list'
|
|||||||
import IconX from '~icons/tabler/x'
|
import IconX from '~icons/tabler/x'
|
||||||
|
|
||||||
const currentDictTitle = atom((get) => {
|
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() {
|
export default function WordList() {
|
||||||
|
|||||||
@@ -167,6 +167,12 @@ export default function WordComponent({ word, onFinish }: { word: Word; onFinish
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const inputLength = wordState.inputWord.length
|
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) {
|
if (wordState.hasWrong || inputLength === 0 || wordState.displayWord.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { TypingContext, TypingStateActionType } from '../../store'
|
import { TypingContext, TypingStateActionType } from '../../store'
|
||||||
|
import type { TypingState } from '../../store/type'
|
||||||
import PrevAndNextWord from '../PrevAndNextWord'
|
import PrevAndNextWord from '../PrevAndNextWord'
|
||||||
import Progress from '../Progress'
|
import Progress from '../Progress'
|
||||||
import Phonetic from './components/Phonetic'
|
import Phonetic from './components/Phonetic'
|
||||||
import Translation from './components/Translation'
|
import Translation from './components/Translation'
|
||||||
import WordComponent from './components/Word'
|
import WordComponent from './components/Word'
|
||||||
import { usePrefetchPronunciationSound } from '@/hooks/usePronunciation'
|
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 type { Word } from '@/typings'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue, useSetAtom } from 'jotai'
|
||||||
import { useCallback, useContext, useMemo, useState } from 'react'
|
import { useCallback, useContext, useMemo, useState } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
|
||||||
@@ -22,6 +23,9 @@ export default function WordPanel() {
|
|||||||
const currentWord = state.chapterData.words[state.chapterData.index]
|
const currentWord = state.chapterData.words[state.chapterData.index]
|
||||||
const nextWord = state.chapterData.words[state.chapterData.index + 1] as Word | undefined
|
const nextWord = state.chapterData.words[state.chapterData.index + 1] as Word | undefined
|
||||||
|
|
||||||
|
const setReviewModeInfo = useSetAtom(reviewModeInfoAtom)
|
||||||
|
const isReviewMode = useAtomValue(isReviewModeAtom)
|
||||||
|
|
||||||
const prevIndex = useMemo(() => {
|
const prevIndex = useMemo(() => {
|
||||||
const newIndex = state.chapterData.index - 1
|
const newIndex = state.chapterData.index - 1
|
||||||
return newIndex < 0 ? 0 : newIndex
|
return newIndex < 0 ? 0 : newIndex
|
||||||
@@ -37,6 +41,16 @@ export default function WordPanel() {
|
|||||||
setWordComponentKey((old) => old + 1)
|
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(() => {
|
const onFinish = useCallback(() => {
|
||||||
if (state.chapterData.index < state.chapterData.words.length - 1 || currentWordExerciseCount < loopWordTimes - 1) {
|
if (state.chapterData.index < state.chapterData.words.length - 1 || currentWordExerciseCount < loopWordTimes - 1) {
|
||||||
// 用户完成当前单词
|
// 用户完成当前单词
|
||||||
@@ -46,11 +60,23 @@ export default function WordPanel() {
|
|||||||
reloadCurrentWordComponent()
|
reloadCurrentWordComponent()
|
||||||
} else {
|
} else {
|
||||||
setCurrentWordExerciseCount(0)
|
setCurrentWordExerciseCount(0)
|
||||||
dispatch({ type: TypingStateActionType.NEXT_WORD })
|
if (isReviewMode) {
|
||||||
|
dispatch({
|
||||||
|
type: TypingStateActionType.NEXT_WORD,
|
||||||
|
payload: {
|
||||||
|
updateReviewRecord,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
dispatch({ type: TypingStateActionType.NEXT_WORD })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 用户完成当前章节
|
// 用户完成当前章节
|
||||||
dispatch({ type: TypingStateActionType.FINISH_CHAPTER })
|
dispatch({ type: TypingStateActionType.FINISH_CHAPTER })
|
||||||
|
if (isReviewMode) {
|
||||||
|
setReviewModeInfo((old) => ({ ...old, reviewRecord: old.reviewRecord ? { ...old.reviewRecord, isFinished: true } : undefined }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
state.chapterData.index,
|
state.chapterData.index,
|
||||||
@@ -59,6 +85,9 @@ export default function WordPanel() {
|
|||||||
loopWordTimes,
|
loopWordTimes,
|
||||||
dispatch,
|
dispatch,
|
||||||
reloadCurrentWordComponent,
|
reloadCurrentWordComponent,
|
||||||
|
isReviewMode,
|
||||||
|
updateReviewRecord,
|
||||||
|
setReviewModeInfo,
|
||||||
])
|
])
|
||||||
|
|
||||||
const onSkipWord = useCallback(
|
const onSkipWord = useCallback(
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { CHAPTER_LENGTH } from '@/constants'
|
import { CHAPTER_LENGTH } from '@/constants'
|
||||||
import { currentChapterAtom, currentDictInfoAtom } from '@/store'
|
import { currentChapterAtom, currentDictInfoAtom, reviewModeInfoAtom } from '@/store'
|
||||||
import type { WordWithIndex } from '@/typings/index'
|
import type { Word, WordWithIndex } from '@/typings/index'
|
||||||
import { wordListFetcher } from '@/utils/wordListFetcher'
|
import { wordListFetcher } from '@/utils/wordListFetcher'
|
||||||
import { useAtom, useAtomValue } from 'jotai'
|
import { useAtom, useAtomValue } from 'jotai'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import useSWR from 'swr'
|
import useSWR from 'swr'
|
||||||
|
|
||||||
export type UseWordListResult = {
|
export type UseWordListResult = {
|
||||||
words: WordWithIndex[] | undefined
|
words: WordWithIndex[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
error: Error | undefined
|
error: Error | undefined
|
||||||
}
|
}
|
||||||
@@ -18,23 +18,29 @@ export type UseWordListResult = {
|
|||||||
export function useWordList(): UseWordListResult {
|
export function useWordList(): UseWordListResult {
|
||||||
const currentDictInfo = useAtomValue(currentDictInfoAtom)
|
const currentDictInfo = useAtomValue(currentDictInfoAtom)
|
||||||
const [currentChapter, setCurrentChapter] = useAtom(currentChapterAtom)
|
const [currentChapter, setCurrentChapter] = useAtom(currentChapterAtom)
|
||||||
|
const { isReviewMode, reviewRecord } = useAtomValue(reviewModeInfoAtom)
|
||||||
const isFirstChapter = currentDictInfo.id === 'cet4' && currentChapter === 0
|
|
||||||
|
|
||||||
// Reset current chapter to 0, when currentChapter is greater than chapterCount.
|
// Reset current chapter to 0, when currentChapter is greater than chapterCount.
|
||||||
if (currentChapter >= currentDictInfo.chapterCount) {
|
if (currentChapter >= currentDictInfo.chapterCount) {
|
||||||
setCurrentChapter(0)
|
setCurrentChapter(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFirstChapter = !isReviewMode && currentDictInfo.id === 'cet4' && currentChapter === 0
|
||||||
const { data: wordList, error, isLoading } = useSWR(currentDictInfo.url, wordListFetcher)
|
const { data: wordList, error, isLoading } = useSWR(currentDictInfo.url, wordListFetcher)
|
||||||
|
|
||||||
const words: WordWithIndex[] = useMemo(() => {
|
const words: WordWithIndex[] = useMemo(() => {
|
||||||
const newWords = isFirstChapter
|
let newWords: Word[]
|
||||||
? firstChapter
|
if (isFirstChapter) {
|
||||||
: wordList
|
newWords = firstChapter
|
||||||
? wordList.slice(currentChapter * CHAPTER_LENGTH, (currentChapter + 1) * CHAPTER_LENGTH)
|
} 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) => {
|
return newWords.map((word, index) => {
|
||||||
let trans: string[]
|
let trans: string[]
|
||||||
if (Array.isArray(word.trans)) {
|
if (Array.isArray(word.trans)) {
|
||||||
@@ -50,9 +56,9 @@ export function useWordList(): UseWordListResult {
|
|||||||
trans,
|
trans,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [isFirstChapter, wordList, currentChapter])
|
}, [isFirstChapter, isReviewMode, wordList, reviewRecord?.words, currentChapter])
|
||||||
|
|
||||||
return { words: wordList === undefined ? undefined : words, isLoading, error }
|
return { words, isLoading, error }
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstChapter = [
|
const firstChapter = [
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ import Header from '@/components/Header'
|
|||||||
import StarCard from '@/components/StarCard'
|
import StarCard from '@/components/StarCard'
|
||||||
import Tooltip from '@/components/Tooltip'
|
import Tooltip from '@/components/Tooltip'
|
||||||
import { idDictionaryMap } from '@/resources/dictionary'
|
import { idDictionaryMap } from '@/resources/dictionary'
|
||||||
import { currentDictIdAtom, randomConfigAtom } from '@/store'
|
import { currentChapterAtom, currentDictIdAtom, isReviewModeAtom, randomConfigAtom, reviewModeInfoAtom } from '@/store'
|
||||||
import { IsDesktop, isLegal } from '@/utils'
|
import { IsDesktop, isLegal } from '@/utils'
|
||||||
import { useSaveChapterRecord } from '@/utils/db'
|
import { useSaveChapterRecord } from '@/utils/db'
|
||||||
import { useMixPanelChapterLogUploader } from '@/utils/mixpanel'
|
import { useMixPanelChapterLogUploader } from '@/utils/mixpanel'
|
||||||
import { useAtom, useAtomValue } from 'jotai'
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useImmerReducer } from 'use-immer'
|
import { useImmerReducer } from 'use-immer'
|
||||||
@@ -30,11 +30,14 @@ const App: React.FC = () => {
|
|||||||
const { words } = useWordList()
|
const { words } = useWordList()
|
||||||
|
|
||||||
const [currentDictId, setCurrentDictId] = useAtom(currentDictIdAtom)
|
const [currentDictId, setCurrentDictId] = useAtom(currentDictIdAtom)
|
||||||
|
const setCurrentChapter = useSetAtom(currentChapterAtom)
|
||||||
const randomConfig = useAtomValue(randomConfigAtom)
|
const randomConfig = useAtomValue(randomConfigAtom)
|
||||||
|
|
||||||
const chapterLogUploader = useMixPanelChapterLogUploader(state)
|
const chapterLogUploader = useMixPanelChapterLogUploader(state)
|
||||||
const saveChapterRecord = useSaveChapterRecord()
|
const saveChapterRecord = useSaveChapterRecord()
|
||||||
|
|
||||||
|
const reviewModeInfo = useAtomValue(reviewModeInfoAtom)
|
||||||
|
const isReviewMode = useAtomValue(isReviewModeAtom)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 检测用户设备
|
// 检测用户设备
|
||||||
if (!IsDesktop()) {
|
if (!IsDesktop()) {
|
||||||
@@ -51,8 +54,10 @@ const App: React.FC = () => {
|
|||||||
const id = currentDictId
|
const id = currentDictId
|
||||||
if (!(id in idDictionaryMap)) {
|
if (!(id in idDictionaryMap)) {
|
||||||
setCurrentDictId('cet4')
|
setCurrentDictId('cet4')
|
||||||
|
setCurrentChapter(0)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}, [currentDictId, setCurrentDictId])
|
}, [currentDictId, setCurrentChapter, setCurrentDictId])
|
||||||
|
|
||||||
const skipWord = useCallback(() => {
|
const skipWord = useCallback(() => {
|
||||||
dispatch({ type: TypingStateActionType.SKIP_WORD })
|
dispatch({ type: TypingStateActionType.SKIP_WORD })
|
||||||
@@ -89,9 +94,11 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (words !== undefined) {
|
if (words !== undefined) {
|
||||||
|
const initialIndex = isReviewMode && reviewModeInfo.reviewRecord?.index ? reviewModeInfo.reviewRecord.index : 0
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: TypingStateActionType.SETUP_CHAPTER,
|
type: TypingStateActionType.SETUP_CHAPTER,
|
||||||
payload: { words, shouldShuffle: randomConfig.isOpen },
|
payload: { words, shouldShuffle: randomConfig.isOpen, initialIndex },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TypingState, UserInputLog } from './type'
|
import type { TypingState, UserInputLog } from './type'
|
||||||
import type { WordWithIndex } from '@/typings'
|
import type { WordWithIndex } from '@/typings'
|
||||||
import type { LetterMistakes } from '@/utils/db/record'
|
import type { LetterMistakes } from '@/utils/db/record'
|
||||||
|
import '@/utils/db/review-record'
|
||||||
import { mergeLetterMistake } from '@/utils/db/utils'
|
import { mergeLetterMistake } from '@/utils/db/utils'
|
||||||
import shuffle from '@/utils/shuffle'
|
import shuffle from '@/utils/shuffle'
|
||||||
import { createContext } from 'react'
|
import { createContext } from 'react'
|
||||||
@@ -57,16 +58,22 @@ export enum TypingStateActionType {
|
|||||||
SET_IS_SAVING_RECORD = 'SET_IS_SAVING_RECORD',
|
SET_IS_SAVING_RECORD = 'SET_IS_SAVING_RECORD',
|
||||||
SET_IS_LOOP_SINGLE_WORD = 'SET_IS_LOOP_SINGLE_WORD',
|
SET_IS_LOOP_SINGLE_WORD = 'SET_IS_LOOP_SINGLE_WORD',
|
||||||
TOGGLE_IS_LOOP_SINGLE_WORD = 'TOGGLE_IS_LOOP_SINGLE_WORD',
|
TOGGLE_IS_LOOP_SINGLE_WORD = 'TOGGLE_IS_LOOP_SINGLE_WORD',
|
||||||
|
SET_REVISION_INDEX = 'SET_REVISION_INDEX',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TypingStateAction =
|
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_SKIP; payload: boolean }
|
||||||
| { type: TypingStateActionType.SET_IS_TYPING; payload: boolean }
|
| { type: TypingStateActionType.SET_IS_TYPING; payload: boolean }
|
||||||
| { type: TypingStateActionType.TOGGLE_IS_TYPING }
|
| { type: TypingStateActionType.TOGGLE_IS_TYPING }
|
||||||
| { type: TypingStateActionType.REPORT_WRONG_WORD; payload: { letterMistake: LetterMistakes } }
|
| { type: TypingStateActionType.REPORT_WRONG_WORD; payload: { letterMistake: LetterMistakes } }
|
||||||
| { type: TypingStateActionType.REPORT_CORRECT_WORD }
|
| { 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.LOOP_CURRENT_WORD }
|
||||||
| { type: TypingStateActionType.FINISH_CHAPTER }
|
| { type: TypingStateActionType.FINISH_CHAPTER }
|
||||||
| { type: TypingStateActionType.SKIP_WORD }
|
| { type: TypingStateActionType.SKIP_WORD }
|
||||||
@@ -87,6 +94,11 @@ export const typingReducer = (state: TypingState, action: TypingStateAction) =>
|
|||||||
case TypingStateActionType.SETUP_CHAPTER: {
|
case TypingStateActionType.SETUP_CHAPTER: {
|
||||||
const newState = structuredClone(initialState)
|
const newState = structuredClone(initialState)
|
||||||
const words = action.payload.shouldShuffle ? shuffle(action.payload.words) : action.payload.words
|
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.words = words
|
||||||
newState.chapterData.userInputLogs = words.map((_, index) => ({ ...structuredClone(initialUserInputLog), index }))
|
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)
|
wordLog.LetterMistakes = mergeLetterMistake(wordLog.LetterMistakes, letterMistake)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case TypingStateActionType.NEXT_WORD:
|
case TypingStateActionType.NEXT_WORD: {
|
||||||
state.chapterData.index += 1
|
state.chapterData.index += 1
|
||||||
state.chapterData.wordCount += 1
|
state.chapterData.wordCount += 1
|
||||||
state.isShowSkip = false
|
state.isShowSkip = false
|
||||||
|
|
||||||
|
if (action?.payload?.updateReviewRecord) {
|
||||||
|
action.payload.updateReviewRecord(state)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case TypingStateActionType.LOOP_CURRENT_WORD:
|
case TypingStateActionType.LOOP_CURRENT_WORD:
|
||||||
state.isShowSkip = false
|
state.isShowSkip = false
|
||||||
state.chapterData.wordCount += 1
|
state.chapterData.wordCount += 1
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import atomForConfig from './atomForConfig'
|
import atomForConfig from './atomForConfig'
|
||||||
|
import { reviewInfoAtom } from './reviewInfoAtom'
|
||||||
import { DISMISS_START_CARD_DATE_KEY, defaultFontSizeConfig } from '@/constants'
|
import { DISMISS_START_CARD_DATE_KEY, defaultFontSizeConfig } from '@/constants'
|
||||||
import { idDictionaryMap } from '@/resources/dictionary'
|
import { idDictionaryMap } from '@/resources/dictionary'
|
||||||
import { correctSoundResources, keySoundResources, wrongSoundResources } from '@/resources/soundResource'
|
import { correctSoundResources, keySoundResources, wrongSoundResources } from '@/resources/soundResource'
|
||||||
@@ -11,6 +12,7 @@ import type {
|
|||||||
WordDictationOpenBy,
|
WordDictationOpenBy,
|
||||||
WordDictationType,
|
WordDictationType,
|
||||||
} from '@/typings'
|
} from '@/typings'
|
||||||
|
import type { ReviewRecord } from '@/utils/db/record'
|
||||||
import { atom } from 'jotai'
|
import { atom } from 'jotai'
|
||||||
import { atomWithStorage } from 'jotai/utils'
|
import { atomWithStorage } from 'jotai/utils'
|
||||||
|
|
||||||
@@ -76,6 +78,12 @@ export const isShowAnswerOnHoverAtom = atomWithStorage('isShowAnswerOnHover', tr
|
|||||||
|
|
||||||
export const isTextSelectableAtom = atomWithStorage('isTextSelectable', false)
|
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', {
|
export const phoneticConfigAtom = atomForConfig('phoneticConfig', {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
type: 'us' as PhoneticType,
|
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 type { IChapterRecord, IReviewRecord, IRevisionDictRecord, IWordRecord, LetterMistakes } from './record'
|
||||||
import { ChapterRecord, WordRecord } from './record'
|
import { ChapterRecord, ReviewRecord, WordRecord } from './record'
|
||||||
import { TypingContext, TypingStateActionType } from '@/pages/Typing/store'
|
import { TypingContext, TypingStateActionType } from '@/pages/Typing/store'
|
||||||
import type { TypingState } from '@/pages/Typing/store/type'
|
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 type { Table } from 'dexie'
|
||||||
import Dexie from 'dexie'
|
import Dexie from 'dexie'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
@@ -11,6 +11,10 @@ import { useCallback, useContext } from 'react'
|
|||||||
class RecordDB extends Dexie {
|
class RecordDB extends Dexie {
|
||||||
wordRecords!: Table<IWordRecord, number>
|
wordRecords!: Table<IWordRecord, number>
|
||||||
chapterRecords!: Table<IChapterRecord, number>
|
chapterRecords!: Table<IChapterRecord, number>
|
||||||
|
reviewRecords!: Table<IReviewRecord, number>
|
||||||
|
|
||||||
|
revisionDictRecords!: Table<IRevisionDictRecord, number>
|
||||||
|
revisionWordRecords!: Table<IWordRecord, number>
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('RecordDB')
|
super('RecordDB')
|
||||||
@@ -22,6 +26,11 @@ class RecordDB extends Dexie {
|
|||||||
wordRecords: '++id,word,timeStamp,dict,chapter,wrongCount,[dict+chapter]',
|
wordRecords: '++id,word,timeStamp,dict,chapter,wrongCount,[dict+chapter]',
|
||||||
chapterRecords: '++id,timeStamp,dict,chapter,time,[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.wordRecords.mapToClass(WordRecord)
|
||||||
db.chapterRecords.mapToClass(ChapterRecord)
|
db.chapterRecords.mapToClass(ChapterRecord)
|
||||||
|
db.reviewRecords.mapToClass(ReviewRecord)
|
||||||
|
|
||||||
export function useSaveChapterRecord() {
|
export function useSaveChapterRecord() {
|
||||||
const currentChapter = useAtomValue(currentChapterAtom)
|
const currentChapter = useAtomValue(currentChapterAtom)
|
||||||
|
const isRevision = useAtomValue(isReviewModeAtom)
|
||||||
const dictID = useAtomValue(currentDictIdAtom)
|
const dictID = useAtomValue(currentDictIdAtom)
|
||||||
|
|
||||||
const saveChapterRecord = useCallback(
|
const saveChapterRecord = useCallback(
|
||||||
@@ -44,7 +55,7 @@ export function useSaveChapterRecord() {
|
|||||||
|
|
||||||
const chapterRecord = new ChapterRecord(
|
const chapterRecord = new ChapterRecord(
|
||||||
dictID,
|
dictID,
|
||||||
currentChapter,
|
isRevision ? -1 : currentChapter,
|
||||||
time,
|
time,
|
||||||
correctCount,
|
correctCount,
|
||||||
wrongCount,
|
wrongCount,
|
||||||
@@ -55,7 +66,7 @@ export function useSaveChapterRecord() {
|
|||||||
)
|
)
|
||||||
db.chapterRecords.add(chapterRecord)
|
db.chapterRecords.add(chapterRecord)
|
||||||
},
|
},
|
||||||
[currentChapter, dictID],
|
[currentChapter, dictID, isRevision],
|
||||||
)
|
)
|
||||||
|
|
||||||
return saveChapterRecord
|
return saveChapterRecord
|
||||||
@@ -67,6 +78,7 @@ export type WordKeyLogger = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSaveWordRecord() {
|
export function useSaveWordRecord() {
|
||||||
|
const isRevision = useAtomValue(isReviewModeAtom)
|
||||||
const currentChapter = useAtomValue(currentChapterAtom)
|
const currentChapter = useAtomValue(currentChapterAtom)
|
||||||
const dictID = useAtomValue(currentDictIdAtom)
|
const dictID = useAtomValue(currentDictIdAtom)
|
||||||
|
|
||||||
@@ -90,7 +102,7 @@ export function useSaveWordRecord() {
|
|||||||
timing.push(diff)
|
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
|
let dbID = -1
|
||||||
try {
|
try {
|
||||||
@@ -103,7 +115,7 @@ export function useSaveWordRecord() {
|
|||||||
dispatch({ type: TypingStateActionType.SET_IS_SAVING_RECORD, payload: false })
|
dispatch({ type: TypingStateActionType.SET_IS_SAVING_RECORD, payload: false })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentChapter, dictID, dispatch],
|
[currentChapter, dictID, dispatch, isRevision],
|
||||||
)
|
)
|
||||||
|
|
||||||
return saveWordRecord
|
return saveWordRecord
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getUTCUnixTimestamp } from '../index'
|
import { getUTCUnixTimestamp } from '../index'
|
||||||
|
import type { Word } from '@/typings'
|
||||||
|
|
||||||
export interface IWordRecord {
|
export interface IWordRecord {
|
||||||
word: string
|
word: string
|
||||||
@@ -47,7 +48,7 @@ export class WordRecord implements IWordRecord {
|
|||||||
export interface IChapterRecord {
|
export interface IChapterRecord {
|
||||||
// 正常章节为 dictKey, 其他功能则为对应的类型
|
// 正常章节为 dictKey, 其他功能则为对应的类型
|
||||||
dict: string
|
dict: string
|
||||||
// 用户可能是在 错题/其他类似组件中 进行的练习则为 null
|
// 在错题场景中为 -1
|
||||||
chapter: number | null
|
chapter: number | null
|
||||||
timeStamp: number
|
timeStamp: number
|
||||||
// 单位为 s,章节的记录没必要到毫秒级
|
// 单位为 s,章节的记录没必要到毫秒级
|
||||||
@@ -113,3 +114,72 @@ export class ChapterRecord implements IChapterRecord {
|
|||||||
return Math.round((this.correctWordIndexes.length / this.wordNumber) * 100)
|
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,
|
) / 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 = {
|
module.exports = {
|
||||||
|
darkMode: ['class'],
|
||||||
content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'],
|
content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'],
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
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: {
|
transitionDuration: {
|
||||||
0: '0ms',
|
0: '0ms',
|
||||||
},
|
},
|
||||||
@@ -55,7 +70,7 @@ module.exports = {
|
|||||||
backgroundOpacity: ['dark'],
|
backgroundOpacity: ['dark'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require('@headlessui/tailwindcss'), require('@tailwindcss/forms'), require('daisyui')],
|
plugins: [require('@headlessui/tailwindcss'), require('@tailwindcss/forms'), require('daisyui'), require('tailwindcss-animate')],
|
||||||
daisyui: {
|
daisyui: {
|
||||||
themes: [
|
themes: [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user