first commit
This commit is contained in:
commit
60ee9dffd5
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.pnpm-store
|
||||
.trae
|
||||
57
README.md
Normal file
57
README.md
Normal file
@ -0,0 +1,57 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
24
index.html
Normal file
24
index.html
Normal file
@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Trae Project</title>
|
||||
<script type="module">
|
||||
if (import.meta.hot?.on) {
|
||||
import.meta.hot.on('vite:error', (error) => {
|
||||
if (error.err) {
|
||||
console.error(
|
||||
[error.err.message, error.err.frame].filter(Boolean).join('\n'),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
package.json
Normal file
43
package.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "workspace",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"check": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^0.511.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-plugin-react-dev-locator": "^1.0.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-trae-solo-badge": "^1.0.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
2776
pnpm-lock.yaml
generated
Normal file
2776
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
postcss.config.js
Normal file
10
postcss.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
/** WARNING: DON'T EDIT THIS FILE */
|
||||
/** WARNING: DON'T EDIT THIS FILE */
|
||||
/** WARNING: DON'T EDIT THIS FILE */
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" fill="#0A0B0D"/>
|
||||
<path d="M26.6677 23.7149H8.38057V20.6496H5.33301V8.38159H26.6677V23.7149ZM8.38057 20.6496H23.6201V11.4482H8.38057V20.6496ZM16.0011 16.0021L13.8461 18.1705L11.6913 16.0021L13.8461 13.8337L16.0011 16.0021ZM22.0963 16.0008L19.9414 18.1691L17.7865 16.0008L19.9414 13.8324L22.0963 16.0008Z" fill="#32F08C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 453 B |
34
src/App.tsx
Normal file
34
src/App.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { MainLayout } from './layouts/MainLayout';
|
||||
import { Home } from './pages/Home';
|
||||
import { Login } from './pages/Login';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Courses } from './pages/Courses';
|
||||
import { CourseDetail } from './pages/CourseDetail';
|
||||
import { Practice } from './pages/Practice';
|
||||
import { Community } from './pages/Community';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
{/* Protected Routes (mocked) */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/courses" element={<Courses />} />
|
||||
<Route path="/courses/:id" element={<CourseDetail />} />
|
||||
<Route path="/community" element={<Community />} />
|
||||
</Route>
|
||||
|
||||
{/* Practice Routes without standard layout */}
|
||||
<Route path="/practice/:type" element={<Practice />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
8
src/components/Empty.tsx
Normal file
8
src/components/Empty.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Empty component
|
||||
export default function Empty() {
|
||||
return (
|
||||
<div className={cn('flex h-full items-center justify-center')}>Empty</div>
|
||||
)
|
||||
}
|
||||
111
src/data/mock.ts
Normal file
111
src/data/mock.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { User, Course, Badge, Post, PracticeQuestion } from '../types';
|
||||
|
||||
export const mockUser: User = {
|
||||
id: 'user-1',
|
||||
name: '李小明',
|
||||
email: 'liming@example.com',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=liming',
|
||||
currentLanguage: 'en',
|
||||
level: 'B1',
|
||||
totalStudyTime: 1250,
|
||||
streakDays: 14,
|
||||
};
|
||||
|
||||
export const mockCourses: Course[] = [
|
||||
{
|
||||
id: 'course-en-1',
|
||||
title: '职场英语进阶',
|
||||
description: '掌握商务会议、邮件撰写与跨文化沟通技巧。',
|
||||
language: 'en',
|
||||
level: 'B2',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1516321497487-e288fb19713f?auto=format&fit=crop&w=800&q=80',
|
||||
lessonsCount: 24,
|
||||
completedLessons: 10,
|
||||
},
|
||||
{
|
||||
id: 'course-ja-1',
|
||||
title: '零基础新标日',
|
||||
description: '从五十音图开始,带你沉浸式体验日式日常对话。',
|
||||
language: 'ja',
|
||||
level: 'A1',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1524413840807-0c3cb6fa808d?auto=format&fit=crop&w=800&q=80',
|
||||
lessonsCount: 40,
|
||||
completedLessons: 0,
|
||||
},
|
||||
{
|
||||
id: 'course-ko-1',
|
||||
title: '看韩剧学韩语',
|
||||
description: '通过经典热门韩剧片段,轻松学习地道韩语表达。',
|
||||
language: 'ko',
|
||||
level: 'A2',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1580214157582-748ce0db5fb1?auto=format&fit=crop&w=800&q=80',
|
||||
lessonsCount: 15,
|
||||
completedLessons: 15,
|
||||
}
|
||||
];
|
||||
|
||||
export const mockBadges: Badge[] = [
|
||||
{
|
||||
id: 'badge-1',
|
||||
name: '初出茅庐',
|
||||
description: '完成第一节语言课程。',
|
||||
iconUrl: '🌟',
|
||||
earnedAt: '2023-10-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'badge-2',
|
||||
name: '坚持不懈',
|
||||
description: '连续打卡学习 7 天。',
|
||||
iconUrl: '🔥',
|
||||
earnedAt: '2023-10-08T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'badge-3',
|
||||
name: '词汇达人',
|
||||
description: '掌握 500 个目标语言单词。',
|
||||
iconUrl: '🧠',
|
||||
}
|
||||
];
|
||||
|
||||
export const mockPosts: Post[] = [
|
||||
{
|
||||
id: 'post-1',
|
||||
authorId: 'user-2',
|
||||
authorName: '樱花雪',
|
||||
authorAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=sakura',
|
||||
content: '今天终于把五十音图全背下来了!给大家分享一个记忆小口诀:...',
|
||||
likes: 42,
|
||||
commentsCount: 8,
|
||||
createdAt: '2023-10-24T08:30:00Z',
|
||||
language: 'ja',
|
||||
},
|
||||
{
|
||||
id: 'post-2',
|
||||
authorId: 'user-3',
|
||||
authorName: 'Jacky',
|
||||
authorAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=jacky',
|
||||
content: 'Does anyone want to practice English speaking together? I am looking for a language partner. Level B2.',
|
||||
likes: 15,
|
||||
commentsCount: 22,
|
||||
createdAt: '2023-10-23T14:20:00Z',
|
||||
language: 'en',
|
||||
}
|
||||
];
|
||||
|
||||
export const mockVocabQuestions: PracticeQuestion[] = [
|
||||
{
|
||||
id: 'q-1',
|
||||
type: 'vocab',
|
||||
question: 'Apple',
|
||||
options: ['苹果', '香蕉', '橘子', '西瓜'],
|
||||
correctAnswer: '苹果',
|
||||
audioUrl: 'https://example.com/audio/apple.mp3'
|
||||
},
|
||||
{
|
||||
id: 'q-2',
|
||||
type: 'vocab',
|
||||
question: 'Serendipity',
|
||||
options: ['意外发现珍奇事物的本领', '平静的', '愤怒的', '忧郁的'],
|
||||
correctAnswer: '意外发现珍奇事物的本领'
|
||||
}
|
||||
];
|
||||
29
src/hooks/useTheme.ts
Normal file
29
src/hooks/useTheme.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||
if (savedTheme) {
|
||||
return savedTheme;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return {
|
||||
theme,
|
||||
toggleTheme,
|
||||
isDark: theme === 'dark'
|
||||
};
|
||||
}
|
||||
33
src/index.css
Normal file
33
src/index.css
Normal file
@ -0,0 +1,33 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob {
|
||||
0% {
|
||||
transform: translate(0px, 0px) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -50px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0px, 0px) scale(1);
|
||||
}
|
||||
}
|
||||
100
src/layouts/MainLayout.tsx
Normal file
100
src/layouts/MainLayout.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { Home, BookOpen, Gamepad2, MessageCircle, LogOut } from 'lucide-react';
|
||||
import { useStore } from '../store';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/dashboard', label: '仪表盘', icon: Home },
|
||||
{ path: '/courses', label: '课程', icon: BookOpen },
|
||||
{ path: '/practice/vocab', label: '练习', icon: Gamepad2 },
|
||||
{ path: '/community', label: '社区', icon: MessageCircle },
|
||||
];
|
||||
|
||||
export const MainLayout: React.FC = () => {
|
||||
const { user, logout } = useStore();
|
||||
const location = useLocation();
|
||||
|
||||
if (!user) return <Outlet />;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-50 text-slate-900">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-white border-r border-slate-200 flex flex-col hidden md:flex">
|
||||
<div className="p-6 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-600 text-white flex items-center justify-center font-bold text-xl">L</div>
|
||||
<span className="font-bold text-xl tracking-tight">LinguaJourney</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-4 space-y-2 mt-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-600 font-medium'
|
||||
: 'text-slate-500 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Icon size={20} className={isActive ? 'text-blue-600' : 'text-slate-400'} />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-slate-100">
|
||||
<div className="flex items-center gap-3 p-2">
|
||||
<img src={user.avatar} alt="avatar" className="w-10 h-10 rounded-full bg-slate-200" />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="text-sm font-medium truncate">{user.name}</p>
|
||||
<p className="text-xs text-slate-400 truncate">{user.level} · {user.currentLanguage.toUpperCase()}</p>
|
||||
</div>
|
||||
<button onClick={logout} className="p-2 text-slate-400 hover:text-red-500 transition-colors" title="退出登录">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-y-auto relative">
|
||||
{/* Mobile Header */}
|
||||
<header className="md:hidden flex items-center justify-between p-4 bg-white border-b border-slate-200 sticky top-0 z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-600 text-white flex items-center justify-center font-bold text-xl">L</div>
|
||||
<span className="font-bold text-lg">LinguaJourney</span>
|
||||
</div>
|
||||
<img src={user.avatar} alt="avatar" className="w-8 h-8 rounded-full" />
|
||||
</header>
|
||||
|
||||
<div className="p-4 md:p-8 max-w-6xl mx-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile Nav Bottom */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 flex justify-around items-center p-2 z-10 pb-safe">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex flex-col items-center p-2 ${
|
||||
isActive ? 'text-blue-600' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<Icon size={20} className="mb-1" />
|
||||
<span className="text-[10px] font-medium">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
164
src/pages/Community.tsx
Normal file
164
src/pages/Community.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useStore } from '../store';
|
||||
import { motion } from 'framer-motion';
|
||||
import { mockPosts, mockBadges } from '../data/mock';
|
||||
import { MessageCircle, Heart, Share2, Award, Search, PenSquare } from 'lucide-react';
|
||||
|
||||
export const Community: React.FC = () => {
|
||||
const { user } = useStore();
|
||||
const [activeTab, setActiveTab] = useState<'feed' | 'badges'>('feed');
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-20 md:pb-0 animate-in fade-in duration-500">
|
||||
<div className="flex flex-col md:flex-row justify-between md:items-end gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">学习社区</h1>
|
||||
<p className="text-slate-500 mt-1">与全球学习者分享你的进展</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-sm transition-all flex items-center gap-2">
|
||||
<PenSquare size={18} />
|
||||
发动态
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-4 border-b border-slate-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('feed')}
|
||||
className={`pb-4 px-2 font-bold transition-colors relative ${activeTab === 'feed' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-900'}`}
|
||||
>
|
||||
动态广场
|
||||
{activeTab === 'feed' && (
|
||||
<motion.div layoutId="underline" className="absolute bottom-0 left-0 right-0 h-1 bg-blue-600 rounded-t-full" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('badges')}
|
||||
className={`pb-4 px-2 font-bold transition-colors relative ${activeTab === 'badges' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-900'}`}
|
||||
>
|
||||
我的徽章
|
||||
{activeTab === 'badges' && (
|
||||
<motion.div layoutId="underline" className="absolute bottom-0 left-0 right-0 h-1 bg-blue-600 rounded-t-full" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{activeTab === 'feed' ? (
|
||||
<div className="space-y-6">
|
||||
{mockPosts.map((post, i) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
key={post.id}
|
||||
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<img src={post.authorAvatar} alt={post.authorName} className="w-12 h-12 rounded-full bg-slate-100" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-bold text-slate-900">{post.authorName}</h4>
|
||||
<span className="text-xs px-2 py-0.5 bg-slate-100 text-slate-600 rounded uppercase font-bold">{post.language}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{new Date(post.createdAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-700 leading-relaxed mb-6">{post.content}</p>
|
||||
|
||||
<div className="flex items-center gap-6 pt-4 border-t border-slate-100 text-slate-500">
|
||||
<button className="flex items-center gap-2 hover:text-red-500 transition-colors group">
|
||||
<Heart size={18} className="group-hover:fill-current" />
|
||||
<span className="text-sm font-medium">{post.likes}</span>
|
||||
</button>
|
||||
<button className="flex items-center gap-2 hover:text-blue-500 transition-colors">
|
||||
<MessageCircle size={18} />
|
||||
<span className="text-sm font-medium">{post.commentsCount}</span>
|
||||
</button>
|
||||
<button className="flex items-center gap-2 hover:text-green-500 transition-colors ml-auto">
|
||||
<Share2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{mockBadges.map((badge, i) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
key={badge.id}
|
||||
className={`p-6 rounded-2xl border ${badge.earnedAt ? 'bg-gradient-to-br from-amber-50 to-orange-50 border-amber-200' : 'bg-slate-50 border-slate-200 grayscale opacity-60'} text-center flex flex-col items-center justify-center`}
|
||||
>
|
||||
<div className="w-16 h-16 bg-white rounded-full flex items-center justify-center text-3xl shadow-sm mb-4">
|
||||
{badge.iconUrl}
|
||||
</div>
|
||||
<h4 className="font-bold text-slate-900 mb-1">{badge.name}</h4>
|
||||
<p className="text-xs text-slate-500 mb-4">{badge.description}</p>
|
||||
{badge.earnedAt ? (
|
||||
<span className="text-xs font-bold text-amber-600 bg-amber-100 px-3 py-1 rounded-full">
|
||||
已获得
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-bold text-slate-500 bg-slate-200 px-3 py-1 rounded-full">
|
||||
未解锁
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Award className="text-amber-500" size={20} />
|
||||
本周排行榜
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ name: '樱花雪', score: 3250, lang: 'ja' },
|
||||
{ name: 'Alex', score: 2840, lang: 'en' },
|
||||
{ name: 'Kim', score: 2100, lang: 'ko' },
|
||||
{ name: user?.name, score: 1250, lang: user?.currentLanguage, isMe: true },
|
||||
].sort((a, b) => b.score - a.score).map((u, i) => (
|
||||
<div key={i} className={`flex items-center gap-3 p-3 rounded-xl ${u.isMe ? 'bg-blue-50 border border-blue-100' : ''}`}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${
|
||||
i === 0 ? 'bg-amber-100 text-amber-600' :
|
||||
i === 1 ? 'bg-slate-200 text-slate-600' :
|
||||
i === 2 ? 'bg-orange-100 text-orange-600' :
|
||||
'bg-slate-100 text-slate-400'
|
||||
}`}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-slate-800 text-sm">
|
||||
{u.name} {u.isMe && <span className="text-xs text-blue-600 font-normal ml-1">(我)</span>}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-blue-600 text-sm">{u.score}</p>
|
||||
<p className="text-[10px] text-slate-400 uppercase">{u.lang}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
134
src/pages/CourseDetail.tsx
Normal file
134
src/pages/CourseDetail.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useStore } from '../store';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, PlayCircle, FileText, Mic, Headphones } from 'lucide-react';
|
||||
|
||||
export const CourseDetail: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
const { courses } = useStore();
|
||||
const navigate = useNavigate();
|
||||
const course = courses.find(c => c.id === id);
|
||||
|
||||
if (!course) {
|
||||
return <div className="text-center py-20 text-slate-500">课程未找到</div>;
|
||||
}
|
||||
|
||||
const practiceModules = [
|
||||
{ type: 'vocab', label: '词汇记忆', icon: FileText, color: 'bg-blue-100 text-blue-600', route: '/practice/vocab' },
|
||||
{ type: 'grammar', label: '语法练习', icon: FileText, color: 'bg-purple-100 text-purple-600', route: '/practice/grammar' },
|
||||
{ type: 'speaking', label: '口语跟读', icon: Mic, color: 'bg-green-100 text-green-600', route: '/practice/speaking' },
|
||||
{ type: 'listening', label: '听力训练', icon: Headphones, color: 'bg-orange-100 text-orange-600', route: '/practice/listening' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500 pb-20 md:pb-0">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-2 text-slate-500 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span className="font-medium">返回课程列表</span>
|
||||
</button>
|
||||
|
||||
{/* Hero */}
|
||||
<div className="relative rounded-3xl overflow-hidden shadow-xl border border-slate-200">
|
||||
<div className="h-64 md:h-96 relative">
|
||||
<img src={course.thumbnail} alt={course.title} className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900 via-slate-900/60 to-transparent"></div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-8 text-white">
|
||||
<div className="flex gap-3 mb-4">
|
||||
<span className="px-3 py-1 bg-white/20 backdrop-blur-md rounded shadow-sm uppercase font-bold text-sm tracking-wider">
|
||||
{course.language}
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-blue-600 rounded shadow-sm font-bold text-sm">
|
||||
{course.level} 等级
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold mb-4">{course.title}</h1>
|
||||
<p className="text-slate-300 text-lg max-w-2xl">{course.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="md:col-span-2 space-y-8">
|
||||
<h2 className="text-2xl font-bold text-slate-900">选择练习模块</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{practiceModules.map((mod, i) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
key={mod.type}
|
||||
onClick={() => navigate(mod.route)}
|
||||
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-lg transition-all cursor-pointer group flex items-center gap-4"
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${mod.color} shadow-inner`}>
|
||||
<mod.icon size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-900 group-hover:text-blue-600 transition-colors">{mod.label}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">进入互动练习</p>
|
||||
</div>
|
||||
<PlayCircle className="ml-auto text-slate-300 group-hover:text-blue-600 transition-colors" size={24} />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Lessons List (Mock) */}
|
||||
<div className="mt-12 space-y-4">
|
||||
<h2 className="text-2xl font-bold text-slate-900">课程大纲</h2>
|
||||
<div className="bg-white rounded-2xl border border-slate-200 divide-y divide-slate-100 overflow-hidden shadow-sm">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="p-5 hover:bg-slate-50 transition-colors flex items-center gap-4 cursor-pointer">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold shadow-inner ${i < course.completedLessons ? 'bg-green-100 text-green-600' : 'bg-slate-100 text-slate-400'}`}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-bold ${i < course.completedLessons ? 'text-slate-900' : 'text-slate-700'}`}>
|
||||
Unit {i + 1}: {['基础问候', '自我介绍', '在餐厅点餐', '问路与交通', '购物与讨价还价'][i]}
|
||||
</h4>
|
||||
<p className="text-sm text-slate-500 mt-1">包含词汇、语法和口语训练</p>
|
||||
</div>
|
||||
{i < course.completedLessons && (
|
||||
<span className="text-xs font-bold text-green-600 bg-green-100 px-2 py-1 rounded">已完成</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Info */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
|
||||
<h3 className="font-bold text-slate-900 mb-4">课程信息</h3>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex justify-between items-center text-sm">
|
||||
<span className="text-slate-500">总课时</span>
|
||||
<span className="font-bold text-slate-900">{course.lessonsCount} 节</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center text-sm">
|
||||
<span className="text-slate-500">已完成</span>
|
||||
<span className="font-bold text-blue-600">{course.completedLessons} 节</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center text-sm">
|
||||
<span className="text-slate-500">预计学习时长</span>
|
||||
<span className="font-bold text-slate-900">{course.lessonsCount * 45} 分钟</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-slate-100">
|
||||
<button className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-md hover:shadow-lg transition-all">
|
||||
继续学习 Unit {course.completedLessons + 1}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
101
src/pages/Courses.tsx
Normal file
101
src/pages/Courses.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useStore } from '../store';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Search, Filter, BookOpen } from 'lucide-react';
|
||||
import { Language } from '../types';
|
||||
|
||||
export const Courses: React.FC = () => {
|
||||
const { courses, user } = useStore();
|
||||
const navigate = useNavigate();
|
||||
const [filterLang, setFilterLang] = useState<Language | 'all'>('all');
|
||||
|
||||
const filteredCourses = filterLang === 'all' ? courses : courses.filter(c => c.language === filterLang);
|
||||
|
||||
const languages = [
|
||||
{ code: 'all', label: '全部语言' },
|
||||
{ code: 'en', label: '英语' },
|
||||
{ code: 'ja', label: '日语' },
|
||||
{ code: 'ko', label: '韩语' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-20 md:pb-0">
|
||||
<div className="flex flex-col md:flex-row justify-between md:items-end gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">探索课程</h1>
|
||||
<p className="text-slate-500 mt-1">找到适合你的学习内容</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索课程..."
|
||||
className="pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none text-sm w-full md:w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex overflow-x-auto pb-2 gap-2 hide-scrollbar">
|
||||
{languages.map(lang => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => setFilterLang(lang.code as any)}
|
||||
className={`px-5 py-2 rounded-full whitespace-nowrap text-sm font-medium transition-colors ${
|
||||
filterLang === lang.code
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Course Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredCourses.map((course, i) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
key={course.id}
|
||||
onClick={() => navigate(`/courses/${course.id}`)}
|
||||
className="bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-sm hover:shadow-xl transition-all cursor-pointer group flex flex-col"
|
||||
>
|
||||
<div className="h-48 overflow-hidden relative">
|
||||
<img src={course.thumbnail} alt={course.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||
<div className="absolute top-3 left-3 flex gap-2">
|
||||
<span className="px-2 py-1 bg-white/90 backdrop-blur text-slate-900 text-xs font-bold rounded shadow-sm uppercase">
|
||||
{course.language}
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-blue-600 text-white text-xs font-bold rounded shadow-sm">
|
||||
{course.level}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 flex-1 flex flex-col">
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-2">{course.title}</h3>
|
||||
<p className="text-slate-500 text-sm mb-4 line-clamp-2 flex-1">{course.description}</p>
|
||||
|
||||
<div className="flex items-center justify-between text-sm pt-4 border-t border-slate-100">
|
||||
<div className="flex items-center gap-1.5 text-slate-500">
|
||||
<BookOpen size={16} />
|
||||
<span>{course.lessonsCount} 课时</span>
|
||||
</div>
|
||||
{user?.currentLanguage === course.language && course.completedLessons > 0 && (
|
||||
<span className="text-blue-600 font-bold">
|
||||
已学 {Math.round((course.completedLessons / course.lessonsCount) * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
137
src/pages/Dashboard.tsx
Normal file
137
src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { useStore } from '../store';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Award, BookOpen, Flame, Clock } from 'lucide-react';
|
||||
import { mockBadges } from '../data/mock';
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const { user, courses } = useStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentCourse = courses.find(c => c.language === user.currentLanguage);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500 pb-20 md:pb-0">
|
||||
{/* Welcome Header */}
|
||||
<header className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">
|
||||
你好, {user.name} 👋
|
||||
</h1>
|
||||
<p className="text-slate-500 mt-1">今天是你学习 {user.currentLanguage.toUpperCase()} 的第 {user.streakDays} 天,继续保持!</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '连续打卡', value: `${user.streakDays} 天`, icon: Flame, color: 'text-orange-500', bg: 'bg-orange-50' },
|
||||
{ label: '学习时长', value: `${(user.totalStudyTime / 60).toFixed(1)} 小时`, icon: Clock, color: 'text-blue-500', bg: 'bg-blue-50' },
|
||||
{ label: '当前等级', value: user.level, icon: Award, color: 'text-purple-500', bg: 'bg-purple-50' },
|
||||
{ label: '在学课程', value: currentCourse ? 1 : 0, icon: BookOpen, color: 'text-green-500', bg: 'bg-green-50' },
|
||||
].map((stat, i) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
key={i}
|
||||
className="p-5 bg-white rounded-2xl border border-slate-100 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${stat.bg} ${stat.color} mb-3`}>
|
||||
<stat.icon size={20} />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-800">{stat.value}</p>
|
||||
<p className="text-sm text-slate-500 font-medium">{stat.label}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
|
||||
{/* Recommended Path */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-slate-900">推荐学习路径</h2>
|
||||
</div>
|
||||
|
||||
{currentCourse ? (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
className="bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-sm cursor-pointer group"
|
||||
onClick={() => navigate(`/courses/${currentCourse.id}`)}
|
||||
>
|
||||
<div className="h-48 overflow-hidden relative">
|
||||
<img src={currentCourse.thumbnail} alt={currentCourse.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
<div className="absolute bottom-4 left-4 right-4">
|
||||
<span className="px-2 py-1 bg-white/20 backdrop-blur-md rounded text-white text-xs font-bold uppercase tracking-wider mb-2 inline-block">
|
||||
{currentCourse.language} • {currentCourse.level}
|
||||
</span>
|
||||
<h3 className="text-2xl font-bold text-white">{currentCourse.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-slate-500 font-medium">学习进度</span>
|
||||
<span className="text-blue-600 font-bold">{Math.round((currentCourse.completedLessons / currentCourse.lessonsCount) * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2.5 mb-4 overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(currentCourse.completedLessons / currentCourse.lessonsCount) * 100}%` }}
|
||||
transition={{ duration: 1, delay: 0.5 }}
|
||||
className="bg-blue-600 h-2.5 rounded-full"
|
||||
></motion.div>
|
||||
</div>
|
||||
<button className="w-full py-3 bg-slate-900 hover:bg-blue-600 text-white rounded-xl font-medium transition-colors">
|
||||
继续学习
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="p-8 bg-slate-50 rounded-2xl border border-slate-200 border-dashed text-center">
|
||||
<p className="text-slate-500 mb-4">你还没有开始该语言的课程</p>
|
||||
<button
|
||||
onClick={() => navigate('/courses')}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
浏览课程
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Achievements sidebar */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-slate-900">最近成就</h2>
|
||||
<button onClick={() => navigate('/community')} className="text-sm text-blue-600 font-medium hover:underline">查看全部</button>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 p-5 shadow-sm">
|
||||
<div className="space-y-4">
|
||||
{mockBadges.slice(0, 3).map((badge) => (
|
||||
<div key={badge.id} className="flex items-center gap-4 p-3 rounded-xl hover:bg-slate-50 transition-colors">
|
||||
<div className="w-12 h-12 flex items-center justify-center bg-amber-100 rounded-full text-2xl shadow-inner">
|
||||
{badge.iconUrl}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-800">{badge.name}</h4>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{badge.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
113
src/pages/Home.tsx
Normal file
113
src/pages/Home.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Globe2, Sparkles, Zap, ArrowRight } from 'lucide-react';
|
||||
|
||||
export const Home: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 overflow-hidden font-sans">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-md border-b border-slate-200">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center text-white font-bold text-xl shadow-lg">L</div>
|
||||
<span className="font-extrabold text-2xl tracking-tight text-slate-900">LinguaJourney</span>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="px-5 py-2.5 text-slate-600 font-bold hover:text-blue-600 transition-colors"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="px-6 py-2.5 bg-slate-900 hover:bg-blue-600 text-white font-bold rounded-full shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
免费注册
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<main className="pt-32 pb-20 px-6 max-w-7xl mx-auto relative">
|
||||
{/* Background Gradients */}
|
||||
<div className="absolute top-20 left-0 w-72 h-72 bg-blue-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
|
||||
<div className="absolute top-40 right-20 w-72 h-72 bg-purple-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-16 relative z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="md:w-1/2 space-y-8 text-center md:text-left"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-blue-100 text-blue-700 font-bold text-sm">
|
||||
<Sparkles size={16} />
|
||||
<span>开启你的多语种之旅</span>
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-7xl font-extrabold text-slate-900 leading-tight tracking-tighter">
|
||||
沉浸式学习,<br/>
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600">高效掌握</span>新语言
|
||||
</h1>
|
||||
<p className="text-xl text-slate-500 leading-relaxed max-w-lg mx-auto md:mx-0">
|
||||
提供英语、日语、韩语等主流语言的个性化课程。通过互动式练习、场景模拟和成就激励,让你轻松开口说外语。
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center md:justify-start">
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="px-8 py-4 bg-blue-600 hover:bg-blue-700 text-white text-lg font-bold rounded-full shadow-xl hover:shadow-2xl transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
开始学习 <ArrowRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="md:w-1/2 relative"
|
||||
>
|
||||
<div className="relative rounded-3xl overflow-hidden shadow-2xl border-4 border-white transform rotate-3 hover:rotate-0 transition-transform duration-500">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1522202176988-66273c2fd55f?auto=format&fit=crop&w=1200&q=80"
|
||||
alt="Students learning"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-blue-600/40 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
{/* Floating Badges */}
|
||||
<motion.div
|
||||
animate={{ y: [0, -10, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 4 }}
|
||||
className="absolute -top-6 -right-6 bg-white p-4 rounded-2xl shadow-xl border border-slate-100 flex items-center gap-3"
|
||||
>
|
||||
<div className="w-10 h-10 bg-green-100 text-green-600 rounded-full flex items-center justify-center"><Globe2 size={20} /></div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">支持语种</p>
|
||||
<p className="text-lg font-extrabold text-slate-900">5+ 语言</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
animate={{ y: [0, 10, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 5 }}
|
||||
className="absolute -bottom-10 -left-10 bg-white p-4 rounded-2xl shadow-xl border border-slate-100 flex items-center gap-3"
|
||||
>
|
||||
<div className="w-10 h-10 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center"><Zap size={20} /></div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">活跃用户</p>
|
||||
<p className="text-lg font-extrabold text-slate-900">10W+</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
src/pages/Login.tsx
Normal file
71
src/pages/Login.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useStore } from '../store';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const { login } = useStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
login(email || 'user@example.com');
|
||||
navigate('/dashboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 relative overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-400 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
|
||||
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-purple-400 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
|
||||
<div className="absolute bottom-1/4 left-1/2 w-96 h-96 bg-pink-400 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-4000"></div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-md p-8 bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl z-10 border border-white"
|
||||
>
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-blue-600 rounded-2xl mx-auto flex items-center justify-center text-white text-3xl font-bold shadow-lg mb-4">L</div>
|
||||
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">欢迎回来</h2>
|
||||
<p className="text-slate-500 mt-2">继续你的语言学习之旅</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">邮箱地址</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white/50"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white/50"
|
||||
placeholder="••••••••"
|
||||
defaultValue="password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-md hover:shadow-lg transition-all active:scale-95"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-slate-500 text-sm">还没有账号? <a href="#" className="text-blue-600 font-bold hover:underline">立即注册</a></p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
148
src/pages/Practice.tsx
Normal file
148
src/pages/Practice.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { mockVocabQuestions } from '../data/mock';
|
||||
import { useStore } from '../store';
|
||||
import { X, Volume2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
|
||||
export const Practice: React.FC = () => {
|
||||
const { type } = useParams(); // vocab, grammar, speaking, listening
|
||||
const navigate = useNavigate();
|
||||
const { addStudyTime } = useStore();
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null);
|
||||
const [isCorrect, setIsCorrect] = useState<boolean | null>(null);
|
||||
const [score, setScore] = useState(0);
|
||||
const [isFinished, setIsFinished] = useState(false);
|
||||
|
||||
// Using vocab questions for all types as a mock
|
||||
const questions = mockVocabQuestions;
|
||||
const currentQ = questions[currentIndex];
|
||||
|
||||
const handleSelect = (option: string) => {
|
||||
if (selectedAnswer) return;
|
||||
|
||||
setSelectedAnswer(option);
|
||||
const correct = option === currentQ.correctAnswer;
|
||||
setIsCorrect(correct);
|
||||
if (correct) setScore(s => s + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
if (currentIndex < questions.length - 1) {
|
||||
setCurrentIndex(i => i + 1);
|
||||
setSelectedAnswer(null);
|
||||
setIsCorrect(null);
|
||||
} else {
|
||||
setIsFinished(true);
|
||||
addStudyTime(5); // Mock adding 5 minutes
|
||||
}
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
if (isFinished) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="bg-white p-8 rounded-3xl max-w-md w-full text-center shadow-2xl border border-slate-100"
|
||||
>
|
||||
<div className="w-24 h-24 bg-green-100 text-green-500 rounded-full mx-auto flex items-center justify-center mb-6">
|
||||
<CheckCircle2 size={48} />
|
||||
</div>
|
||||
<h2 className="text-3xl font-extrabold text-slate-900 mb-2">练习完成!</h2>
|
||||
<p className="text-slate-500 mb-8">你答对了 {score} / {questions.length} 题</p>
|
||||
|
||||
<div className="flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="px-6 py-3 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl font-bold transition-colors"
|
||||
>
|
||||
返回仪表盘
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-md hover:shadow-lg transition-all"
|
||||
>
|
||||
再来一次
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = ((currentIndex) / questions.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex flex-col font-sans">
|
||||
{/* Header */}
|
||||
<header className="p-4 flex items-center gap-4 bg-white border-b border-slate-200 sticky top-0 z-10">
|
||||
<button onClick={() => navigate(-1)} className="p-2 text-slate-400 hover:bg-slate-100 rounded-full transition-colors">
|
||||
<X size={24} />
|
||||
</button>
|
||||
<div className="flex-1 h-3 bg-slate-100 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-green-500 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Practice Area */}
|
||||
<main className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ x: 50, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -50, opacity: 0 }}
|
||||
className="bg-white rounded-3xl shadow-xl border border-slate-100 p-8 md:p-12 text-center"
|
||||
>
|
||||
<h3 className="text-slate-500 font-bold uppercase tracking-wider mb-8 text-sm">
|
||||
选择正确的翻译
|
||||
</h3>
|
||||
|
||||
<div className="mb-12">
|
||||
<h1 className="text-5xl md:text-6xl font-extrabold text-slate-900 mb-6">{currentQ.question}</h1>
|
||||
<button className="mx-auto w-12 h-12 rounded-full bg-blue-50 text-blue-600 flex items-center justify-center hover:bg-blue-100 transition-colors shadow-inner">
|
||||
<Volume2 size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{currentQ.options?.map((opt, i) => {
|
||||
let btnState = 'bg-white border-slate-200 hover:border-blue-500 hover:bg-blue-50 text-slate-700';
|
||||
|
||||
if (selectedAnswer === opt) {
|
||||
btnState = isCorrect
|
||||
? 'bg-green-100 border-green-500 text-green-700 shadow-inner'
|
||||
: 'bg-red-100 border-red-500 text-red-700 shadow-inner';
|
||||
} else if (selectedAnswer && opt === currentQ.correctAnswer) {
|
||||
btnState = 'bg-green-100 border-green-500 text-green-700 shadow-inner'; // Show correct answer
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleSelect(opt)}
|
||||
disabled={!!selectedAnswer}
|
||||
className={`p-4 rounded-2xl border-2 font-bold text-lg transition-all ${btnState} flex items-center justify-between`}
|
||||
>
|
||||
<span>{opt}</span>
|
||||
{selectedAnswer === opt && isCorrect && <CheckCircle2 size={24} className="text-green-500" />}
|
||||
{selectedAnswer === opt && !isCorrect && <XCircle size={24} className="text-red-500" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
30
src/store/index.ts
Normal file
30
src/store/index.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { create } from 'zustand';
|
||||
import { User, Course, Language } from '../types';
|
||||
import { mockUser, mockCourses } from '../data/mock';
|
||||
|
||||
interface AppState {
|
||||
user: User | null;
|
||||
courses: Course[];
|
||||
isAuthenticated: boolean;
|
||||
login: (email: string) => void;
|
||||
logout: () => void;
|
||||
updateLanguage: (lang: Language) => void;
|
||||
addStudyTime: (minutes: number) => void;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set) => ({
|
||||
user: mockUser,
|
||||
courses: mockCourses,
|
||||
isAuthenticated: true, // Auto login for demo
|
||||
login: (email) => set({
|
||||
user: { ...mockUser, email },
|
||||
isAuthenticated: true
|
||||
}),
|
||||
logout: () => set({ user: null, isAuthenticated: false }),
|
||||
updateLanguage: (lang) => set((state) => ({
|
||||
user: state.user ? { ...state.user, currentLanguage: lang } : null
|
||||
})),
|
||||
addStudyTime: (minutes) => set((state) => ({
|
||||
user: state.user ? { ...state.user, totalStudyTime: state.user.totalStudyTime + minutes } : null
|
||||
}))
|
||||
}));
|
||||
53
src/types/index.ts
Normal file
53
src/types/index.ts
Normal file
@ -0,0 +1,53 @@
|
||||
export type Language = 'en' | 'ja' | 'ko' | 'es' | 'fr';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
currentLanguage: Language;
|
||||
level: string; // e.g., 'A1', 'B2'
|
||||
totalStudyTime: number; // in minutes
|
||||
streakDays: number;
|
||||
}
|
||||
|
||||
export interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
language: Language;
|
||||
level: string;
|
||||
thumbnail: string;
|
||||
lessonsCount: number;
|
||||
completedLessons: number;
|
||||
}
|
||||
|
||||
export interface Badge {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
iconUrl: string;
|
||||
earnedAt?: string;
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
authorAvatar?: string;
|
||||
content: string;
|
||||
likes: number;
|
||||
commentsCount: number;
|
||||
createdAt: string;
|
||||
language: Language;
|
||||
}
|
||||
|
||||
export interface PracticeQuestion {
|
||||
id: string;
|
||||
type: 'vocab' | 'grammar' | 'speaking' | 'listening';
|
||||
question: string;
|
||||
options?: string[];
|
||||
correctAnswer: string;
|
||||
audioUrl?: string;
|
||||
hint?: string;
|
||||
}
|
||||
6
src/utils/cn.ts
Normal file
6
src/utils/cn.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
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
13
tailwind.config.js
Normal file
13
tailwind.config.js
Normal file
@ -0,0 +1,13 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
|
||||
export default {
|
||||
darkMode: "class",
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
},
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
36
tsconfig.json
Normal file
36
tsconfig.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"noUncheckedSideEffectImports": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"api"
|
||||
]
|
||||
}
|
||||
5
vercel.json
Normal file
5
vercel.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{ "source": "/(.*)", "destination": "/index.html" }
|
||||
]
|
||||
}
|
||||
30
vite.config.ts
Normal file
30
vite.config.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { traeBadgePlugin } from 'vite-plugin-trae-solo-badge';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
build: {
|
||||
sourcemap: 'hidden',
|
||||
},
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
plugins: [
|
||||
'react-dev-locator',
|
||||
],
|
||||
},
|
||||
}),
|
||||
traeBadgePlugin({
|
||||
variant: 'dark',
|
||||
position: 'bottom-right',
|
||||
prodOnly: true,
|
||||
clickable: true,
|
||||
clickUrl: 'https://www.trae.ai/solo?showJoin=1',
|
||||
autoTheme: true,
|
||||
autoThemeTarget: '#root'
|
||||
}),
|
||||
tsconfigPaths()
|
||||
],
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user