first commit

This commit is contained in:
Agent 2026-04-20 06:48:31 +00:00
commit 60ee9dffd5
32 changed files with 4343 additions and 0 deletions

27
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

10
postcss.config.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
tailwind.config.js Normal file
View 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
View 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
View File

@ -0,0 +1,5 @@
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}

30
vite.config.ts Normal file
View 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()
],
})