@ -19,6 +19,7 @@
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"animate.css": "^4.1.1",
|
||||
"canvas-confetti": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"classnames": "^2.3.2",
|
||||
@ -28,6 +29,7 @@
|
||||
"dexie-export-import": "^4.0.7",
|
||||
"dexie-react-hooks": "^1.1.3",
|
||||
"echarts": "^5.4.2",
|
||||
"embla-carousel-react": "^8.2.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"howler": "^2.2.3",
|
||||
"html-to-image": "^1.11.11",
|
||||
|
BIN
src/assets/mobile/carousel/directory.png
Normal file
After Width: | Height: | Size: 450 KiB |
BIN
src/assets/mobile/carousel/hot.png
Normal file
After Width: | Height: | Size: 421 KiB |
BIN
src/assets/mobile/carousel/index.png
Normal file
After Width: | Height: | Size: 352 KiB |
BIN
src/assets/mobile/carousel/interface.png
Normal file
After Width: | Height: | Size: 469 KiB |
BIN
src/assets/mobile/detail/code.png
Normal file
After Width: | Height: | Size: 77 KiB |
BIN
src/assets/mobile/detail/dictation.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
src/assets/mobile/detail/phonetic.png
Normal file
After Width: | Height: | Size: 239 KiB |
BIN
src/assets/mobile/detail/speed.png
Normal file
After Width: | Height: | Size: 92 KiB |
@ -162,3 +162,46 @@ body,
|
||||
filter: hue-rotate(-1turn);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 30em;
|
||||
} /* 调整为字符总数的一半 */
|
||||
}
|
||||
|
||||
@keyframes blink-caret {
|
||||
from,
|
||||
to {
|
||||
border-color: transparent;
|
||||
}
|
||||
50% {
|
||||
border-color: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.typewriter {
|
||||
width: 30em;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@keyframes hideCaret {
|
||||
to {
|
||||
border-right-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradientBG {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,14 @@
|
||||
import Loading from './components/Loading'
|
||||
import './index.css'
|
||||
import { ErrorBook } from './pages/ErrorBook'
|
||||
import MobilePage from './pages/Mobile'
|
||||
import TypingPage from './pages/Typing'
|
||||
import { isOpenDarkModeAtom } from '@/store'
|
||||
import 'animate.css'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import mixpanel from 'mixpanel-browser'
|
||||
import process from 'process'
|
||||
import React, { Suspense, lazy, useEffect } from 'react'
|
||||
import React, { Suspense, lazy, useEffect, useState } from 'react'
|
||||
import 'react-app-polyfill/stable'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
@ -22,24 +24,44 @@ if (process.env.NODE_ENV === 'production') {
|
||||
mixpanel.init('5474177127e4767124c123b2d7846e2a', { debug: true })
|
||||
}
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
function Root() {
|
||||
const darkMode = useAtomValue(isOpenDarkModeAtom)
|
||||
useEffect(() => {
|
||||
darkMode ? document.documentElement.classList.add('dark') : document.documentElement.classList.remove('dark')
|
||||
}, [darkMode])
|
||||
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 600)
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const isMobile = window.innerWidth <= 600
|
||||
if (!isMobile) {
|
||||
window.location.href = '/'
|
||||
}
|
||||
setIsMobile(isMobile)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename={REACT_APP_DEPLOY_ENV === 'pages' ? '/qwerty-learner' : ''}>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route index element={<TypingPage />} />
|
||||
<Route path="/gallery" element={<GalleryPage />} />
|
||||
<Route path="/analysis" element={<AnalysisPage />} />
|
||||
<Route path="/error-book" element={<ErrorBook />} />
|
||||
<Route path="/*" element={<Navigate to="/" />} />
|
||||
{isMobile ? (
|
||||
<Route path="/*" element={<Navigate to="/mobile" />} />
|
||||
) : (
|
||||
<>
|
||||
<Route index element={<TypingPage />} />
|
||||
<Route path="/gallery" element={<GalleryPage />} />
|
||||
<Route path="/analysis" element={<AnalysisPage />} />
|
||||
<Route path="/error-book" element={<ErrorBook />} />
|
||||
<Route path="/*" element={<Navigate to="/" />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="/mobile" element={<MobilePage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
@ -47,4 +69,6 @@ function Root() {
|
||||
)
|
||||
}
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
container && createRoot(container).render(<Root />)
|
||||
|
@ -78,7 +78,7 @@ export default function DictDetail({ dictionary: dict }: { dictionary: Dictionar
|
||||
<ToggleGroupItem
|
||||
value={Tab.Chapters}
|
||||
disabled={curTab === Tab.Chapters}
|
||||
className={`${curTab === Tab.Chapters ? 'bg-primary text-primary-foreground' : ''} disabled:opacity-100`}
|
||||
className={`${curTab === Tab.Chapters ? 'text-primary-foreground bg-primary' : ''} disabled:opacity-100`}
|
||||
>
|
||||
<MajesticonsPaperFoldTextLine className="mr-1.5 text-gray-500" />
|
||||
章节选择
|
||||
@ -88,7 +88,7 @@ export default function DictDetail({ dictionary: dict }: { dictionary: Dictionar
|
||||
<ToggleGroupItem
|
||||
value={Tab.Errors}
|
||||
disabled={curTab === Tab.Errors}
|
||||
className={`${curTab === Tab.Errors ? 'bg-primary text-primary-foreground' : ''} disabled:opacity-100`}
|
||||
className={`${curTab === Tab.Errors ? 'text-primary-foreground bg-primary' : ''} disabled:opacity-100`}
|
||||
>
|
||||
<IcOutlineCollectionsBookmark className="mr-1.5 text-gray-500" />
|
||||
查看错题
|
||||
@ -96,7 +96,7 @@ export default function DictDetail({ dictionary: dict }: { dictionary: Dictionar
|
||||
<ToggleGroupItem
|
||||
value={Tab.Review}
|
||||
disabled={curTab === Tab.Review}
|
||||
className={`${curTab === Tab.Review ? 'bg-primary text-primary-foreground' : ''} disabled:opacity-100`}
|
||||
className={`${curTab === Tab.Review ? 'text-primary-foreground bg-primary' : ''} disabled:opacity-100`}
|
||||
>
|
||||
<PajamasReviewList className="mr-1.5 text-gray-500" />
|
||||
错题回顾
|
||||
|
51
src/pages/Mobile/flow.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import type React from 'react'
|
||||
|
||||
const Flow: React.FC = () => {
|
||||
const waveStyle = {
|
||||
animation: 'move 3s linear infinite both',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[15rem] w-full">
|
||||
<svg className="h-full w-full rotate-180 transform blur-[2px]" viewBox="0 24 150 24" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<path id="wave" d="M-160 44c30 0 58-18 88-18s 58 18 88 18 58-18 88-18 58 18 88 18 v60h-352z" />
|
||||
</defs>
|
||||
<g>
|
||||
<use
|
||||
className="wave"
|
||||
xlinkHref="#wave"
|
||||
fill="#ced2fc"
|
||||
x="50"
|
||||
y="0"
|
||||
style={{ ...waveStyle, animationDelay: '-2s', animationDuration: '12s' }}
|
||||
/>
|
||||
<use
|
||||
className="wave"
|
||||
xlinkHref="#wave"
|
||||
fill="#a8b0f6"
|
||||
x="50"
|
||||
y="2"
|
||||
style={{ ...waveStyle, animationDelay: '-4s', animationDuration: '9s' }}
|
||||
/>
|
||||
<use
|
||||
className="wave"
|
||||
xlinkHref="#wave"
|
||||
fill="#818cf8"
|
||||
x="50"
|
||||
y="4"
|
||||
style={{ ...waveStyle, animationDelay: '-6s', animationDuration: '6s' }}
|
||||
/>
|
||||
</g>
|
||||
<style>{`
|
||||
@keyframes move {
|
||||
from { transform: translate(85px, 0%); }
|
||||
to { transform: translate(-90px, 0%); }
|
||||
}
|
||||
`}</style>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Flow
|
167
src/pages/Mobile/index.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import Flow from './flow'
|
||||
import logo from '@/assets/logo.svg'
|
||||
import directoryImg from '@/assets/mobile/carousel/directory.png'
|
||||
import hotImg from '@/assets/mobile/carousel/hot.png'
|
||||
import indexImg from '@/assets/mobile/carousel/index.png'
|
||||
import codeImg from '@/assets/mobile/detail/code.png'
|
||||
import dictationImg from '@/assets/mobile/detail/dictation.png'
|
||||
import phoneticImg from '@/assets/mobile/detail/phonetic.png'
|
||||
import speedImg from '@/assets/mobile/detail/speed.png'
|
||||
import type React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const detail = [
|
||||
{
|
||||
title: '音标显示与发音功能',
|
||||
description: '帮助用户同时记忆单词的读音与音标',
|
||||
img: phoneticImg,
|
||||
},
|
||||
{
|
||||
title: '默写模式',
|
||||
description: '每章结束后可选择默写,巩固所学单词',
|
||||
img: dictationImg,
|
||||
},
|
||||
{
|
||||
title: '实时反馈',
|
||||
description: '显示输入速度和正确率,量化技能提升',
|
||||
img: speedImg,
|
||||
},
|
||||
{
|
||||
title: '为程序员定制',
|
||||
description: '内置编程相关词库,提高工作效率',
|
||||
img: codeImg,
|
||||
},
|
||||
]
|
||||
|
||||
const MobilePage: React.FC = () => {
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
const totalSlides = 3 // 轮播图的总数量
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentSlide((prevSlide) => (prevSlide + 1) % totalSlides)
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const container = containerRef.current
|
||||
const slideWidth = container.offsetWidth
|
||||
|
||||
if (currentSlide === 0) {
|
||||
container.style.transition = 'none'
|
||||
container.style.transform = `translateX(0)`
|
||||
setTimeout(() => {
|
||||
container.style.transition = 'transform 0.5s ease'
|
||||
container.style.transform = `translateX(-${slideWidth}px)`
|
||||
}, 50)
|
||||
} else {
|
||||
container.style.transform = `translateX(-${currentSlide * slideWidth}px)`
|
||||
}
|
||||
}
|
||||
}, [currentSlide])
|
||||
|
||||
return (
|
||||
<div className="flex w-screen flex-col">
|
||||
<section className="flex items-center justify-center py-2 shadow-md">
|
||||
<img src={logo} className="mr-3 h-16 w-16" alt="Qwerty Learner Logo" />
|
||||
<h1 className="text-2xl font-bold text-primary">Qwerty Learner</h1>
|
||||
</section>
|
||||
|
||||
<section className="relative">
|
||||
<Flow />
|
||||
<div className="absolute top-10 flex w-full flex-col items-center justify-center">
|
||||
<h1 className="animate__animated animate__zoomIn bg-gradient-to-b from-white to-[#dee0ff] bg-clip-text text-3xl font-bold text-transparent">
|
||||
为键盘工作者设计的软件
|
||||
</h1>
|
||||
<h2
|
||||
className="animate__animated animate__zoomIn mt-5 text-sm font-bold text-white"
|
||||
style={{
|
||||
textShadow: '1px 1px 2px #9c9ea3',
|
||||
}}
|
||||
>
|
||||
简化英语学习与打字技能提升过程,快速建立正确的肌肉记忆
|
||||
</h2>
|
||||
<h2
|
||||
className="typewriter !mt-3 text-xs font-bold text-white"
|
||||
style={{
|
||||
textShadow: '1px 1px 2px #9c9ea3',
|
||||
overflow: 'hidden',
|
||||
borderRight: '0.15em solid #f0f0f0',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'inline-block',
|
||||
animation: 'typing 3s steps(50), blink-caret 0.75s step-end 4, hideCaret 0s 3s forwards',
|
||||
}}
|
||||
>
|
||||
单词记忆与键盘输入相结合、音标发音与默写模式、多样化词库选择
|
||||
</h2>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 px-10">
|
||||
<div
|
||||
style={{
|
||||
boxShadow: '0px 0px 12px rgba(0,0,0,0.12), 0px 8px 15px -3px rgba(0,0,0,0.1)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
transition: 'transform 0.5s ease',
|
||||
}}
|
||||
>
|
||||
<img src={hotImg} alt="" style={{ width: '100%', flexShrink: 0 }} />
|
||||
<img src={directoryImg} alt="" style={{ width: '100%', flexShrink: 0 }} />
|
||||
<img src={indexImg} alt="" style={{ width: '100%', flexShrink: 0 }} />
|
||||
<img src={hotImg} alt="" style={{ width: '100%', flexShrink: 0 }} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-10 px-5">
|
||||
<h1 className="text-center text-3xl font-bold text-primary">助力你快速掌握单词</h1>
|
||||
|
||||
<div className="mt-10">
|
||||
{detail.map((item, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`my-4 rounded-2xl px-6 py-5 ${activeIndex === index ? 'bg-[#e7e7e7]' : 'hover:bg-[#e7e7e7]'}`}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
>
|
||||
<h1>{item.title}</h1>
|
||||
<h2 className="text-gray-500">{item.description}</h2>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-20 flex h-[10rem] justify-center bg-white">
|
||||
<img className="w-full object-contain" src={detail[activeIndex].img} alt="" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="mt-10 flex h-[20rem] w-full flex-col items-center"
|
||||
style={{
|
||||
background: 'linear-gradient(-45deg, #6366f1, #6366f1, #6366f1, #b600ff)',
|
||||
backgroundSize: '600% 600%',
|
||||
animation: 'gradientBG 4s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
<h1 className="mt-10 text-3xl font-bold text-white">轻松上手,简单好用</h1>
|
||||
<h2 className="mt-10 px-10 text-white">为键盘工作者设计的单词与肌肉记忆锻炼软件</h2>
|
||||
<div className="mt-10 rounded-2xl bg-white px-6 py-3 font-bold text-primary">请使用桌面端浏览器访问本网站,即刻体验!</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobilePage
|
@ -4,6 +4,9 @@ module.exports = {
|
||||
content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#6366f1',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: 0 },
|
||||
|
37
yarn.lock
@ -1550,6 +1550,11 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-compose-refs@1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz#656432461fc8283d7b591dcf0d79152fae9ecc74"
|
||||
integrity sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==
|
||||
|
||||
"@radix-ui/react-context@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.0.0.tgz"
|
||||
@ -1803,7 +1808,7 @@
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "1.0.0"
|
||||
|
||||
"@radix-ui/react-slot@1.0.2", "@radix-ui/react-slot@^1.0.2":
|
||||
"@radix-ui/react-slot@1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
|
||||
integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==
|
||||
@ -1811,6 +1816,13 @@
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
|
||||
"@radix-ui/react-slot@^1.0.2":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz#7c5e48c36ef5496d97b08f1357bb26ed7c714b84"
|
||||
integrity sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.0"
|
||||
|
||||
"@radix-ui/react-tabs@^1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2"
|
||||
@ -2414,6 +2426,11 @@ ajv@^6.10.0, ajv@^6.12.4:
|
||||
json-schema-traverse "^0.4.1"
|
||||
uri-js "^4.2.2"
|
||||
|
||||
animate.css@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz#614ec5a81131d7e4dc362a58143f7406abd68075"
|
||||
integrity sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==
|
||||
|
||||
ansi-escapes@^4.3.0:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz"
|
||||
@ -3201,6 +3218,24 @@ electron-to-chromium@^1.4.284:
|
||||
resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.4.402.tgz"
|
||||
integrity sha512-gWYvJSkohOiBE6ecVYXkrDgNaUjo47QEKK0kQzmWyhkH+yoYiG44bwuicTGNSIQRG3WDMsWVZJLRnJnLNkbWvA==
|
||||
|
||||
embla-carousel-react@^8.2.1:
|
||||
version "8.2.1"
|
||||
resolved "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.2.1.tgz#0202bd6b04f749cf9a56ad86f4549f75b7bb43bb"
|
||||
integrity sha512-YKtARk101mp00Zb6UAFkkvK+5XRo92LAtO9xLFeDnQ/XU9DqFhKnRy1CedRRj0/RSk6MTFDx3MqOQue3gJj9DA==
|
||||
dependencies:
|
||||
embla-carousel "8.2.1"
|
||||
embla-carousel-reactive-utils "8.2.1"
|
||||
|
||||
embla-carousel-reactive-utils@8.2.1:
|
||||
version "8.2.1"
|
||||
resolved "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.2.1.tgz#c62fdb6f77c6dcd68bcdaba62523acacb8e633fc"
|
||||
integrity sha512-LXMVOOyv09ZKRxRQXYMX1FpVGcypsuxdcidNcNlBQUN2mK7hkmjVFQwwhfnnY39KMi88XYnYPBgMxfTe0vxSrA==
|
||||
|
||||
embla-carousel@8.2.1:
|
||||
version "8.2.1"
|
||||
resolved "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.2.1.tgz#d156be420f47d9f61f444eb789c9901cce43f7f8"
|
||||
integrity sha512-9mTDtyMZJhFuuW5pixhTT4iLiJB1l3dH3IpXUKCsgLlRlHCiySf/wLKy5xIAzmxIsokcQ50xea8wi7BCt0+Rxg==
|
||||
|
||||
emoji-regex@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz"
|
||||
|