Merge pull request #847 from rainnoon/feat/mobile

feat/mobile
This commit is contained in:
Kai 2024-09-09 16:20:43 +08:00 committed by GitHub
commit 20ff29254b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 337 additions and 12 deletions

View File

@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@ -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%;
}
}

View File

@ -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 />)

View File

@ -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
View 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
View 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

View File

@ -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 },

View File

@ -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"