Initialize project scaffold with full web and API setup.

Add the Vite frontend, Express API server, and supporting configuration files so the app can run locally on a complete development stack.

Made-with: Cursor
This commit is contained in:
Ebenezer
2026-04-15 17:41:46 +08:00
parent c44442de6f
commit 123f82e92c
108 changed files with 20910 additions and 1 deletions

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Dependencies
node_modules/
# Build outputs
dist/
build/
# Environment files
.env
.env.*
# Editor/system files
.DS_Store
Thumbs.db
.idea/
.vscode/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
# Uploaded course videos (local dev)
uploads/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

0
.prewarm Normal file
View File

View File

@@ -1,3 +1,20 @@
# youwei-business
Initial project setup.
Starter scaffold for a simple web project.
## Project Structure
- `index.html` - Main page
- `styles.css` - Styling
- `script.js` - Frontend logic
## Quick Start
1. Open `index.html` in your browser.
2. Click the button to verify JavaScript is working.
## Next Steps
- Add your actual business content to `index.html`
- Split logic into modules as features grow
- Add a build tool (Vite/Webpack) when needed

1238
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb Normal file

Binary file not shown.

20
components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

27
eslint.config.js Normal file
View File

@@ -0,0 +1,27 @@
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}"],
ignores: ["server/**"],
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 }],
"@typescript-eslint/no-unused-vars": "off",
},
},
);

30
index.html Normal file
View File

@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- TODO: Set the document title to the name of your application -->
<title>Lovable App</title>
<meta name="description" content="Lovable Generated Project">
<meta name="author" content="Lovable" />
<!-- TODO: Update og:title to match your application name -->
<meta property="og:type" content="website" />
<meta property="og:image" content="https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/4f2c413f-20f3-4c74-b498-b5ecfb0df975/id-preview-3bc20041--907ffd16-1460-46df-8fec-c0cd1a15e8ad.lovable.app-1774322130673.png">
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@Lovable" />
<meta name="twitter:image" content="https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/4f2c413f-20f3-4c74-b498-b5ecfb0df975/id-preview-3bc20041--907ffd16-1460-46df-8fec-c0cd1a15e8ad.lovable.app-1774322130673.png">
<meta property="og:title" content="Lovable App">
<meta name="twitter:title" content="Lovable App">
<meta property="og:description" content="Lovable Generated Project">
<meta name="twitter:description" content="Lovable Generated Project">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

10266
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

108
package.json Normal file
View File

@@ -0,0 +1,108 @@
{
"name": "vite_react_shadcn_ts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev:web": "vite",
"dev": "concurrently -k \"npm run server:dev\" \"npm run dev:web\" -n api,web -c blue,green",
"dev:stack": "npm run dev",
"server": "tsx server/index.ts",
"server:dev": "tsx watch server/index.ts",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.38.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.61.1",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "^3.25.76",
"argon2": "^0.41.1",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"mysql2": "^3.12.0",
"multer": "^1.4.5-lts.1"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@playwright/test": "^1.57.0",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21",
"concurrently": "^9.1.2",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"jsdom": "^20.0.3",
"lovable-tagger": "^1.1.13",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^5.4.19",
"vitest": "^3.2.4"
}
}

3
playwright-fixture.ts Normal file
View File

@@ -0,0 +1,3 @@
// Re-export the base fixture from the package
// Override or extend test/expect here if needed
export { test, expect } from "lovable-agent-playwright-config/fixture";

10
playwright.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createLovableConfig } from "lovable-agent-playwright-config/config";
export default createLovableConfig({
// Add your custom playwright configuration overrides here
// Example:
// timeout: 60000,
// use: {
// baseURL: 'http://localhost:3000',
// },
});

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

40
public/placeholder.svg Normal file
View File

@@ -0,0 +1,40 @@
<svg width="150" height="39" viewBox="0 0 150 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M145.867 11.5547C145.143 11.5547 144.503 11.3984 143.945 11.0859C143.388 10.7682 142.951 10.2865 142.633 9.64062C142.315 8.99479 142.156 8.19271 142.156 7.23438C142.156 6.375 142.315 5.6224 142.633 4.97656C142.956 4.32552 143.398 3.82552 143.961 3.47656C144.523 3.1224 145.159 2.94531 145.867 2.94531C146.565 2.94531 147.177 3.10677 147.703 3.42969C148.229 3.7526 148.635 4.22656 148.922 4.85156C149.214 5.47135 149.359 6.22135 149.359 7.10156C149.359 7.26302 149.359 7.42448 149.359 7.58594H142.891V6.42969H148.539L147.836 6.8125C147.836 6.21875 147.758 5.72396 147.602 5.32812C147.451 4.93229 147.227 4.63802 146.93 4.44531C146.638 4.2526 146.279 4.15625 145.852 4.15625C145.419 4.15625 145.036 4.27083 144.703 4.5C144.37 4.72917 144.107 5.06771 143.914 5.51562C143.727 5.96354 143.633 6.5026 143.633 7.13281V7.21875C143.633 7.91146 143.727 8.48958 143.914 8.95312C144.102 9.41667 144.37 9.76302 144.719 9.99219C145.068 10.2214 145.484 10.3359 145.969 10.3359C146.474 10.3359 146.893 10.1875 147.227 9.89062C147.565 9.58854 147.773 9.16146 147.852 8.60938H149.312C149.25 9.19271 149.073 9.70573 148.781 10.1484C148.49 10.5911 148.094 10.9375 147.594 11.1875C147.099 11.4323 146.523 11.5547 145.867 11.5547Z" fill="black"/>
<path d="M136.898 3.17188H138.32V5.13281L138.211 5.08594C138.294 4.66927 138.448 4.30469 138.672 3.99219C138.901 3.67969 139.195 3.4375 139.555 3.26562C139.914 3.09375 140.328 3.00781 140.797 3.00781C140.917 3.00781 141.039 3.01823 141.164 3.03906V4.47656C141.065 4.45573 140.969 4.4401 140.875 4.42969C140.781 4.41927 140.682 4.41406 140.578 4.41406C140.099 4.41406 139.693 4.51042 139.359 4.70312C139.026 4.89583 138.771 5.1849 138.594 5.57031C138.422 5.95052 138.336 6.42448 138.336 6.99219V11.3281H136.898V3.17188Z" fill="black"/>
<path d="M131.508 11.5547C130.784 11.5547 130.143 11.3984 129.586 11.0859C129.029 10.7682 128.591 10.2865 128.273 9.64062C127.956 8.99479 127.797 8.19271 127.797 7.23438C127.797 6.375 127.956 5.6224 128.273 4.97656C128.596 4.32552 129.039 3.82552 129.602 3.47656C130.164 3.1224 130.799 2.94531 131.508 2.94531C132.206 2.94531 132.818 3.10677 133.344 3.42969C133.87 3.7526 134.276 4.22656 134.562 4.85156C134.854 5.47135 135 6.22135 135 7.10156C135 7.26302 135 7.42448 135 7.58594H128.531V6.42969H134.18L133.477 6.8125C133.477 6.21875 133.398 5.72396 133.242 5.32812C133.091 4.93229 132.867 4.63802 132.57 4.44531C132.279 4.2526 131.919 4.15625 131.492 4.15625C131.06 4.15625 130.677 4.27083 130.344 4.5C130.01 4.72917 129.747 5.06771 129.555 5.51562C129.367 5.96354 129.273 6.5026 129.273 7.13281V7.21875C129.273 7.91146 129.367 8.48958 129.555 8.95312C129.742 9.41667 130.01 9.76302 130.359 9.99219C130.708 10.2214 131.125 10.3359 131.609 10.3359C132.115 10.3359 132.534 10.1875 132.867 9.89062C133.206 9.58854 133.414 9.16146 133.492 8.60938H134.953C134.891 9.19271 134.714 9.70573 134.422 10.1484C134.13 10.5911 133.734 10.9375 133.234 11.1875C132.74 11.4323 132.164 11.5547 131.508 11.5547Z" fill="black"/>
<path d="M119.477 0.125H120.914V5.66406L120.703 5.05469C120.802 4.6224 120.971 4.2474 121.211 3.92969C121.456 3.61198 121.766 3.36979 122.141 3.20312C122.516 3.03125 122.943 2.94531 123.422 2.94531C123.958 2.94531 124.419 3.05729 124.805 3.28125C125.195 3.50521 125.492 3.82292 125.695 4.23438C125.898 4.64062 126 5.11719 126 5.66406V11.3281H124.562V5.82031C124.562 5.28385 124.43 4.8724 124.164 4.58594C123.904 4.29948 123.518 4.15625 123.008 4.15625C122.596 4.15625 122.232 4.25781 121.914 4.46094C121.602 4.66406 121.357 4.96875 121.18 5.375C121.003 5.77604 120.914 6.26302 120.914 6.83594V11.3281H119.477V0.125Z" fill="black"/>
<path d="M110.523 11.5547C109.799 11.5547 109.159 11.3984 108.602 11.0859C108.044 10.7682 107.607 10.2865 107.289 9.64062C106.971 8.99479 106.812 8.19271 106.812 7.23438C106.812 6.375 106.971 5.6224 107.289 4.97656C107.612 4.32552 108.055 3.82552 108.617 3.47656C109.18 3.1224 109.815 2.94531 110.523 2.94531C111.221 2.94531 111.833 3.10677 112.359 3.42969C112.885 3.7526 113.292 4.22656 113.578 4.85156C113.87 5.47135 114.016 6.22135 114.016 7.10156C114.016 7.26302 114.016 7.42448 114.016 7.58594H107.547V6.42969H113.195L112.492 6.8125C112.492 6.21875 112.414 5.72396 112.258 5.32812C112.107 4.93229 111.883 4.63802 111.586 4.44531C111.294 4.2526 110.935 4.15625 110.508 4.15625C110.076 4.15625 109.693 4.27083 109.359 4.5C109.026 4.72917 108.763 5.06771 108.57 5.51562C108.383 5.96354 108.289 6.5026 108.289 7.13281V7.21875C108.289 7.91146 108.383 8.48958 108.57 8.95312C108.758 9.41667 109.026 9.76302 109.375 9.99219C109.724 10.2214 110.141 10.3359 110.625 10.3359C111.13 10.3359 111.549 10.1875 111.883 9.89062C112.221 9.58854 112.43 9.16146 112.508 8.60938H113.969C113.906 9.19271 113.729 9.70573 113.438 10.1484C113.146 10.5911 112.75 10.9375 112.25 11.1875C111.755 11.4323 111.18 11.5547 110.523 11.5547Z" fill="black"/>
<path d="M98.5234 3.17188H100.055L102.359 9.90625H102.086L104.344 3.17188H105.805L102.844 11.3281H101.516L98.5234 3.17188Z" fill="black"/>
<path d="M95.6328 3.17188H97.0703V11.3281H95.6328V3.17188ZM96.3516 1.92188C96.1797 1.92188 96.0182 1.88021 95.8672 1.79688C95.7214 1.70833 95.6042 1.59115 95.5156 1.44531C95.4323 1.29427 95.3906 1.13281 95.3906 0.960938C95.3906 0.789062 95.4323 0.630208 95.5156 0.484375C95.6042 0.333333 95.7214 0.216146 95.8672 0.132812C96.0182 0.0442708 96.1797 0 96.3516 0C96.5234 0 96.6823 0.0442708 96.8281 0.132812C96.9792 0.216146 97.0964 0.333333 97.1797 0.484375C97.2682 0.630208 97.3125 0.789062 97.3125 0.960938C97.3125 1.13281 97.2682 1.29427 97.1797 1.44531C97.0964 1.59115 96.9792 1.70833 96.8281 1.79688C96.6823 1.88021 96.5234 1.92188 96.3516 1.92188Z" fill="black"/>
<path d="M91.8672 0.125H93.3047V11.3281H91.8672V0.125Z" fill="black"/>
<path d="M84.5391 0.125H85.9766V11.3281H84.5391V0.125Z" fill="black"/>
<path d="M80.7734 0.125H82.2109V11.3281H80.7734V0.125Z" fill="black"/>
<path d="M77.0078 3.17188H78.4453V11.3281H77.0078V3.17188ZM77.7266 1.92188C77.5547 1.92188 77.3932 1.88021 77.2422 1.79688C77.0964 1.70833 76.9792 1.59115 76.8906 1.44531C76.8073 1.29427 76.7656 1.13281 76.7656 0.960938C76.7656 0.789062 76.8073 0.630208 76.8906 0.484375C76.9792 0.333333 77.0964 0.216146 77.2422 0.132812C77.3932 0.0442708 77.5547 0 77.7266 0C77.8984 0 78.0573 0.0442708 78.2031 0.132812C78.3542 0.216146 78.4714 0.333333 78.5547 0.484375C78.6432 0.630208 78.6875 0.789062 78.6875 0.960938C78.6875 1.13281 78.6432 1.29427 78.5547 1.44531C78.4714 1.59115 78.3542 1.70833 78.2031 1.79688C78.0573 1.88021 77.8984 1.92188 77.7266 1.92188Z" fill="black"/>
<path d="M64.0703 3.17188H65.5391L67.5078 10.3203H66.8984L69.0312 3.17188H70.6406L72.7266 10.3203H72.1875L74.1016 3.17188H75.4766L73.0859 11.3281H71.7344L69.5078 3.61719H70.1641L67.8281 11.3281H66.4688L64.0703 3.17188Z" fill="black"/>
<path d="M56.2891 11.5547C55.8516 11.5547 55.4505 11.4714 55.0859 11.3047C54.7266 11.138 54.4167 10.8854 54.1562 10.5469C53.901 10.2083 53.7109 9.78646 53.5859 9.28125L53.8984 9.60156V11.3281H52.5078V3.17188H53.9453V4.99219L53.6016 5.21875C53.7109 4.74479 53.8958 4.33854 54.1562 4C54.4167 3.65625 54.7344 3.39583 55.1094 3.21875C55.4896 3.03646 55.9036 2.94531 56.3516 2.94531C57.0443 2.94531 57.638 3.1224 58.1328 3.47656C58.6328 3.82552 59.013 4.32552 59.2734 4.97656C59.5339 5.6224 59.6641 6.38021 59.6641 7.25C59.6641 8.11458 59.5286 8.8724 59.2578 9.52344C58.987 10.1693 58.5964 10.6693 58.0859 11.0234C57.5755 11.3776 56.9766 11.5547 56.2891 11.5547ZM56.0781 10.3359C56.526 10.3359 56.9062 10.2083 57.2188 9.95312C57.5312 9.69271 57.7656 9.33333 57.9219 8.875C58.0781 8.41146 58.1562 7.875 58.1562 7.26562C58.1562 6.65625 58.0781 6.11979 57.9219 5.65625C57.7656 5.1875 57.5312 4.82292 57.2188 4.5625C56.9062 4.29688 56.526 4.16406 56.0781 4.16406C55.6302 4.16406 55.2448 4.29688 54.9219 4.5625C54.6042 4.82292 54.362 5.1875 54.1953 5.65625C54.0339 6.11979 53.9531 6.65625 53.9531 7.26562C53.9531 7.86979 54.0339 8.40365 54.1953 8.86719C54.362 9.33073 54.6042 9.69271 54.9219 9.95312C55.2448 10.2083 55.6302 10.3359 56.0781 10.3359ZM52.5078 9.66406H53.9453V14.2109H52.5078V9.66406Z" fill="black"/>
<path d="M47.2578 11.5547C46.8203 11.5547 46.4193 11.4714 46.0547 11.3047C45.6953 11.138 45.3854 10.8854 45.125 10.5469C44.8698 10.2083 44.6797 9.78646 44.5547 9.28125L44.8672 9.60156V11.3281H43.4766V3.17188H44.9141V4.99219L44.5703 5.21875C44.6797 4.74479 44.8646 4.33854 45.125 4C45.3854 3.65625 45.7031 3.39583 46.0781 3.21875C46.4583 3.03646 46.8724 2.94531 47.3203 2.94531C48.013 2.94531 48.6068 3.1224 49.1016 3.47656C49.6016 3.82552 49.9818 4.32552 50.2422 4.97656C50.5026 5.6224 50.6328 6.38021 50.6328 7.25C50.6328 8.11458 50.4974 8.8724 50.2266 9.52344C49.9557 10.1693 49.5651 10.6693 49.0547 11.0234C48.5443 11.3776 47.9453 11.5547 47.2578 11.5547ZM47.0469 10.3359C47.4948 10.3359 47.875 10.2083 48.1875 9.95312C48.5 9.69271 48.7344 9.33333 48.8906 8.875C49.0469 8.41146 49.125 7.875 49.125 7.26562C49.125 6.65625 49.0469 6.11979 48.8906 5.65625C48.7344 5.1875 48.5 4.82292 48.1875 4.5625C47.875 4.29688 47.4948 4.16406 47.0469 4.16406C46.599 4.16406 46.2135 4.29688 45.8906 4.5625C45.5729 4.82292 45.3307 5.1875 45.1641 5.65625C45.0026 6.11979 44.9219 6.65625 44.9219 7.26562C44.9219 7.86979 45.0026 8.40365 45.1641 8.86719C45.3307 9.33073 45.5729 9.69271 45.8906 9.95312C46.2135 10.2083 46.599 10.3359 47.0469 10.3359ZM43.4766 9.66406H44.9141V14.2109H43.4766V9.66406Z" fill="black"/>
<path d="M37.2344 11.5547C36.7292 11.5547 36.2734 11.4583 35.8672 11.2656C35.4609 11.0677 35.1406 10.7865 34.9062 10.4219C34.6771 10.0573 34.5625 9.63281 34.5625 9.14844C34.5625 8.40365 34.7865 7.82552 35.2344 7.41406C35.6875 6.9974 36.3203 6.72396 37.1328 6.59375L38.4297 6.38281C38.763 6.32552 39.0234 6.26302 39.2109 6.19531C39.3984 6.1224 39.5365 6.02865 39.625 5.91406C39.7135 5.79427 39.7578 5.63542 39.7578 5.4375C39.7578 5.21875 39.6979 5.01042 39.5781 4.8125C39.4583 4.61458 39.2734 4.45312 39.0234 4.32812C38.7734 4.20312 38.4635 4.14062 38.0938 4.14062C37.5729 4.14062 37.1484 4.27865 36.8203 4.55469C36.4974 4.82552 36.3203 5.20052 36.2891 5.67969H34.7812C34.7969 5.15885 34.9453 4.69271 35.2266 4.28125C35.5078 3.86458 35.8958 3.53906 36.3906 3.30469C36.8854 3.0651 37.4531 2.94531 38.0938 2.94531C38.7448 2.94531 39.3047 3.0625 39.7734 3.29688C40.2422 3.52604 40.599 3.86198 40.8438 4.30469C41.0938 4.7474 41.2188 5.27604 41.2188 5.89062V9.4375C41.2188 9.81771 41.2448 10.1693 41.2969 10.4922C41.3542 10.8099 41.4349 11.013 41.5391 11.1016V11.3281H40.0156C39.9531 11.1042 39.901 10.8438 39.8594 10.5469C39.8229 10.2448 39.8047 9.95052 39.8047 9.66406L40.0625 9.74219C39.9375 10.0807 39.7396 10.388 39.4688 10.6641C39.1979 10.9401 38.8698 11.1589 38.4844 11.3203C38.099 11.4766 37.6823 11.5547 37.2344 11.5547ZM37.5547 10.3516C38.0078 10.3516 38.4036 10.25 38.7422 10.0469C39.0807 9.83854 39.3385 9.5599 39.5156 9.21094C39.6979 8.85677 39.7891 8.46354 39.7891 8.03125V6.8125L39.9531 6.83594C39.8021 7.00781 39.6094 7.14323 39.375 7.24219C39.1458 7.34115 38.8359 7.42969 38.4453 7.50781L37.5859 7.67969C37.0599 7.78906 36.6745 7.95573 36.4297 8.17969C36.1849 8.39844 36.0625 8.70573 36.0625 9.10156C36.0625 9.48177 36.2031 9.78646 36.4844 10.0156C36.7708 10.2396 37.1276 10.3516 37.5547 10.3516Z" fill="black"/>
<path d="M26.1641 3.17188H27.5859V5.13281L27.4766 5.08594C27.5599 4.66927 27.7135 4.30469 27.9375 3.99219C28.1667 3.67969 28.4609 3.4375 28.8203 3.26562C29.1797 3.09375 29.5938 3.00781 30.0625 3.00781C30.1823 3.00781 30.3047 3.01823 30.4297 3.03906V4.47656C30.3307 4.45573 30.2344 4.4401 30.1406 4.42969C30.0469 4.41927 29.9479 4.41406 29.8438 4.41406C29.3646 4.41406 28.9583 4.51042 28.625 4.70312C28.2917 4.89583 28.0365 5.1849 27.8594 5.57031C27.6875 5.95052 27.6016 6.42448 27.6016 6.99219V11.3281H26.1641V3.17188Z" fill="black"/>
<path d="M19.9688 11.5547C19.4167 11.5547 18.9401 11.4479 18.5391 11.2344C18.1432 11.0208 17.8385 10.7109 17.625 10.3047C17.4167 9.89844 17.3125 9.40885 17.3125 8.83594V3.17188H18.75V8.67188C18.75 9.19792 18.888 9.60677 19.1641 9.89844C19.4401 10.1901 19.8411 10.3359 20.3672 10.3359C20.7682 10.3359 21.1198 10.237 21.4219 10.0391C21.7292 9.84115 21.9688 9.54948 22.1406 9.16406C22.3125 8.77344 22.3984 8.29948 22.3984 7.74219V3.17188H23.8359V11.3281H22.4297V8.83594L22.6719 9.4375C22.5521 9.86979 22.3672 10.2448 22.1172 10.5625C21.8724 10.8802 21.5677 11.125 21.2031 11.2969C20.8385 11.4688 20.4271 11.5547 19.9688 11.5547Z" fill="black"/>
<path d="M11.8672 11.5547C11.138 11.5547 10.4974 11.3776 9.94531 11.0234C9.39323 10.6693 8.96615 10.1667 8.66406 9.51562C8.36719 8.86458 8.21875 8.10677 8.21875 7.24219C8.21875 6.3776 8.36719 5.6224 8.66406 4.97656C8.96615 4.32552 9.39323 3.82552 9.94531 3.47656C10.4974 3.1224 11.138 2.94531 11.8672 2.94531C12.5964 2.94531 13.237 3.1224 13.7891 3.47656C14.3411 3.82552 14.7656 4.32552 15.0625 4.97656C15.3646 5.6224 15.5156 6.3776 15.5156 7.24219C15.5156 8.10677 15.3646 8.86458 15.0625 9.51562C14.7656 10.1667 14.3411 10.6693 13.7891 11.0234C13.237 11.3776 12.5964 11.5547 11.8672 11.5547ZM11.8672 10.3359C12.3151 10.3359 12.6979 10.2161 13.0156 9.97656C13.3385 9.73177 13.5833 9.3776 13.75 8.91406C13.9219 8.45052 14.0078 7.89323 14.0078 7.24219C14.0078 6.26302 13.8203 5.50521 13.4453 4.96875C13.0703 4.43229 12.5443 4.16406 11.8672 4.16406C11.4193 4.16406 11.0339 4.28385 10.7109 4.52344C10.3932 4.76302 10.1484 5.11458 9.97656 5.57812C9.8099 6.03646 9.72656 6.59115 9.72656 7.24219C9.72656 7.89323 9.8099 8.45052 9.97656 8.91406C10.1484 9.3776 10.3932 9.73177 10.7109 9.97656C11.0339 10.2161 11.4193 10.3359 11.8672 10.3359Z" fill="black"/>
<path d="M3.39062 6.17969L3.71094 7.53906L0 0.125H1.64844L4.41406 6H3.86719L6.70312 0.125H8.26562L4.53906 7.53906L4.85938 6.17969V11.3281H3.39062V6.17969Z" fill="black"/>
<path d="M140.19 38.4648C139.789 38.4648 139.448 38.4033 139.165 38.2803C138.882 38.1572 138.661 37.9567 138.502 37.6787C138.347 37.3962 138.27 37.0293 138.27 36.5781V32.2236H137.012V31.1914H137.135C137.445 31.1914 137.693 31.1436 137.88 31.0479C138.071 30.9521 138.215 30.804 138.311 30.6035C138.406 30.403 138.463 30.1364 138.481 29.8037L138.522 29.0996H139.527V31.3008L139.377 31.1914H141.1V32.2236H139.527V36.4961C139.527 36.8197 139.607 37.0498 139.767 37.1865C139.931 37.3232 140.159 37.3916 140.45 37.3916C140.637 37.3916 140.81 37.3734 140.97 37.3369V38.3691C140.828 38.4056 140.699 38.4307 140.58 38.4443C140.462 38.458 140.332 38.4648 140.19 38.4648Z" fill="black"/>
<path d="M134.476 31.1914H135.733V38.3281H134.476V31.1914ZM135.104 30.0977C134.954 30.0977 134.813 30.0612 134.681 29.9883C134.553 29.9108 134.451 29.8083 134.373 29.6807C134.3 29.5485 134.264 29.4072 134.264 29.2568C134.264 29.1064 134.3 28.9674 134.373 28.8398C134.451 28.7077 134.553 28.6051 134.681 28.5322C134.813 28.4548 134.954 28.416 135.104 28.416C135.255 28.416 135.394 28.4548 135.521 28.5322C135.654 28.6051 135.756 28.7077 135.829 28.8398C135.907 28.9674 135.945 29.1064 135.945 29.2568C135.945 29.4072 135.907 29.5485 135.829 29.6807C135.756 29.8083 135.654 29.9108 135.521 29.9883C135.394 30.0612 135.255 30.0977 135.104 30.0977Z" fill="black"/>
<path d="M128.057 28.5254H129.314V31.1914H128.057V28.5254ZM125.965 38.5264C125.354 38.5264 124.83 38.3737 124.393 38.0684C123.955 37.7585 123.622 37.3232 123.395 36.7627C123.167 36.1976 123.053 35.5345 123.053 34.7734C123.053 34.0169 123.171 33.3538 123.408 32.7842C123.65 32.2145 123.994 31.7747 124.44 31.4648C124.887 31.1504 125.409 30.9932 126.006 30.9932C126.389 30.9932 126.737 31.0661 127.052 31.2119C127.371 31.3577 127.642 31.5788 127.865 31.875C128.093 32.1712 128.262 32.5404 128.371 32.9824L128.057 32.7021V31.1914H129.314V38.3281H128.098V36.7354L128.357 36.5371C128.212 37.1615 127.924 37.6491 127.496 38C127.068 38.3509 126.557 38.5264 125.965 38.5264ZM126.19 37.46C126.582 37.46 126.917 37.346 127.195 37.1182C127.478 36.8857 127.69 36.5667 127.831 36.1611C127.977 35.751 128.05 35.2793 128.05 34.7461C128.05 34.2174 127.977 33.7503 127.831 33.3447C127.69 32.9391 127.478 32.6247 127.195 32.4014C126.917 32.1735 126.582 32.0596 126.19 32.0596C125.799 32.0596 125.466 32.1735 125.192 32.4014C124.919 32.6247 124.714 32.9391 124.577 33.3447C124.44 33.7458 124.372 34.2129 124.372 34.7461C124.372 35.2793 124.44 35.751 124.577 36.1611C124.714 36.5667 124.919 36.8857 125.192 37.1182C125.466 37.346 125.799 37.46 126.19 37.46Z" fill="black"/>
<path d="M120.161 28.5254H121.419V38.3281H120.161V28.5254Z" fill="black"/>
<path d="M116.866 31.1914H118.124V38.3281H116.866V31.1914ZM117.495 30.0977C117.345 30.0977 117.203 30.0612 117.071 29.9883C116.944 29.9108 116.841 29.8083 116.764 29.6807C116.691 29.5485 116.654 29.4072 116.654 29.2568C116.654 29.1064 116.691 28.9674 116.764 28.8398C116.841 28.7077 116.944 28.6051 117.071 28.5322C117.203 28.4548 117.345 28.416 117.495 28.416C117.646 28.416 117.785 28.4548 117.912 28.5322C118.044 28.6051 118.147 28.7077 118.22 28.8398C118.297 28.9674 118.336 29.1064 118.336 29.2568C118.336 29.4072 118.297 29.5485 118.22 29.6807C118.147 29.8083 118.044 29.9108 117.912 29.9883C117.785 30.0612 117.646 30.0977 117.495 30.0977Z" fill="black"/>
<path d="M111.445 38.5264C110.962 38.5264 110.545 38.4329 110.194 38.2461C109.848 38.0592 109.581 37.7881 109.395 37.4326C109.212 37.0771 109.121 36.6488 109.121 36.1475V31.1914H110.379V36.0039C110.379 36.4642 110.5 36.8219 110.741 37.0771C110.983 37.3324 111.334 37.46 111.794 37.46C112.145 37.46 112.452 37.3734 112.717 37.2002C112.986 37.027 113.195 36.7718 113.346 36.4346C113.496 36.0928 113.571 35.6781 113.571 35.1904V31.1914H114.829V38.3281H113.599V36.1475L113.811 36.6738C113.706 37.0521 113.544 37.3802 113.325 37.6582C113.111 37.9362 112.844 38.1504 112.525 38.3008C112.206 38.4512 111.846 38.5264 111.445 38.5264Z" fill="black"/>
<path d="M104.589 38.5264C104.206 38.5264 103.855 38.4535 103.536 38.3076C103.222 38.1618 102.951 37.9408 102.723 37.6445C102.499 37.3483 102.333 36.9792 102.224 36.5371L102.497 36.8174V38.3281H101.28V31.1914H102.538V32.7842L102.237 32.9824C102.333 32.5677 102.495 32.2122 102.723 31.916C102.951 31.6152 103.229 31.3874 103.557 31.2324C103.889 31.0729 104.252 30.9932 104.644 30.9932C105.25 30.9932 105.769 31.1481 106.202 31.458C106.64 31.7633 106.972 32.2008 107.2 32.7705C107.428 33.3356 107.542 33.9987 107.542 34.7598C107.542 35.5163 107.424 36.1794 107.187 36.749C106.95 37.3141 106.608 37.7516 106.161 38.0615C105.715 38.3714 105.19 38.5264 104.589 38.5264ZM104.404 37.46C104.796 37.46 105.129 37.3483 105.402 37.125C105.676 36.8971 105.881 36.5827 106.018 36.1816C106.154 35.776 106.223 35.3066 106.223 34.7734C106.223 34.2402 106.154 33.7708 106.018 33.3652C105.881 32.9551 105.676 32.6361 105.402 32.4082C105.129 32.1758 104.796 32.0596 104.404 32.0596C104.012 32.0596 103.675 32.1758 103.393 32.4082C103.115 32.6361 102.903 32.9551 102.757 33.3652C102.616 33.7708 102.545 34.2402 102.545 34.7734C102.545 35.3021 102.616 35.7692 102.757 36.1748C102.903 36.5804 103.115 36.8971 103.393 37.125C103.675 37.3483 104.012 37.46 104.404 37.46ZM101.28 28.5254H102.538V31.1914H101.28V28.5254Z" fill="black"/>
<path d="M93.3369 38.5264C92.6989 38.5264 92.1383 38.3714 91.6553 38.0615C91.1722 37.7516 90.7985 37.3118 90.5342 36.7422C90.2744 36.1725 90.1445 35.5094 90.1445 34.7529C90.1445 33.9964 90.2744 33.3356 90.5342 32.7705C90.7985 32.2008 91.1722 31.7633 91.6553 31.458C92.1383 31.1481 92.6989 30.9932 93.3369 30.9932C93.9749 30.9932 94.5355 31.1481 95.0186 31.458C95.5016 31.7633 95.873 32.2008 96.1328 32.7705C96.3971 33.3356 96.5293 33.9964 96.5293 34.7529C96.5293 35.5094 96.3971 36.1725 96.1328 36.7422C95.873 37.3118 95.5016 37.7516 95.0186 38.0615C94.5355 38.3714 93.9749 38.5264 93.3369 38.5264ZM93.3369 37.46C93.7288 37.46 94.0638 37.3551 94.3418 37.1455C94.6243 36.9313 94.8385 36.6214 94.9844 36.2158C95.1348 35.8102 95.21 35.3226 95.21 34.7529C95.21 33.8962 95.0459 33.2331 94.7178 32.7637C94.3896 32.2943 93.9294 32.0596 93.3369 32.0596C92.945 32.0596 92.6077 32.1644 92.3252 32.374C92.0472 32.5837 91.833 32.8913 91.6826 33.2969C91.5368 33.6979 91.4639 34.1833 91.4639 34.7529C91.4639 35.3226 91.5368 35.8102 91.6826 36.2158C91.833 36.6214 92.0472 36.9313 92.3252 37.1455C92.6077 37.3551 92.945 37.46 93.3369 37.46Z" fill="black"/>
<path d="M88.333 38.4648C87.932 38.4648 87.5902 38.4033 87.3076 38.2803C87.0251 38.1572 86.804 37.9567 86.6445 37.6787C86.4896 37.3962 86.4121 37.0293 86.4121 36.5781V32.2236H85.1543V31.1914H85.2773C85.5872 31.1914 85.8356 31.1436 86.0225 31.0479C86.2139 30.9521 86.3574 30.804 86.4531 30.6035C86.5488 30.403 86.6058 30.1364 86.624 29.8037L86.665 29.0996H87.6699V31.3008L87.5195 31.1914H89.2422V32.2236H87.6699V36.4961C87.6699 36.8197 87.7497 37.0498 87.9092 37.1865C88.0732 37.3232 88.3011 37.3916 88.5928 37.3916C88.7796 37.3916 88.9528 37.3734 89.1123 37.3369V38.3691C88.971 38.4056 88.8411 38.4307 88.7227 38.4443C88.6042 38.458 88.4743 38.4648 88.333 38.4648Z" fill="black"/>
<path d="M78.0791 38.5264C77.4456 38.5264 76.8851 38.3896 76.3975 38.1162C75.9098 37.8382 75.527 37.4167 75.249 36.8516C74.971 36.2865 74.832 35.5846 74.832 34.7461C74.832 33.9941 74.971 33.3356 75.249 32.7705C75.5316 32.2008 75.9189 31.7633 76.4111 31.458C76.9033 31.1481 77.4593 30.9932 78.0791 30.9932C78.6898 30.9932 79.2253 31.1344 79.6855 31.417C80.1458 31.6995 80.5013 32.1143 80.752 32.6611C81.0072 33.2035 81.1348 33.8597 81.1348 34.6299C81.1348 34.7712 81.1348 34.9124 81.1348 35.0537H75.4746V34.042H80.417L79.8018 34.377C79.8018 33.8574 79.7334 33.4245 79.5967 33.0781C79.4645 32.7318 79.2686 32.4743 79.0088 32.3057C78.7536 32.137 78.4391 32.0527 78.0654 32.0527C77.6872 32.0527 77.3522 32.153 77.0605 32.3535C76.7689 32.554 76.5387 32.8503 76.3701 33.2422C76.2061 33.6341 76.124 34.1058 76.124 34.6572V34.7324C76.124 35.3385 76.2061 35.8444 76.3701 36.25C76.5342 36.6556 76.7689 36.9587 77.0742 37.1592C77.3796 37.3597 77.7441 37.46 78.168 37.46C78.61 37.46 78.9769 37.3301 79.2686 37.0703C79.5648 36.806 79.7471 36.4323 79.8154 35.9492H81.0938C81.0391 36.4596 80.8841 36.9085 80.6289 37.2959C80.3737 37.6833 80.0273 37.9863 79.5898 38.2051C79.1569 38.4193 78.6533 38.5264 78.0791 38.5264Z" fill="black"/>
<path d="M71.9404 28.5254H73.1982V38.3281H71.9404V28.5254Z" fill="black"/>
<path d="M67.3467 38.5264C66.9639 38.5264 66.613 38.4535 66.2939 38.3076C65.9795 38.1618 65.7083 37.9408 65.4805 37.6445C65.2572 37.3483 65.0908 36.9792 64.9814 36.5371L65.2549 36.8174V38.3281H64.0381V31.1914H65.2959V32.7842L64.9951 32.9824C65.0908 32.5677 65.2526 32.2122 65.4805 31.916C65.7083 31.6152 65.9863 31.3874 66.3145 31.2324C66.6471 31.0729 67.0094 30.9932 67.4014 30.9932C68.0075 30.9932 68.527 31.1481 68.96 31.458C69.3975 31.7633 69.7301 32.2008 69.958 32.7705C70.1859 33.3356 70.2998 33.9987 70.2998 34.7598C70.2998 35.5163 70.1813 36.1794 69.9443 36.749C69.7074 37.3141 69.3656 37.7516 68.9189 38.0615C68.4723 38.3714 67.9482 38.5264 67.3467 38.5264ZM67.1621 37.46C67.554 37.46 67.8867 37.3483 68.1602 37.125C68.4336 36.8971 68.6387 36.5827 68.7754 36.1816C68.9121 35.776 68.9805 35.3066 68.9805 34.7734C68.9805 34.2402 68.9121 33.7708 68.7754 33.3652C68.6387 32.9551 68.4336 32.6361 68.1602 32.4082C67.8867 32.1758 67.554 32.0596 67.1621 32.0596C66.7702 32.0596 66.4329 32.1758 66.1504 32.4082C65.8724 32.6361 65.6605 32.9551 65.5146 33.3652C65.3734 33.7708 65.3027 34.2402 65.3027 34.7734C65.3027 35.3021 65.3734 35.7692 65.5146 36.1748C65.6605 36.5804 65.8724 36.8971 66.1504 37.125C66.4329 37.3483 66.7702 37.46 67.1621 37.46ZM64.0381 28.5254H65.2959V31.1914H64.0381V28.5254Z" fill="black"/>
<path d="M58.5762 38.5264C58.1341 38.5264 57.7354 38.4421 57.3799 38.2734C57.0244 38.1003 56.7441 37.8542 56.5391 37.5352C56.3385 37.2161 56.2383 36.8447 56.2383 36.4209C56.2383 35.7692 56.4342 35.2633 56.8262 34.9033C57.2227 34.5387 57.7764 34.2995 58.4873 34.1855L59.6221 34.001C59.9137 33.9508 60.1416 33.8962 60.3057 33.8369C60.4697 33.7731 60.5905 33.6911 60.668 33.5908C60.7454 33.486 60.7842 33.347 60.7842 33.1738C60.7842 32.9824 60.7318 32.8001 60.627 32.627C60.5221 32.4538 60.3604 32.3125 60.1416 32.2031C59.9229 32.0938 59.6517 32.0391 59.3281 32.0391C58.8724 32.0391 58.501 32.1598 58.2139 32.4014C57.9313 32.6383 57.7764 32.9665 57.749 33.3857H56.4297C56.4434 32.93 56.5732 32.5221 56.8193 32.1621C57.0654 31.7975 57.4049 31.5127 57.8379 31.3076C58.2708 31.098 58.7676 30.9932 59.3281 30.9932C59.8978 30.9932 60.3877 31.0957 60.7979 31.3008C61.208 31.5013 61.5202 31.7952 61.7344 32.1826C61.9531 32.57 62.0625 33.0326 62.0625 33.5703V36.6738C62.0625 37.0065 62.0853 37.3141 62.1309 37.5967C62.181 37.8747 62.2516 38.0524 62.3428 38.1299V38.3281H61.0098C60.9551 38.1322 60.9095 37.9043 60.873 37.6445C60.8411 37.3802 60.8252 37.1227 60.8252 36.8721L61.0508 36.9404C60.9414 37.2367 60.7682 37.5055 60.5312 37.7471C60.2943 37.9886 60.0072 38.18 59.6699 38.3213C59.3327 38.458 58.9681 38.5264 58.5762 38.5264ZM58.8564 37.4736C59.2529 37.4736 59.5993 37.3848 59.8955 37.207C60.1917 37.0247 60.4173 36.7809 60.5723 36.4756C60.7318 36.1657 60.8115 35.8216 60.8115 35.4434V34.377L60.9551 34.3975C60.8229 34.5479 60.6543 34.6663 60.4492 34.7529C60.2487 34.8395 59.9775 34.917 59.6357 34.9854L58.8838 35.1357C58.4235 35.2314 58.0863 35.3773 57.8721 35.5732C57.6579 35.7646 57.5508 36.0335 57.5508 36.3799C57.5508 36.7126 57.6738 36.9792 57.9199 37.1797C58.1706 37.3757 58.4827 37.4736 58.8564 37.4736Z" fill="black"/>
<path d="M49.1768 31.1914H50.5166L52.5332 37.084H52.2939L54.2695 31.1914H55.5479L52.957 38.3281H51.7949L49.1768 31.1914Z" fill="black"/>
<path d="M45.2666 38.5264C44.6286 38.5264 44.068 38.3714 43.585 38.0615C43.1019 37.7516 42.7282 37.3118 42.4639 36.7422C42.2041 36.1725 42.0742 35.5094 42.0742 34.7529C42.0742 33.9964 42.2041 33.3356 42.4639 32.7705C42.7282 32.2008 43.1019 31.7633 43.585 31.458C44.068 31.1481 44.6286 30.9932 45.2666 30.9932C45.9046 30.9932 46.4652 31.1481 46.9482 31.458C47.4313 31.7633 47.8027 32.2008 48.0625 32.7705C48.3268 33.3356 48.459 33.9964 48.459 34.7529C48.459 35.5094 48.3268 36.1725 48.0625 36.7422C47.8027 37.3118 47.4313 37.7516 46.9482 38.0615C46.4652 38.3714 45.9046 38.5264 45.2666 38.5264ZM45.2666 37.46C45.6585 37.46 45.9935 37.3551 46.2715 37.1455C46.554 36.9313 46.7682 36.6214 46.9141 36.2158C47.0645 35.8102 47.1396 35.3226 47.1396 34.7529C47.1396 33.8962 46.9756 33.2331 46.6475 32.7637C46.3193 32.2943 45.859 32.0596 45.2666 32.0596C44.8747 32.0596 44.5374 32.1644 44.2549 32.374C43.9769 32.5837 43.7627 32.8913 43.6123 33.2969C43.4665 33.6979 43.3936 34.1833 43.3936 34.7529C43.3936 35.3226 43.4665 35.8102 43.6123 36.2158C43.7627 36.6214 43.9769 36.9313 44.2549 37.1455C44.5374 37.3551 44.8747 37.46 45.2666 37.46Z" fill="black"/>
<path d="M35.8672 28.5254H37.1523V37.5557L36.9336 37.166H41.4248V38.3281H35.8672V28.5254Z" fill="black"/>
<path d="M25.6064 28.5254H26.8643V34.6299H26.5293L29.6465 31.1914H31.2461L26.3311 36.4961L26.8643 35.4023V38.3281H25.6064V28.5254ZM27.7051 34.4932L28.3887 33.3037L31.5264 38.3281H30.0156L27.7051 34.4932Z" fill="black"/>
<path d="M21.2109 38.5264C20.5911 38.5264 20.0511 38.4261 19.5908 38.2256C19.1351 38.0205 18.7819 37.7311 18.5312 37.3574C18.2806 36.9837 18.1507 36.5417 18.1416 36.0312H19.4609C19.4792 36.487 19.6501 36.8402 19.9736 37.0908C20.2972 37.3369 20.7188 37.46 21.2383 37.46C21.6758 37.46 22.0267 37.3643 22.291 37.1729C22.5599 36.9814 22.6943 36.7217 22.6943 36.3936C22.6943 36.1292 22.5986 35.9219 22.4072 35.7715C22.2204 35.6211 21.915 35.5026 21.4912 35.416L20.3154 35.1768C19.6273 35.04 19.1214 34.8145 18.7979 34.5C18.4743 34.181 18.3125 33.7686 18.3125 33.2627C18.3125 32.8343 18.4242 32.4492 18.6475 32.1074C18.8708 31.7611 19.1921 31.4899 19.6113 31.2939C20.0306 31.0934 20.5251 30.9932 21.0947 30.9932C21.6781 30.9932 22.1771 31.0957 22.5918 31.3008C23.0065 31.5059 23.3232 31.7907 23.542 32.1553C23.7607 32.5153 23.8792 32.9255 23.8975 33.3857H22.6055C22.5872 32.9665 22.4391 32.6383 22.1611 32.4014C21.8877 32.1598 21.5231 32.0391 21.0674 32.0391C20.7803 32.0391 20.5296 32.0846 20.3154 32.1758C20.1012 32.2624 19.9349 32.3877 19.8164 32.5518C19.6979 32.7113 19.6387 32.8958 19.6387 33.1055C19.6387 33.3424 19.7207 33.5316 19.8848 33.6729C20.0488 33.8141 20.3086 33.9189 20.6641 33.9873L21.9766 34.2402C22.4141 34.3268 22.7832 34.4567 23.084 34.6299C23.3848 34.7985 23.6126 35.015 23.7676 35.2793C23.9271 35.5436 24.0068 35.8512 24.0068 36.2021C24.0068 36.6943 23.8838 37.1159 23.6377 37.4668C23.3962 37.8177 23.0635 38.082 22.6396 38.2598C22.2158 38.4375 21.7396 38.5264 21.2109 38.5264Z" fill="black"/>
<path d="M12.3926 28.5254H13.917L17.417 38.3281H16.0293L13.0215 29.5508H13.2607L10.2188 38.3281H8.8584L12.3926 28.5254ZM10.8203 34.1992H15.5986V35.3477H10.8203V34.1992Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 28 KiB

14
public/robots.txt Normal file
View File

@@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

249
server/auth.ts Normal file
View File

@@ -0,0 +1,249 @@
import type { Express, Request, Response } from "express";
import type { RowDataPacket } from "mysql2";
import type mysql from "mysql2/promise";
import argon2 from "argon2";
import bcrypt from "bcryptjs";
const IDENTITY_CANDIDATES = [
"email",
"unique_name",
"phone",
"mobile",
"username",
"user_name",
"login_name",
"account",
"name",
"user_unique_id",
];
const PASSWORD_CANDIDATES = [
"password",
"passwd",
"pwd",
"user_password",
"password_hash",
"user_pwd",
"enc_password",
];
function safeIdent(raw: string): string {
if (!/^[a-zA-Z0-9_]+$/.test(raw)) {
throw new Error("Invalid SQL identifier");
}
return `\`${raw}\``;
}
/** Logical name from env; defaults to MySQL table `user` in database DB_NAME / MYSQL_DATABASE. */
function authTable(): string {
return process.env.AUTH_USER_TABLE?.trim() || process.env.DB_USER_TABLE?.trim() || "user";
}
/** Match INFORMATION_SCHEMA even when MySQL stores the name as User / USER. */
async function resolveActualTableName(
pool: mysql.Pool,
schema: string,
configured: string,
): Promise<string> {
const [exact] = await pool.query<RowDataPacket[]>(
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1",
[schema, configured],
);
if (exact.length > 0) return String(exact[0].TABLE_NAME);
const [folded] = await pool.query<RowDataPacket[]>(
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND LOWER(TABLE_NAME) = LOWER(?) LIMIT 1",
[schema, configured],
);
if (folded.length > 0) {
const actual = String(folded[0].TABLE_NAME);
if (actual !== configured) {
console.log(`[auth] resolved table name "${configured}" -> "${actual}"`);
}
return actual;
}
throw new Error(`No table matching "${configured}" in database "${schema}"`);
}
async function resolveColumns(
pool: mysql.Pool,
schema: string,
table: string,
): Promise<{ identityCols: string[]; passwordCol: string | null }> {
const [rows] = await pool.query<RowDataPacket[]>(
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?",
[schema, table],
);
const cols = new Set(rows.map((r) => String(r.COLUMN_NAME)));
const envLogin = process.env.AUTH_LOGIN_COLUMNS?.split(",")
.map((s) => s.trim())
.filter(Boolean);
const envPass = process.env.AUTH_PASSWORD_COLUMN?.trim();
const identityCols = envLogin?.length
? envLogin.filter((c) => cols.has(c))
: IDENTITY_CANDIDATES.filter((c) => cols.has(c));
const passwordCol =
envPass && cols.has(envPass)
? envPass
: PASSWORD_CANDIDATES.find((c) => cols.has(c)) ?? null;
return { identityCols, passwordCol };
}
let cachedColumns: { identityCols: string[]; passwordCol: string | null } | null = null;
let cachedTable = "";
async function getAuthColumns(pool: mysql.Pool, schema: string, table: string) {
if (cachedColumns && cachedTable === `${schema}.${table}`) return cachedColumns;
cachedColumns = await resolveColumns(pool, schema, table);
cachedTable = `${schema}.${table}`;
console.log(
`[auth] table=${table} identity=${cachedColumns.identityCols.join(",") || "(none)"} password=${cachedColumns.passwordCol ?? "(none)"}`,
);
return cachedColumns;
}
async function verifyPassword(plain: string, stored: string | null | undefined): Promise<boolean> {
if (stored == null) return false;
const s = String(stored);
if (s.startsWith("$argon2")) {
try {
return await argon2.verify(s, plain);
} catch {
return false;
}
}
if (s.length >= 59 && /^\$2[aby]\$\d{2}\$/.test(s)) {
try {
return await bcrypt.compare(plain, s);
} catch {
return false;
}
}
return plain === s;
}
function pickDisplayName(row: RowDataPacket, login: string): string {
const nick =
row.nickname ??
row.nick_name ??
row.name ??
row.user_name ??
row.username ??
row.unique_name;
if (nick != null && String(nick).trim() !== "") return String(nick);
return login;
}
function pickUserId(row: RowDataPacket): string | number | null {
const v = row.id ?? row.user_id ?? row.uid;
if (v != null && v !== "") return v as string | number;
return null;
}
export function registerAuthRoutes(app: Express, getPool: () => mysql.Pool): void {
app.get("/api/auth/status", async (_req: Request, res: Response) => {
try {
const pool = getPool();
const schema = process.env.MYSQL_DATABASE || process.env.DB_NAME || "";
const configured = authTable();
const resolved = await resolveActualTableName(pool, schema, configured);
const { identityCols, passwordCol } = await getAuthColumns(pool, schema, resolved);
res.json({
ok: true,
database: schema,
userTable: { configured, resolved },
loginColumns: identityCols,
passwordColumn: passwordCol,
});
} catch (err) {
console.error("[auth/status]", err);
res.status(503).json({
ok: false,
error: err instanceof Error ? err.message : "Auth status unavailable",
});
}
});
app.post("/api/auth/login", async (req: Request, res: Response) => {
const login = typeof req.body?.login === "string" ? req.body.login.trim() : "";
const password = typeof req.body?.password === "string" ? req.body.password : "";
if (!login || !password) {
res.status(400).json({ ok: false, error: "Missing login or password" });
return;
}
try {
const pool = getPool();
const schema = process.env.MYSQL_DATABASE || process.env.DB_NAME || "";
const configuredTable = authTable();
const table = await resolveActualTableName(pool, schema, configuredTable);
const { identityCols, passwordCol } = await getAuthColumns(pool, schema, table);
if (!passwordCol) {
console.error(
`[auth/login] Table "${table}" has no known password column; set AUTH_PASSWORD_COLUMN in .env`,
);
res.status(503).json({ ok: false, error: "Server auth is not configured" });
return;
}
if (identityCols.length === 0) {
console.error(
`[auth/login] Table "${table}" has no known login columns; set AUTH_LOGIN_COLUMNS in .env`,
);
res.status(503).json({ ok: false, error: "Server auth is not configured" });
return;
}
const t = safeIdent(table);
const orClause = identityCols.map((c) => `${safeIdent(c)} = ?`).join(" OR ");
const params = identityCols.map(() => login);
const sql = `SELECT * FROM ${t} WHERE ${orClause} LIMIT 1`;
const [found] = await pool.query<RowDataPacket[]>(sql, params);
const row = found[0];
if (!row) {
res.status(401).json({ ok: false, error: "Invalid credentials" });
return;
}
const storedHash = row[passwordCol] as string | null | undefined;
const ok = await verifyPassword(password, storedHash);
if (!ok) {
res.status(401).json({ ok: false, error: "Invalid credentials" });
return;
}
res.json({
ok: true,
user: {
id: pickUserId(row),
displayName: pickDisplayName(row, login),
login,
},
});
} catch (err) {
console.error("[auth/login]", err);
res.status(503).json({ ok: false, error: "Login temporarily unavailable" });
}
});
app.post("/api/auth/admin", async (req: Request, res: Response) => {
const password = typeof req.body?.password === "string" ? req.body.password : "";
const expected = process.env.ADMIN_PASSWORD ?? "admin123";
if (!password) {
res.status(400).json({ ok: false, error: "Missing password" });
return;
}
const match = await verifyPassword(password, expected);
if (!match) {
res.status(401).json({ ok: false, error: "Invalid admin password" });
return;
}
res.json({ ok: true });
});
}

225
server/courseVideos.ts Normal file
View File

@@ -0,0 +1,225 @@
import type { Express, Request, Response } from "express";
import type { RowDataPacket, ResultSetHeader } from "mysql2";
import type mysql from "mysql2/promise";
import fs from "fs";
import path from "path";
import { randomUUID } from "crypto";
import multer from "multer";
const TABLE = "youwei_course_video";
const UPLOAD_SUBDIR = "videos";
function uploadsRoot(): string {
return path.join(process.cwd(), "uploads");
}
function videosDir(): string {
return path.join(uploadsRoot(), UPLOAD_SUBDIR);
}
export async function ensureCourseVideoTable(pool: mysql.Pool): Promise<void> {
await pool.execute(`
CREATE TABLE IF NOT EXISTS ${TABLE} (
id VARCHAR(36) NOT NULL PRIMARY KEY,
title VARCHAR(512) NOT NULL,
course_key VARCHAR(32) NOT NULL,
course_label_zh VARCHAR(128) NOT NULL,
course_label_en VARCHAR(128) NOT NULL,
module_name VARCHAR(256) NOT NULL DEFAULT '',
file_relpath VARCHAR(1024) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
views INT NOT NULL DEFAULT 0,
duration VARCHAR(32) NOT NULL DEFAULT '00:00',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
}
function mapRow(r: RowDataPacket) {
return {
id: r.id,
title: r.title,
courseKey: r.course_key,
courseLabelZh: r.course_label_zh,
courseLabelEn: r.course_label_en,
moduleName: r.module_name,
videoUrl: r.file_relpath,
status: r.status,
views: r.views,
duration: r.duration,
uploadDate: r.created_at instanceof Date ? r.created_at.toISOString().split("T")[0] : String(r.created_at).split(" ")[0],
};
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
const dir = videosDir();
fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname) || ".mp4";
cb(null, `${randomUUID()}${ext}`);
},
});
const uploadMw = multer({
storage,
limits: { fileSize: 500 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype.startsWith("video/")) {
cb(null, true);
return;
}
cb(new Error("Only video files are allowed"));
},
});
export function registerCourseVideoRoutes(app: Express, getPool: () => mysql.Pool): void {
app.get("/api/course-videos", async (req: Request, res: Response) => {
try {
const pool = getPool();
await ensureCourseVideoTable(pool);
const courseKey = typeof req.query.courseKey === "string" ? req.query.courseKey.trim() : "";
const scope = typeof req.query.scope === "string" ? req.query.scope : "";
const isAdmin = scope === "admin";
let sql = `SELECT * FROM ${TABLE}`;
const params: string[] = [];
const where: string[] = [];
if (!isAdmin) {
where.push("status = ?");
params.push("published");
}
if (courseKey) {
where.push("course_key = ?");
params.push(courseKey);
}
if (where.length) sql += ` WHERE ${where.join(" AND ")}`;
sql += " ORDER BY created_at DESC";
const [rows] = await pool.query<RowDataPacket[]>(sql, params);
res.json({ ok: true, videos: rows.map(mapRow) });
} catch (err) {
console.error("[course-videos GET]", err);
res.status(503).json({ ok: false, error: "Could not load videos" });
}
});
app.post(
"/api/course-videos",
(req, res, next) => {
uploadMw.single("file")(req, res, (e) => {
if (e) {
res.status(400).json({ ok: false, error: e instanceof Error ? e.message : "Upload failed" });
return;
}
next();
});
},
async (req: Request, res: Response) => {
try {
const pool = getPool();
await ensureCourseVideoTable(pool);
const file = req.file;
if (!file) {
res.status(400).json({ ok: false, error: "Video file is required" });
return;
}
const title = String(req.body.title || "").trim();
const courseKey = String(req.body.courseKey || "").trim();
const courseLabelZh = String(req.body.courseLabelZh || "").trim();
const courseLabelEn = String(req.body.courseLabelEn || "").trim();
const moduleName = String(req.body.moduleName || "").trim();
const statusRaw = String(req.body.status || "published").trim().toLowerCase();
const status = statusRaw === "draft" ? "draft" : "published";
if (!title || !courseKey) {
fs.unlink(file.path, () => {});
res.status(400).json({ ok: false, error: "Missing title or courseKey" });
return;
}
const id = randomUUID();
const rel = `/uploads/${UPLOAD_SUBDIR}/${file.filename}`;
await pool.execute(
`INSERT INTO ${TABLE} (id, title, course_key, course_label_zh, course_label_en, module_name, file_relpath, status, views, duration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, '00:00')`,
[id, title, courseKey, courseLabelZh || courseKey, courseLabelEn || courseKey, moduleName, rel, status],
);
const [rows] = await pool.query<RowDataPacket[]>(`SELECT * FROM ${TABLE} WHERE id = ?`, [id]);
res.json({ ok: true, video: rows[0] ? mapRow(rows[0]) : null });
} catch (err) {
console.error("[course-videos POST]", err);
res.status(503).json({ ok: false, error: "Could not save video" });
}
},
);
app.patch("/api/course-videos/:id", async (req: Request, res: Response) => {
try {
const pool = getPool();
await ensureCourseVideoTable(pool);
const id = req.params.id;
const status = typeof req.body?.status === "string" ? req.body.status.trim().toLowerCase() : "";
if (status !== "published" && status !== "draft" && status !== "processing") {
res.status(400).json({ ok: false, error: "Invalid status" });
return;
}
const [result] = await pool.execute<ResultSetHeader>(
`UPDATE ${TABLE} SET status = ? WHERE id = ?`,
[status, id],
);
if (result.affectedRows === 0) {
res.status(404).json({ ok: false, error: "Not found" });
return;
}
const [rows] = await pool.query<RowDataPacket[]>(`SELECT * FROM ${TABLE} WHERE id = ?`, [id]);
res.json({ ok: true, video: rows[0] ? mapRow(rows[0]) : null });
} catch (err) {
console.error("[course-videos PATCH]", err);
res.status(503).json({ ok: false, error: "Could not update video" });
}
});
app.delete("/api/course-videos/:id", async (req: Request, res: Response) => {
try {
const pool = getPool();
await ensureCourseVideoTable(pool);
const id = req.params.id;
const [rows] = await pool.query<RowDataPacket[]>(`SELECT file_relpath FROM ${TABLE} WHERE id = ?`, [id]);
const row = rows[0];
if (!row) {
res.status(404).json({ ok: false, error: "Not found" });
return;
}
const rel = String(row.file_relpath || "");
await pool.execute(`DELETE FROM ${TABLE} WHERE id = ?`, [id]);
if (rel.startsWith("/uploads/")) {
const abs = path.join(process.cwd(), rel.replace(/^\//, ""));
fs.unlink(abs, () => {});
}
res.json({ ok: true });
} catch (err) {
console.error("[course-videos DELETE]", err);
res.status(503).json({ ok: false, error: "Could not delete video" });
}
});
app.post("/api/course-videos/:id/view", async (req: Request, res: Response) => {
try {
const pool = getPool();
await ensureCourseVideoTable(pool);
const id = req.params.id;
await pool.execute(`UPDATE ${TABLE} SET views = views + 1 WHERE id = ?`, [id]);
res.json({ ok: true });
} catch (err) {
console.error("[course-videos view]", err);
res.status(503).json({ ok: false });
}
});
}

78
server/index.ts Normal file
View File

@@ -0,0 +1,78 @@
/**
* Local API for MySQL. Keeps credentials off the client.
* Run: npm run server, or npm run dev (starts API + Vite together).
*/
import "dotenv/config";
import cors from "cors";
import express from "express";
import path from "path";
import type { RowDataPacket } from "mysql2";
import mysql from "mysql2/promise";
import { registerAuthRoutes } from "./auth";
import { registerCourseVideoRoutes } from "./courseVideos";
const app = express();
app.use(express.json());
app.use(
cors({
origin: process.env.CORS_ORIGIN?.split(",").map((s) => s.trim()) ?? true,
}),
);
const apiPort = Number(process.env.API_PORT ?? 3001);
const apiHost = process.env.API_HOST ?? "127.0.0.1";
function sslOption(): boolean | { rejectUnauthorized: boolean } | undefined {
if (process.env.MYSQL_SSL !== "true") return undefined;
if (process.env.MYSQL_SSL_INSECURE === "true") {
return { rejectUnauthorized: false };
}
return true;
}
let pool: mysql.Pool | null = null;
function getPool(): mysql.Pool {
if (pool) return pool;
const host = process.env.MYSQL_HOST || process.env.DB_HOST;
const user = process.env.MYSQL_USER || process.env.DB_USER;
const database = process.env.MYSQL_DATABASE || process.env.DB_NAME;
if (!host || !user || !database) {
throw new Error("Set DB_HOST, DB_USER, DB_NAME (or MYSQL_*) in .env");
}
const portEnv = process.env.MYSQL_PORT || process.env.DB_PORT;
pool = mysql.createPool({
host,
port: Number(portEnv ?? 3306),
user,
password: process.env.MYSQL_PASSWORD ?? process.env.DB_PASSWORD ?? "",
database,
waitForConnections: true,
connectionLimit: Number(process.env.MYSQL_CONNECTION_LIMIT ?? 5),
ssl: sslOption(),
});
return pool;
}
registerAuthRoutes(app, getPool);
registerCourseVideoRoutes(app, getPool);
app.use("/uploads", express.static(path.join(process.cwd(), "uploads")));
app.get("/api/health", (_req, res) => {
res.json({ ok: true });
});
app.get("/api/db/ping", async (_req, res) => {
try {
const [rows] = await getPool().query<RowDataPacket[]>("SELECT 1 AS ok");
res.json({ ok: true, rows });
} catch (err) {
console.error("[db/ping]", err);
res.status(503).json({ ok: false, error: "Database unreachable" });
}
});
app.listen(apiPort, apiHost, () => {
console.log(`API listening on http://${apiHost}:${apiPort}`);
});

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

38
src/App.tsx Normal file
View File

@@ -0,0 +1,38 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { LanguageProvider } from "@/contexts/LanguageContext";
import { UserProvider } from "@/contexts/UserContext";
import Index from "./pages/Index.tsx";
import Login from "./pages/Login.tsx";
import Admin from "./pages/Admin.tsx";
import AdminLogin from "./pages/AdminLogin.tsx";
import NotFound from "./pages/NotFound.tsx";
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<LanguageProvider>
<UserProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/home" element={<Index />} />
<Route path="/admin" element={<AdminLogin />} />
<Route path="/admin/dashboard" element={<Admin />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</TooltipProvider>
</UserProvider>
</LanguageProvider>
</QueryClientProvider>
);
export default App;

BIN
src/assets/ai-girl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

BIN
src/assets/hero-bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
src/assets/qr-code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,52 @@
import { motion, AnimatePresence } from "framer-motion";
import { Rocket } from "lucide-react";
import { useLang } from "@/contexts/LanguageContext";
interface ComingSoonModalProps {
open: boolean;
onClose: () => void;
}
export default function ComingSoonModal({ open, onClose }: ComingSoonModalProps) {
const { lang } = useLang();
// Auto-dismiss after 3 seconds
if (open) {
setTimeout(onClose, 3000);
}
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -100, opacity: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="fixed top-6 inset-x-0 mx-auto w-fit z-[60] cursor-pointer"
onClick={onClose}
>
<div className="relative px-8 py-4 rounded-2xl bg-gradient-to-r from-primary via-accent to-primary text-primary-foreground shadow-[0_8px_32px_rgba(0,0,0,0.25)] flex items-center gap-3 overflow-hidden">
{/* Shimmer effect */}
<motion.div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent"
animate={{ x: ["-100%", "200%"] }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
/>
<motion.div
animate={{ y: [0, -4, 0], rotate: [0, 10, -10, 0] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<Rocket className="w-5 h-5" />
</motion.div>
<span className="font-bold text-sm tracking-wide relative z-10">
{lang === "zh" ? "🚀 即将上线,敬请期待!" : "🚀 Coming Soon — Stay Tuned!"}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,200 @@
import { useState, useRef, useEffect } from "react";
import { useLang } from "@/contexts/LanguageContext";
import { X, Send, Headset } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import aiGirlImg from "@/assets/ai-girl.png";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
const knowledgeBase: Record<string, { keywords: string[]; answer: string }[]> = {
zh: [
{ keywords: ["有维", "平台", "是什么", "介绍", "关于"], answer: "有维商学是一个面向OPC创业者的综合赋能平台提供商业教育课程、AI工具集、校友网络和会员体系帮助创业者系统掌握商业知识并实现业务增长。" },
{ keywords: ["课程", "教育", "学习", "培训"], answer: "有维教育提供四大核心课程:商业模式与商业画布、产品设计与用户体验、全域营销策略、以及运营管理体系。每门课程包含视频课程、实操作业和配套资料。" },
{ keywords: ["AI", "工具", "人工智能", "智能"], answer: "有维AI工具集为创业者提供智能助手包括商业计划书生成、市场分析、营销文案撰写等AI驱动的效率工具帮助OPC创业者降低运营成本。" },
{ keywords: ["会员", "价格", "费用", "加入"], answer: "有维提供多层级会员体系,包含不同权益和服务。您可以通过添加客服微信了解详细的会员方案和价格信息。" },
{ keywords: ["校友", "社群", "网络", "交流"], answer: "有维校友网络汇聚了2000+OPC创业者提供线上线下交流活动、资源对接和互助成长的社群环境。" },
{ keywords: ["联系", "客服", "咨询", "微信"], answer: "您可以在课程详情页面点击「添加客服获取更多课程」按钮,扫描二维码添加客服微信进行详细咨询。" },
],
en: [
{ keywords: ["youwei", "platform", "what", "about", "intro"], answer: "Youwei Business School is a comprehensive empowerment platform for OPC entrepreneurs, offering business education, AI tools, alumni network, and membership system." },
{ keywords: ["course", "education", "learn", "training"], answer: "Youwei Education offers four core courses: Business Models, Product Design, Marketing Strategy, and Operations Management, each with video lessons and hands-on assignments." },
{ keywords: ["AI", "tool", "artificial", "intelligent"], answer: "Youwei AI Toolkit provides smart assistants for business plan generation, market analysis, and marketing copywriting to help OPC entrepreneurs reduce operational costs." },
{ keywords: ["member", "price", "cost", "join"], answer: "Youwei offers a multi-tier membership system with different benefits. Contact customer service for detailed plans and pricing." },
{ keywords: ["alumni", "community", "network"], answer: "Youwei Alumni Network brings together 2000+ OPC entrepreneurs for online and offline exchange, resource connections, and mutual growth." },
{ keywords: ["contact", "service", "consult"], answer: "You can click the 'Add Service' button on course detail pages and scan the QR code to contact customer service." },
],
};
const defaultResponses = {
zh: "感谢您的提问我是有维智能助手可以回答关于平台、课程、AI工具、会员体系等方面的问题。请问您想了解什么呢",
en: "Thanks for your question! I'm the Youwei AI assistant. I can answer questions about the platform, courses, AI tools, and membership. What would you like to know?",
};
const greetings = {
zh: "您好!👋 我是有维智能客服很高兴为您服务。您可以问我关于有维平台、课程内容、AI工具、会员体系等任何问题。",
en: "Hello! 👋 I'm the Youwei AI assistant. You can ask me about the platform, courses, AI tools, membership, and more.",
};
type Msg = { role: "user" | "assistant"; content: string };
function getAnswer(input: string, lang: "zh" | "en"): string {
const kb = knowledgeBase[lang] || knowledgeBase.zh;
const lower = input.toLowerCase();
for (const entry of kb) {
if (entry.keywords.some((kw) => lower.includes(kw))) return entry.answer;
}
return defaultResponses[lang] || defaultResponses.zh;
}
export default function FloatingHelpdesk() {
const { lang, t } = useLang();
const [open, setOpen] = useState(false);
const [showHumanQR, setShowHumanQR] = useState(false);
const [messages, setMessages] = useState<Msg[]>([]);
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open && messages.length === 0) {
setMessages([{ role: "assistant", content: greetings[lang] || greetings.zh }]);
}
}, [open]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages]);
const send = () => {
const trimmed = input.trim();
if (!trimmed) return;
const userMsg: Msg = { role: "user", content: trimmed };
setMessages((prev) => [...prev, userMsg]);
setInput("");
setTimeout(() => {
const answer = getAnswer(trimmed, lang);
setMessages((prev) => [...prev, { role: "assistant", content: answer }]);
}, 500);
};
return (
<>
{/* Floating avatar button */}
<motion.button
onClick={() => setOpen(!open)}
className="fixed bottom-6 right-6 z-50 w-16 h-16 rounded-full shadow-lg shadow-primary/30 overflow-hidden border-2 border-primary/50"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
animate={!open ? { y: [0, -6, 0] } : {}}
transition={!open ? { duration: 2, repeat: Infinity, ease: "easeInOut" } : {}}
>
<img src={aiGirlImg} alt="AI Assistant" className="w-full h-full object-cover bg-background" width={512} height={512} />
{!open && (
<motion.div
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-primary border-2 border-background"
animate={{ scale: [1, 1.3, 1] }}
transition={{ duration: 1.5, repeat: Infinity }}
/>
)}
</motion.button>
{/* Chat panel */}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.25 }}
className="fixed bottom-24 right-6 z-50 w-80 sm:w-96 glass-card-elevated overflow-hidden flex flex-col"
style={{ maxHeight: "min(500px, 70vh)" }}
>
{/* Header */}
<div className="p-4 border-b border-border/30 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-sm">🤖</div>
<div className="flex-1">
<p className="text-sm font-semibold text-foreground">{lang === "zh" ? "有维智能客服" : "Youwei AI Help"}</p>
<p className="text-[10px] text-muted-foreground">{lang === "zh" ? "在线" : "Online"}</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => { setShowHumanQR(true); setOpen(false); }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent text-accent-foreground text-xs font-medium border border-border/30 hover:bg-accent/80 transition-colors"
>
<Headset className="w-3.5 h-3.5" />
{lang === "zh" ? "转人工" : "Human"}
</motion.button>
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-3" style={{ minHeight: 200 }}>
{messages.map((msg, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[80%] px-3 py-2 rounded-2xl text-sm leading-relaxed ${
msg.role === "user"
? "bg-primary text-primary-foreground rounded-br-md"
: "bg-muted/50 text-foreground rounded-bl-md"
}`}
>
{msg.content}
</div>
</motion.div>
))}
</div>
{/* Input */}
<div className="p-3 border-t border-border/30 flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
placeholder={lang === "zh" ? "请输入您的问题..." : "Ask a question..."}
className="flex-1 bg-muted/30 rounded-xl px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-primary/50"
/>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={send}
className="w-9 h-9 rounded-xl bg-primary text-primary-foreground flex items-center justify-center shrink-0"
>
<Send className="w-4 h-4" />
</motion.button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Human assistant QR dialog */}
<Dialog open={showHumanQR} onOpenChange={setShowHumanQR}>
<DialogContent className="sm:max-w-md border-border/30 bg-background z-[100]" style={{ zIndex: 100 }}>
<DialogHeader>
<DialogTitle className="text-center text-foreground flex items-center justify-center gap-2">
<Headset className="w-5 h-5 text-primary" />
{lang === "zh" ? "联系人工客服" : "Contact Human Support"}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
<p className="text-sm text-muted-foreground text-center">
{lang === "zh"
? "扫描下方二维码添加客服微信,我们的专业团队将为您提供一对一服务。"
: "Scan the QR code below to add our customer service on WeChat for one-on-one support."}
</p>
<div className="w-48 h-48 bg-muted/30 rounded-xl border border-border/30 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<div className="text-4xl mb-2">📱</div>
<p className="text-xs">{lang === "zh" ? "客服二维码" : "QR Code"}</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
{lang === "zh" ? "工作时间:周一至周五 9:00-18:00" : "Hours: Mon-Fri 9:00-18:00"}
</p>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,28 @@
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
className?: string;
activeClassName?: string;
pendingClassName?: string;
}
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
return (
<RouterNavLink
ref={ref}
to={to}
className={({ isActive, isPending }) =>
cn(className, isActive && activeClassName, isPending && pendingClassName)
}
{...props}
/>
);
},
);
NavLink.displayName = "NavLink";
export { NavLink };

215
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,215 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Globe, Bell, Crown, Settings, LogOut, Menu, X } from "lucide-react";
import logo from "@/assets/logo.png";
import { useLang } from "@/contexts/LanguageContext";
import { useUser } from "@/contexts/UserContext";
import { motion, AnimatePresence } from "framer-motion";
import ComingSoonModal from "@/components/ComingSoonModal";
const navItems = [
{ key: "nav.frontPage", tabKey: null },
{ key: "nav.aiAgent", tabKey: "tab.aiTools" },
{ key: "nav.learning", tabKey: "tab.education" },
{ key: "nav.community", tabKey: "tab.alumni" },
{ key: "kouzi", tabKey: "__kouzi__" },
];
interface NavbarProps {
onNavClick?: (tabKey: string | null) => void;
activeTab?: string;
}
export default function Navbar({ onNavClick, activeTab }: NavbarProps) {
const { t, lang, setLang } = useLang();
const { username, setUsername } = useUser();
const navigate = useNavigate();
const [profileOpen, setProfileOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
const isActive = (item: typeof navItems[0]) => {
if (item.tabKey === null) return !activeTab || activeTab === "tab.overview";
return activeTab === item.tabKey;
};
const [comingSoonOpen, setComingSoonOpen] = useState(false);
const handleNavClick = (tabKey: string | null) => {
if (tabKey === "__kouzi__") {
setComingSoonOpen(true);
return;
}
setMobileMenuOpen(false);
onNavClick?.(tabKey);
};
const handleLogout = () => {
setProfileOpen(false);
setMobileMenuOpen(false);
setUsername("");
navigate("/");
};
useEffect(() => {
const handler = (e: MouseEvent) => {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setProfileOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const displayInitial = username ? username.charAt(0).toUpperCase() : "U";
return (
<>
<div className="sticky top-0 z-50 px-3 sm:px-4 pt-3 sm:pt-4">
<nav className="glass-nav rounded-2xl max-w-5xl mx-auto px-4 sm:px-5 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<img src={logo} alt="有维商学" className="w-7 h-7 sm:w-8 sm:h-8 rounded-xl object-contain" />
<span className="font-semibold text-foreground tracking-tight text-xs sm:text-sm">{t("nav.brand")}</span>
</div>
{/* Desktop nav */}
<div className="hidden md:flex items-center gap-0">
{navItems.map((item) => (
<button
key={item.key}
onClick={() => handleNavClick(item.tabKey)}
className={`${
item.tabKey === "__kouzi__"
? "nav-tab-inactive"
: isActive(item)
? "nav-tab-active"
: "nav-tab-inactive"
} cursor-pointer transition-all duration-200`}
>
{item.key === "kouzi"
? lang === "zh"
? "扣子工作台"
: "Coze Workspace"
: t(item.key)}
</button>
))}
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<button
onClick={() => setLang(lang === "en" ? "zh" : "en")}
className="glass-pill flex items-center gap-1 sm:gap-1.5 px-2.5 sm:px-3 py-1.5 text-[10px] sm:text-xs font-medium text-foreground hover:scale-105 transition-transform duration-300"
>
<Globe className="w-3 h-3" />
{lang === "en" ? "中文" : "EN"}
</button>
<button className="relative w-7 h-7 sm:w-8 sm:h-8 flex items-center justify-center rounded-full hover:bg-muted/50 transition-colors">
<Bell className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground" />
<span className="absolute top-0.5 right-1 sm:top-1 sm:right-1.5 w-1.5 h-1.5 sm:w-2 sm:h-2 bg-destructive rounded-full" />
</button>
<div className="relative" ref={profileRef}>
<button
onClick={() => setProfileOpen(!profileOpen)}
className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-xs sm:text-sm font-bold hover:scale-110 transition-transform duration-200 shadow-lg shadow-primary/30"
>
{displayInitial}
</button>
<AnimatePresence>
{profileOpen && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="absolute right-0 top-full mt-2 w-52 sm:w-56 rounded-xl border border-border/50 bg-card/95 backdrop-blur-xl shadow-2xl shadow-black/20 overflow-hidden"
>
<div className="px-4 py-3 border-b border-border/30">
<p className="text-sm font-semibold text-foreground">{username || "User"}</p>
<p className="text-xs text-muted-foreground">{lang === "zh" ? "专业版会员" : "Pro Member"}</p>
</div>
<div className="py-1.5">
{[
{ icon: Crown, label: lang === "zh" ? "会员中心" : "Membership", emoji: "👑" },
{ icon: Settings, label: lang === "zh" ? "账户设置" : "Account Settings", emoji: "⚙️" },
].map((item) => (
<button
key={item.label}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-muted/50 transition-colors text-left"
onClick={() => {
setProfileOpen(false);
if (item.emoji === "👑") {
onNavClick?.("tab.membership");
}
}}
>
<span className="text-base">{item.emoji}</span>
<span>{item.label}</span>
</button>
))}
</div>
<div className="border-t border-border/30 py-1.5">
<button
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-destructive hover:bg-destructive/10 transition-colors text-left"
onClick={handleLogout}
>
<span className="text-base">📦</span>
<span>{lang === "zh" ? "退出登录" : "Log Out"}</span>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Mobile hamburger */}
<button
className="md:hidden w-7 h-7 flex items-center justify-center rounded-lg hover:bg-muted/50 transition-colors"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X className="w-4 h-4 text-foreground" /> : <Menu className="w-4 h-4 text-foreground" />}
</button>
</div>
</nav>
{/* Mobile menu */}
<AnimatePresence>
{mobileMenuOpen && (
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="md:hidden max-w-5xl mx-auto mt-2 glass-card rounded-xl overflow-hidden"
>
<div className="py-2">
{navItems.map((item) => (
<button
key={item.key}
onClick={() => handleNavClick(item.tabKey)}
className={`w-full text-left px-5 py-3 text-sm font-medium transition-colors ${
isActive(item) && item.tabKey !== "__kouzi__"
? "text-foreground bg-muted/30"
: "text-muted-foreground hover:text-foreground hover:bg-muted/20"
}`}
>
{item.key === "kouzi"
? lang === "zh"
? "扣子工作台"
: "Coze Workspace"
: t(item.key)}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<ComingSoonModal open={comingSoonOpen} onClose={() => setComingSoonOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,414 @@
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { Search, ArrowRight, Sparkles, Zap, X } from "lucide-react";
import { useLang } from "@/contexts/LanguageContext";
import { motion, AnimatePresence } from "framer-motion";
import ComingSoonModal from "@/components/ComingSoonModal";
const agents = [
{ nameKey: "ai.aism.name", tagKey: "ai.aism.tag", descKey: "ai.aism.desc", emoji: "🤖", tags: ["ai.tag.instantResponse", "ai.tag.multiRound", "ai.tag.problemTriage"], gradient: "from-blue-500/10 to-cyan-500/5", status: "online", category: "ai.customerService" },
{ nameKey: "ai.meeting.name", tagKey: "ai.meeting.tag", descKey: "ai.meeting.desc", emoji: "📝", tags: ["ai.tag.speechToText", "ai.tag.keyPoints", "ai.tag.taskAllocation"], gradient: "from-violet-500/10 to-purple-500/5", status: "online", category: "ai.efficiency" },
{ nameKey: "ai.deepseek.name", tagKey: "ai.deepseek.tag", descKey: "ai.deepseek.desc", emoji: "🧠", tags: ["ai.tag.deepUnderstanding", "ai.tag.accurate", "ai.tag.knowledgeable"], gradient: "from-emerald-500/10 to-teal-500/5", status: "online", category: "ai.analyze" },
{ nameKey: "ai.adminAssist.name", tagKey: "ai.adminAssist.tag", descKey: "ai.adminAssist.desc", emoji: "📋", tags: ["ai.tag.scheduling", "ai.tag.documents", "ai.tag.reminders"], gradient: "from-amber-500/10 to-orange-500/5", status: "busy", category: "ai.admin" },
{ nameKey: "ai.strategic.name", tagKey: "ai.strategic.tag", descKey: "ai.strategic.desc", emoji: "🎯", tags: ["ai.tag.swotAnalysis", "ai.tag.competitorResearch", "ai.tag.marketInsights"], gradient: "from-rose-500/10 to-pink-500/5", status: "online", category: "ai.analyze" },
{ nameKey: "ai.bizplan.name", tagKey: "ai.bizplan.tag", descKey: "ai.bizplan.desc", emoji: "📈", tags: ["ai.tag.templates", "ai.tag.dataAnalysis", "ai.tag.oneClickExport"], gradient: "from-indigo-500/10 to-blue-500/5", status: "online", category: "ai.efficiency" },
{ nameKey: "ai.marketReport.name", tagKey: "ai.marketReport.tag", descKey: "ai.marketReport.desc", emoji: "📊", tags: ["ai.tag.trendScan", "ai.tag.sectorBenchmark", "ai.tag.reportStructured"], gradient: "from-sky-500/10 to-blue-500/5", status: "online", category: "ai.analyze" },
{ nameKey: "ai.techReport.name", tagKey: "ai.techReport.tag", descKey: "ai.techReport.desc", emoji: "🔬", tags: ["ai.tag.techFeasibility", "ai.tag.riskReview", "ai.tag.implementationPath"], gradient: "from-teal-500/10 to-emerald-500/5", status: "online", category: "ai.analyze" },
{ nameKey: "ai.factorySourcing.name", tagKey: "ai.factorySourcing.tag", descKey: "ai.factorySourcing.desc", emoji: "🏭", tags: ["ai.tag.capacityMatch", "ai.tag.certAudit", "ai.tag.geoSourcing"], gradient: "from-orange-500/10 to-amber-500/5", status: "online", category: "ai.industrial" },
{ nameKey: "ai.patentCompare.name", tagKey: "ai.patentCompare.tag", descKey: "ai.patentCompare.desc", emoji: "📑", tags: ["ai.tag.claimDiff", "ai.tag.featureOverlap", "ai.tag.priorArtHint"], gradient: "from-fuchsia-500/10 to-purple-500/5", status: "online", category: "ai.analyze" },
{ nameKey: "ai.zoneInspection.name", tagKey: "ai.zoneInspection.tag", descKey: "ai.zoneInspection.desc", emoji: "🔍", tags: ["ai.tag.innovationClaims", "ai.tag.listingConsistency", "ai.tag.policyAlignment"], gradient: "from-lime-500/10 to-green-500/5", status: "online", category: "ai.analyze" },
{ nameKey: "ai.techPrice.name", tagKey: "ai.techPrice.tag", descKey: "ai.techPrice.desc", emoji: "💰", tags: ["ai.tag.comparableTx", "ai.tag.licensingContext", "ai.tag.priceRange"], gradient: "from-rose-500/10 to-orange-500/5", status: "online", category: "ai.analyze" },
{ nameKey: "ai.patentMicroNav.name", tagKey: "ai.patentMicroNav.tag", descKey: "ai.patentMicroNav.desc", emoji: "🧭", tags: ["ai.tag.citationMap", "ai.tag.techBranches", "ai.tag.landscapeBrief"], gradient: "from-cyan-500/10 to-sky-500/5", status: "online", category: "ai.analyze" },
];
const categories = ["ai.all", ...Array.from(new Set(agents.map((a) => a.category)))];
// Optional .env overrides: VITE_*_CHATBOT_URL for each bot (see vite-env.d.ts)
const MEETING_CHATBOT_IFRAME_SRC =
(import.meta.env.VITE_MEETING_CHATBOT_URL as string | undefined)?.trim() ||
"http://nw.sgcode.cn:18181/chat/CjmrnoUyQKBa9wEP";
const DEEPSEEK_CHATBOT_IFRAME_SRC =
(import.meta.env.VITE_DEEPSEEK_CHATBOT_URL as string | undefined)?.trim() ||
"http://nw.sgcode.cn:18181/chatbot/pOcS3NYaXCkOP9K6";
const AISM_CHATBOT_IFRAME_SRC =
(import.meta.env.VITE_AISM_CHATBOT_URL as string | undefined)?.trim() ||
"http://nw.sgcode.cn:18181/chatbot/grHow5qCsnZd7PYq";
const ADMIN_ASSIST_CHATBOT_IFRAME_SRC =
(import.meta.env.VITE_ADMIN_ASSIST_CHATBOT_URL as string | undefined)?.trim() ||
"http://nw.sgcode.cn:18181/chatbot/FIIxbn1rsEtmw2Yt";
const STRATEGIC_CHATBOT_IFRAME_SRC =
(import.meta.env.VITE_STRATEGIC_CHATBOT_URL as string | undefined)?.trim() ||
"http://nw.sgcode.cn:18181/chatbot/bKP9rLpTdWQgWSLA";
const BIZPLAN_CHATBOT_IFRAME_SRC =
(import.meta.env.VITE_BIZPLAN_CHATBOT_URL as string | undefined)?.trim() ||
"http://nw.sgcode.cn:18181/chatbot/u0mmSsO0YCtjh37K";
const MARKET_REPORT_CHATBOT_IFRAME_SRC =
(import.meta.env.VITE_MARKET_REPORT_CHATBOT_URL as string | undefined)?.trim() ||
"http://nw.sgcode.cn:18181/chatbot/SqVX9saxCfTdJq6F";
const TECH_REPORT_CHATBOT_IFRAME_SRC =
(import.meta.env.VITE_TECH_REPORT_CHATBOT_URL as string | undefined)?.trim() ||
"http://nw.sgcode.cn:18181/chatbot/S8fdxgEVKa3V1kWb";
const PATENT_COMPARE_CHATBOT_IFRAME_SRC =
(import.meta.env.VITE_PATENT_COMPARE_CHATBOT_URL as string | undefined)?.trim() ||
"http://nw.sgcode.cn:18181/chatbot/tvX9ytDVu0BonRZp";
const ZONE_INSPECTION_CHATBOT_IFRAME_SRC =
(import.meta.env.VITE_ZONE_INSPECTION_CHATBOT_URL as string | undefined)?.trim() ||
"http://nw.sgcode.cn:18181/chatbot/RUeFYK8U6gt9cncx";
const TECH_PRICE_CHATBOT_IFRAME_SRC =
(import.meta.env.VITE_TECH_PRICE_CHATBOT_URL as string | undefined)?.trim() ||
"http://nw.sgcode.cn:18181/chatbot/VDLRBetGCViMjEEy";
const PATENT_MICRO_NAV_CHATBOT_IFRAME_SRC =
(import.meta.env.VITE_PATENT_MICRO_NAV_CHATBOT_URL as string | undefined)?.trim() ||
"http://nw.sgcode.cn:18181/chatbot/pU0QjcVNDaz0mBzJ";
// Override with VITE_FACTORY_SOURCING_CHATBOT_URL if this id is not your factory bot.
const FACTORY_SOURCING_CHATBOT_IFRAME_SRC =
(import.meta.env.VITE_FACTORY_SOURCING_CHATBOT_URL as string | undefined)?.trim() ||
"http://nw.sgcode.cn:18181/chatbot/OstO0weOb2iRy4Ng";
const CHATBOT_IFRAME_STYLE = {
width: "100%",
height: "100%",
minHeight: 700,
border: 0,
} as const;
type ChatSession = { src: string; title: string } | null;
export default function AITools() {
const { t, lang } = useLang();
const [activeCategory, setActiveCategory] = useState("ai.all");
const [searchQuery, setSearchQuery] = useState("");
const [chatSession, setChatSession] = useState<ChatSession>(null);
const [comingSoonOpen, setComingSoonOpen] = useState(false);
const filteredAgents = agents.filter((agent) => {
const matchesCategory = activeCategory === "ai.all" || agent.category === activeCategory;
const matchesSearch =
!searchQuery ||
t(agent.nameKey).toLowerCase().includes(searchQuery.toLowerCase()) ||
t(agent.descKey).toLowerCase().includes(searchQuery.toLowerCase());
return matchesCategory && matchesSearch;
});
useEffect(() => {
if (!chatSession) return;
const html = document.documentElement;
const body = document.body;
const prevHtmlOverflow = html.style.overflow;
const prevBodyOverflow = body.style.overflow;
const scrollY = window.scrollY;
html.style.overflow = "hidden";
body.style.overflow = "hidden";
body.style.position = "fixed";
body.style.top = `-${scrollY}px`;
body.style.left = "0";
body.style.right = "0";
body.style.width = "100%";
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setChatSession(null);
};
window.addEventListener("keydown", onKeyDown);
return () => {
html.style.overflow = prevHtmlOverflow;
body.style.overflow = prevBodyOverflow;
body.style.position = "";
body.style.top = "";
body.style.left = "";
body.style.right = "";
body.style.width = "";
window.removeEventListener("keydown", onKeyDown);
window.scrollTo(0, scrollY);
};
}, [chatSession]);
return (
<div className="space-y-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4"
>
<div>
<div className="section-tag w-fit mb-4">
<Sparkles className="w-3 h-3" />
{t("ai.tag")}
</div>
<h3 className="text-3xl font-bold text-foreground tracking-tight">{t("ai.title")}</h3>
<p className="text-sm text-muted-foreground mt-2 max-w-md">{t("ai.subtitle")}</p>
</div>
<div className="relative w-full md:w-72">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder={t("ai.search")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-3 rounded-xl glass-card text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 placeholder:text-muted-foreground border-0"
/>
{searchQuery && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
>
</motion.button>
)}
</div>
</motion.div>
{/* Category pills with animated indicator */}
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="flex flex-wrap gap-2"
>
{categories.map((cat) => (
<motion.button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`relative px-4 py-2 rounded-xl text-xs font-medium transition-colors duration-200 ${
activeCategory === cat
? "text-background"
: "glass-pill text-muted-foreground hover:text-foreground"
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.97 }}
>
{activeCategory === cat && (
<motion.div
layoutId="activeCat"
className="absolute inset-0 bg-foreground rounded-xl shadow-lg shadow-foreground/10"
transition={{ type: "spring", stiffness: 400, damping: 28 }}
/>
)}
<span className="relative z-10">{t(cat)}</span>
</motion.button>
))}
</motion.div>
{/* Agent grid */}
<motion.div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{filteredAgents.length === 0 && (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="col-span-full text-center py-12 text-muted-foreground text-sm"
>
{lang === "zh" ? "没有找到匹配的智能体" : "No matching agents found"}
</motion.div>
)}
{filteredAgents.map((agent, i) => (
<motion.div
key={agent.nameKey}
initial="rest"
animate="rest"
whileHover="hover"
variants={{ rest: { y: 0, scale: 1 }, hover: { y: -8, scale: 1.03 } }}
transition={{ duration: 0.25, delay: i * 0.04 }}
className={`glass-card p-0 flex flex-col overflow-hidden cursor-pointer ${
i === 0 && filteredAgents.length >= 3 ? "lg:col-span-2 lg:row-span-1" : ""
}`}
style={{ boxShadow: "none" }}
>
{/* Gradient header */}
<div className={`bg-gradient-to-br ${agent.gradient} p-6 pb-4 relative`}>
<div className="flex items-start justify-between">
<motion.div
className="text-4xl origin-center"
variants={{
rest: { scale: 1, rotate: 0 },
hover: { scale: 1.3, rotate: [0, -12, 10, -6, 0] },
}}
transition={{ type: "spring", stiffness: 400, damping: 10 }}
>
{agent.emoji}
</motion.div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<div
className={`w-2 h-2 rounded-full ${agent.status === "online" ? "bg-emerald-400" : "bg-amber-400"}`}
/>
{agent.status}
</div>
<span className="section-tag !text-[10px] !px-2.5 !py-1">{t(agent.tagKey)}</span>
</div>
</div>
<h5 className="font-bold text-foreground text-base mt-3 leading-tight">{t(agent.nameKey)}</h5>
</div>
<div className="p-6 pt-4 flex flex-col flex-1">
<p className="text-xs text-muted-foreground flex-1 mb-4 leading-relaxed">{t(agent.descKey)}</p>
<div className="flex flex-wrap gap-1.5 mb-5">
{agent.tags.map((tag) => (
<span
key={tag}
className="glass-pill !rounded-lg px-2.5 py-1 text-[10px] text-muted-foreground font-medium"
>
{t(tag)}
</span>
))}
</div>
<button
className="btn-primary w-full !rounded-xl !text-xs relative isolate !shadow-none hover:!shadow-none hover:[filter:none] flex items-center justify-center gap-2 transition-transform active:scale-[0.98]"
onClick={() => {
if (agent.nameKey === "ai.aism.name") {
setChatSession({ src: AISM_CHATBOT_IFRAME_SRC, title: t("ai.aism.name") });
} else if (agent.nameKey === "ai.meeting.name") {
setChatSession({
src: MEETING_CHATBOT_IFRAME_SRC,
title: lang === "zh" ? "会议纪要助手" : "Meeting minutes assistant",
});
} else if (agent.nameKey === "ai.deepseek.name") {
setChatSession({
src: DEEPSEEK_CHATBOT_IFRAME_SRC,
title: lang === "zh" ? "Deepseek问答助手" : "Deepseek Q&A assistant",
});
} else if (agent.nameKey === "ai.adminAssist.name") {
setChatSession({
src: ADMIN_ASSIST_CHATBOT_IFRAME_SRC,
title: t("ai.adminAssist.name"),
});
} else if (agent.nameKey === "ai.strategic.name") {
setChatSession({
src: STRATEGIC_CHATBOT_IFRAME_SRC,
title: t("ai.strategic.name"),
});
} else if (agent.nameKey === "ai.bizplan.name") {
setChatSession({
src: BIZPLAN_CHATBOT_IFRAME_SRC,
title: t("ai.bizplan.name"),
});
} else if (agent.nameKey === "ai.marketReport.name") {
setChatSession({
src: MARKET_REPORT_CHATBOT_IFRAME_SRC,
title: t("ai.marketReport.name"),
});
} else if (agent.nameKey === "ai.techReport.name") {
setChatSession({
src: TECH_REPORT_CHATBOT_IFRAME_SRC,
title: t("ai.techReport.name"),
});
} else if (agent.nameKey === "ai.patentCompare.name") {
setChatSession({
src: PATENT_COMPARE_CHATBOT_IFRAME_SRC,
title: t("ai.patentCompare.name"),
});
} else if (agent.nameKey === "ai.zoneInspection.name") {
setChatSession({
src: ZONE_INSPECTION_CHATBOT_IFRAME_SRC,
title: t("ai.zoneInspection.name"),
});
} else if (agent.nameKey === "ai.techPrice.name") {
setChatSession({
src: TECH_PRICE_CHATBOT_IFRAME_SRC,
title: t("ai.techPrice.name"),
});
} else if (agent.nameKey === "ai.patentMicroNav.name") {
setChatSession({
src: PATENT_MICRO_NAV_CHATBOT_IFRAME_SRC,
title: t("ai.patentMicroNav.name"),
});
} else if (agent.nameKey === "ai.factorySourcing.name") {
setChatSession({
src: FACTORY_SOURCING_CHATBOT_IFRAME_SRC,
title: t("ai.factorySourcing.name"),
});
} else {
setComingSoonOpen(true);
}
}}
>
<Zap className="w-3 h-3" />
{t("ai.startChat")}
<ArrowRight className="w-3 h-3" />
</button>
</div>
</motion.div>
))}
</motion.div>
{/* Portal modal with viewport padding (not full-bleed) */}
{typeof document !== "undefined" &&
createPortal(
<AnimatePresence>
{chatSession && (
<motion.div
key={`chat-fullscreen-${chatSession.src}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[9999] flex items-center justify-center p-3 sm:p-6 overflow-hidden overscroll-none bg-zinc-950/80 backdrop-blur-sm"
style={{
overscrollBehavior: "none",
width: "100vw",
minHeight: "100dvh",
height: "100dvh",
}}
onClick={() => setChatSession(null)}
>
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.98 }}
transition={{ duration: 0.2 }}
role="dialog"
aria-modal="true"
aria-label={chatSession.title}
className="relative w-full max-w-5xl h-[min(86dvh,820px)] sm:min-h-[700px] rounded-2xl overflow-hidden border border-zinc-700/80 bg-zinc-950 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<iframe
title={chatSession.title}
src={chatSession.src}
className="absolute inset-0 bg-zinc-950"
style={CHATBOT_IFRAME_STYLE}
allow="microphone"
/>
<button
type="button"
onClick={() => setChatSession(null)}
className="absolute z-10 flex h-11 w-11 items-center justify-center rounded-full border border-zinc-600/80 bg-zinc-900/90 text-zinc-100 shadow-lg backdrop-blur-sm transition-colors hover:bg-zinc-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400"
style={{
top: "max(0.75rem, env(safe-area-inset-top, 0px))",
right: "max(0.75rem, env(safe-area-inset-right, 0px))",
}}
aria-label={lang === "zh" ? "关闭" : "Close"}
>
<X className="h-5 w-5" />
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
<ComingSoonModal open={comingSoonOpen} onClose={() => setComingSoonOpen(false)} />
</div>
);
}

View File

@@ -0,0 +1,372 @@
import { useState, useEffect } from "react";
import { useLang } from "@/contexts/LanguageContext";
import { fetchPublishedCourseVideos, mediaUrl, recordCourseVideoView, type CourseVideo } from "@/lib/api";
import { ArrowLeft, BookOpen, Users, Clock, Play, FileText, X, Headphones } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import qrCodeImg from "@/assets/qr-code.png";
interface CourseDetailProps {
courseKey: string;
onBack: () => void;
}
const courseData: Record<string, {
emoji: string;
color: string;
lessons: number;
students: string;
hours: number;
completion: number;
modules: { titleKey: string; lessons: { titleKey: string; type: "video" | "homework" }[] }[];
resources: { titleKey: string; icon: string }[];
}> = {
biz: {
emoji: "📊",
color: "from-blue-500 to-cyan-400",
lessons: 32,
students: "1200+",
hours: 45,
completion: 85,
modules: [
{
titleKey: "course.biz.mod1",
lessons: [
{ titleKey: "course.biz.mod1.l1", type: "video" },
{ titleKey: "course.biz.mod1.l2", type: "video" },
{ titleKey: "course.biz.mod1.hw", type: "homework" },
],
},
{
titleKey: "course.biz.mod2",
lessons: [
{ titleKey: "course.biz.mod2.l1", type: "video" },
{ titleKey: "course.biz.mod2.l2", type: "video" },
],
},
],
resources: [
{ titleKey: "course.res.ppt", icon: "📑" },
{ titleKey: "course.res.canvas", icon: "📋" },
],
},
product: {
emoji: "💡",
color: "from-violet-500 to-purple-400",
lessons: 28,
students: "980+",
hours: 38,
completion: 72,
modules: [
{
titleKey: "course.product.mod1",
lessons: [
{ titleKey: "course.product.mod1.l1", type: "video" },
{ titleKey: "course.product.mod1.l2", type: "video" },
],
},
],
resources: [
{ titleKey: "course.res.ppt", icon: "📑" },
{ titleKey: "course.res.prd", icon: "📋" },
],
},
marketing: {
emoji: "📢",
color: "from-amber-500 to-orange-400",
lessons: 36,
students: "1500+",
hours: 50,
completion: 91,
modules: [
{
titleKey: "course.marketing.mod1",
lessons: [
{ titleKey: "course.marketing.mod1.l1", type: "video" },
{ titleKey: "course.marketing.mod1.l2", type: "video" },
],
},
],
resources: [
{ titleKey: "course.res.ppt", icon: "📑" },
{ titleKey: "course.res.strategy", icon: "📋" },
],
},
ops: {
emoji: "⚙️",
color: "from-emerald-500 to-teal-400",
lessons: 24,
students: "1100+",
hours: 42,
completion: 68,
modules: [
{
titleKey: "course.ops.mod1",
lessons: [
{ titleKey: "course.ops.mod1.l1", type: "video" },
{ titleKey: "course.ops.mod1.l2", type: "video" },
],
},
],
resources: [
{ titleKey: "course.res.ppt", icon: "📑" },
{ titleKey: "course.res.sop", icon: "📋" },
],
},
};
export default function CourseDetail({ courseKey, onBack }: CourseDetailProps) {
const { t } = useLang();
const [videoOpen, setVideoOpen] = useState(false);
const [videoTitle, setVideoTitle] = useState("");
const [videoSrc, setVideoSrc] = useState<string | null>(null);
const [uploadedVideos, setUploadedVideos] = useState<CourseVideo[]>([]);
const [qrOpen, setQrOpen] = useState(false);
const data = courseData[courseKey];
useEffect(() => {
let cancelled = false;
(async () => {
const list = await fetchPublishedCourseVideos(courseKey);
if (!cancelled) setUploadedVideos(list);
})();
return () => {
cancelled = true;
};
}, [courseKey]);
if (!data) return null;
const handleVideoClick = (titleKey: string) => {
setVideoTitle(t(titleKey));
setVideoSrc(null);
setVideoOpen(true);
};
const handleUploadedVideoClick = (v: CourseVideo) => {
setVideoTitle(v.title);
setVideoSrc(mediaUrl(v.videoUrl));
setVideoOpen(true);
recordCourseVideoView(v.id);
};
const closeVideoModal = () => {
setVideoOpen(false);
setVideoSrc(null);
};
return (
<motion.div
initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -30 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Breadcrumb + Back */}
<button
onClick={onBack}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
<span>{t("edu.tag")}</span>
<span>/</span>
<span className="text-foreground font-medium">{t(`edu.${courseKey}`)}</span>
</button>
{/* Course Header */}
<div className="glass-card p-8">
<div className="flex flex-col md:flex-row gap-6 items-start">
<div className={`w-16 h-16 rounded-2xl bg-gradient-to-br ${data.color} flex items-center justify-center text-3xl shadow-lg shrink-0`}>
{data.emoji}
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold text-foreground mb-2">{t(`edu.${courseKey}`)}</h2>
<p className="text-muted-foreground mb-4">{t(`edu.${courseKey}Desc`)}</p>
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1.5"><BookOpen className="w-4 h-4" /> {data.lessons} {t("edu.lessons")}</span>
<span className="flex items-center gap-1.5"><Users className="w-4 h-4" /> {data.students} {t("edu.students")}</span>
<span className="flex items-center gap-1.5"><Clock className="w-4 h-4" /> {data.hours} {t("course.hours")}</span>
</div>
<div className="mt-4">
<div className="flex justify-between text-xs text-muted-foreground mb-1.5">
<span>{t("edu.completion") ?? "Completion"}</span>
<span>{data.completion}%</span>
</div>
<div className="h-2 bg-border/40 rounded-full overflow-hidden">
<motion.div
className={`h-full rounded-full bg-gradient-to-r ${data.color}`}
initial={{ width: 0 }}
animate={{ width: `${data.completion}%` }}
transition={{ duration: 1, ease: "easeOut" }}
/>
</div>
</div>
</div>
</div>
</div>
{/* Course Info */}
<div className="glass-card p-8">
<h3 className="text-lg font-semibold text-foreground mb-4 pb-2 border-b border-border/30">{t("course.info")}</h3>
<p className="text-sm text-muted-foreground leading-relaxed mb-2">
<span className="font-semibold text-foreground">{t("course.objective")}</span>
{t(`course.${courseKey}.objective`)}
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
<span className="font-semibold text-foreground">{t("course.audience")}</span>
{t(`course.${courseKey}.audience`)}
</p>
</div>
{/* Syllabus */}
{/* Syllabus */}
<div className="glass-card p-8">
<h3 className="text-lg font-semibold text-foreground mb-4 pb-2 border-b border-border/30">{t("course.syllabus")}</h3>
{data.modules.map((mod, mi) => (
<div key={mi} className="mb-6 last:mb-0">
<h4 className="font-semibold text-foreground mb-3">{t(mod.titleKey)}</h4>
<div className="space-y-0">
{mod.lessons.map((lesson, li) => (
<div key={li} className="flex items-center justify-between py-3 border-b border-border/20 last:border-0">
{lesson.type === "video" ? (
<button
onClick={() => handleVideoClick(lesson.titleKey)}
className="flex items-center gap-2 text-sm text-foreground hover:text-primary transition-colors group cursor-pointer"
>
<Play className="w-4 h-4 text-primary" />
<span>{t(lesson.titleKey)}</span>
<span className="text-[10px] text-muted-foreground bg-muted/50 px-2 py-0.5 rounded">{t("course.video")}</span>
</button>
) : (
<span className="text-sm text-destructive font-medium">{t(lesson.titleKey)}</span>
)}
</div>
))}
</div>
</div>
))}
</div>
{/* Admin-published videos (same course) */}
{uploadedVideos.length > 0 && (
<div className="glass-card p-8">
<h3 className="text-lg font-semibold text-foreground mb-4 pb-2 border-b border-border/30">
{t("course.adminVideos")}
</h3>
<div className="space-y-0">
{uploadedVideos.map((v) => (
<div
key={v.id}
className="flex items-center justify-between py-3 border-b border-border/20 last:border-0"
>
<button
type="button"
onClick={() => handleUploadedVideoClick(v)}
className="flex items-center gap-2 text-sm text-foreground hover:text-primary transition-colors text-left"
>
<Play className="w-4 h-4 text-primary shrink-0" />
<span>{v.title}</span>
<span className="text-[10px] text-muted-foreground bg-muted/50 px-2 py-0.5 rounded">
{v.moduleName}
</span>
<span className="text-[10px] text-muted-foreground bg-primary/10 px-2 py-0.5 rounded">
{t("course.video")}
</span>
</button>
</div>
))}
</div>
</div>
)}
{/* Add Service CTA - below lessons */}
<motion.button
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
onClick={() => setQrOpen(true)}
className="relative w-full flex items-center justify-center gap-3 px-6 py-4 rounded-2xl text-sm font-semibold text-primary-foreground bg-gradient-to-r from-primary to-accent shadow-lg shadow-primary/25 overflow-hidden active:scale-[0.98] transition-transform"
>
<motion.div
className="absolute inset-0 bg-white/20"
animate={{ x: ["-100%", "200%"] }}
transition={{ duration: 2, repeat: Infinity, repeatDelay: 3, ease: "easeInOut" }}
/>
<Headphones className="w-4 h-4 relative z-10" />
<span className="relative z-10">{t("course.addService")}</span>
</motion.button>
{/* QR Code Modal */}
<AnimatePresence>
{qrOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
onClick={() => setQrOpen(false)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="glass-card-elevated w-full max-w-sm p-8 text-center"
onClick={(e) => e.stopPropagation()}
>
<button onClick={() => setQrOpen(false)} className="absolute top-4 right-4 p-1 hover:bg-muted/50 rounded-lg transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
<h4 className="font-bold text-foreground text-lg mb-6">{t("course.addServiceTitle")}</h4>
<img src={qrCodeImg} alt="QR Code" className="w-48 h-48 mx-auto rounded-xl" loading="lazy" width={512} height={512} />
<p className="text-sm text-muted-foreground mt-4">{t("course.scanQr")}</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Video Modal */}
<AnimatePresence>
{videoOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
onClick={closeVideoModal}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="w-full max-w-3xl bg-card rounded-2xl overflow-hidden shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-border/30">
<h4 className="font-semibold text-foreground text-sm">{videoTitle}</h4>
<button onClick={closeVideoModal} className="p-1 hover:bg-muted/50 rounded-lg transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div className="aspect-video bg-black flex items-center justify-center">
{videoSrc ? (
<video
src={videoSrc}
controls
className="w-full h-full max-h-[70vh]"
playsInline
/>
) : (
<div className="text-center text-muted-foreground">
<Play className="w-16 h-16 mx-auto mb-3 text-primary/50" />
<p className="text-sm">{t("course.videoPlaceholder")}</p>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}

View File

@@ -0,0 +1,194 @@
import { useState } from "react";
import { Check, Crown, ArrowRight, Star, X } from "lucide-react";
import { useLang } from "@/contexts/LanguageContext";
import { motion, AnimatePresence } from "framer-motion";
import ComingSoonModal from "@/components/ComingSoonModal";
import qrCodeImg from "@/assets/qr-code.png";
const plans = [
{
titleKey: "member.basic", priceKey: "member.free", descKey: "member.basicDesc",
items: ["member.basicItem1", "member.basicItem2", "member.basicItem3", "member.basicItem4"],
btnKey: "member.basicBtn", highlighted: false, icon: "🌱", contactSales: false,
},
{
titleKey: "member.pro", priceKey: "member.proPrice", descKey: "member.proDesc",
items: ["member.proItem1", "member.proItem2", "member.proItem3", "member.proItem4", "member.proItem5", "member.proItem6"],
btnKey: "member.proBtn", highlighted: true, icon: "⚡", contactSales: false,
},
{
titleKey: "member.business", priceKey: "member.bizPrice", descKey: "member.bizDesc",
items: ["member.bizItem1", "member.bizItem2", "member.bizItem3", "member.bizItem4", "member.bizItem5", "member.bizItem6"],
btnKey: "member.bizBtn", highlighted: false, icon: "🏢", contactSales: true,
},
{
titleKey: "member.custom", priceKey: "member.customPrice", descKey: "member.customDesc",
items: ["member.customItem1", "member.customItem2", "member.customItem3", "member.customItem4", "member.customItem5", "member.customItem6"],
btnKey: "member.customBtn", highlighted: false, icon: "🚀", contactSales: true,
},
];
const container = { hidden: {}, show: { transition: { staggerChildren: 0.15 } } };
const item = { hidden: { opacity: 0, y: 40, scale: 0.92 }, show: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.55 } } };
export default function MembershipSystem() {
const { t, lang } = useLang();
const [hoveredPlan, setHoveredPlan] = useState<number | null>(null);
const [qrOpen, setQrOpen] = useState(false);
const [comingSoonOpen, setComingSoonOpen] = useState(false);
return (
<div className="space-y-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center max-w-lg mx-auto"
>
<div className="section-tag mx-auto w-fit mb-4">
<Crown className="w-3 h-3" />
{t("member.tag")}
</div>
<h3 className="text-3xl font-bold text-foreground tracking-tight">{t("member.title")}</h3>
<p className="text-sm text-muted-foreground mt-3">{t("member.subtitle")}</p>
</motion.div>
<motion.div
variants={container}
initial="hidden"
whileInView="show"
viewport={{ once: true, amount: 0.2 }}
className="grid md:grid-cols-2 lg:grid-cols-4 gap-5 items-stretch"
>
{plans.map((plan, i) => (
<motion.div
key={plan.titleKey}
variants={item}
className={`relative flex flex-col overflow-hidden ${
plan.highlighted ? "glass-card-elevated" : "glass-card"
}`}
onMouseEnter={() => setHoveredPlan(i)}
onMouseLeave={() => setHoveredPlan(null)}
whileHover={{ y: -8, scale: 1.02 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
{/* Animated glow on highlighted */}
{plan.highlighted && (
<motion.div
className="absolute -top-20 -right-20 w-60 h-60 bg-gradient-to-bl from-primary/10 via-accent/5 to-transparent rounded-full blur-3xl pointer-events-none"
animate={{ scale: [1, 1.2, 1], rotate: [0, 15, 0] }}
transition={{ duration: 6, repeat: Infinity, ease: "easeInOut" }}
/>
)}
<div className="p-7 pb-4 relative">
<div className="h-6 mb-2">
{plan.highlighted && (
<motion.span
className="section-tag text-[10px] font-semibold inline-flex items-center gap-1"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5 }}
>
<Star className="w-2.5 h-2.5 fill-current" />
{t("member.recommended")}
</motion.span>
)}
</div>
<div className="flex items-center gap-3 mb-2">
<motion.span
className="text-2xl"
animate={hoveredPlan === i ? { rotate: [0, -10, 10, 0], scale: [1, 1.2, 1] } : {}}
transition={{ duration: 0.5 }}
>
{plan.icon}
</motion.span>
<h4 className="text-base font-bold text-foreground">{t(plan.titleKey)}</h4>
</div>
<motion.div
className="text-4xl font-bold gradient-text my-3"
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.2 }}
>
{t(plan.priceKey)}
</motion.div>
<p className="text-xs text-muted-foreground">{t(plan.descKey)}</p>
</div>
<div className="p-7 pt-5 flex flex-col flex-1 relative">
<ul className="space-y-3 flex-1">
{plan.items.map((itm, idx) => (
<motion.li
key={itm}
className="flex items-start gap-2.5 text-sm text-muted-foreground"
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: idx * 0.06 }}
>
<motion.div
className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5"
whileHover={{ scale: 1.2, backgroundColor: "hsla(220, 80%, 58%, 0.2)" }}
>
<Check className="w-3 h-3 text-primary" />
</motion.div>
{t(itm)}
</motion.li>
))}
</ul>
<motion.button
className={`w-full mt-7 !rounded-xl group flex items-center justify-center gap-2 ${plan.highlighted ? "btn-primary" : "btn-glass"}`}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={() => {
if (plan.contactSales) {
setQrOpen(true);
} else {
setComingSoonOpen(true);
}
}}
>
{t(plan.btnKey)}
<ArrowRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" />
</motion.button>
</div>
</motion.div>
))}
</motion.div>
{/* QR Code Modal */}
<AnimatePresence>
{qrOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
onClick={() => setQrOpen(false)}
>
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.85, opacity: 0 }}
className="glass-card-elevated p-8 rounded-2xl text-center max-w-sm relative"
onClick={(e) => e.stopPropagation()}
>
<button onClick={() => setQrOpen(false)} className="absolute top-4 right-4 p-1 hover:bg-muted/50 rounded-lg transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
<h4 className="font-bold text-foreground text-lg mb-6">{t("course.addServiceTitle")}</h4>
<img src={qrCodeImg} alt="QR Code" className="w-48 h-48 mx-auto rounded-xl" loading="lazy" width={512} height={512} />
<p className="text-sm text-muted-foreground mt-4">{t("course.scanQr")}</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ComingSoonModal open={comingSoonOpen} onClose={() => setComingSoonOpen(false)} />
</div>
);
}

View File

@@ -0,0 +1,256 @@
import { useState } from "react";
import { useLang } from "@/contexts/LanguageContext";
import { Target, Bot, Crown, Rocket, ArrowUpRight, ChevronDown } from "lucide-react";
import { motion } from "framer-motion";
import { useCountUp } from "@/hooks/useCountUp";
const highlights = [
{ icon: Target, titleKey: "overview.opc", subKey: "overview.opcSub", color: "from-blue-500 to-cyan-400", num: 2000, suffix: "+" },
{ icon: Bot, titleKey: "overview.ai", subKey: "overview.aiSub", color: "from-violet-500 to-purple-400", num: 50, suffix: "+" },
{ icon: Crown, titleKey: "overview.membership", subKey: "overview.membershipSub", color: "from-amber-500 to-orange-400", num: 0, display: "∞" },
{ icon: Rocket, titleKey: "overview.count", subKey: "overview.countSub", color: "from-emerald-500 to-teal-400", num: 24, suffix: "/7" },
];
const pillars = [
{ emoji: "📊", titleKey: "overview.eduTitle", subKey: "overview.eduSub", items: ["overview.eduItem1", "overview.eduItem2", "overview.eduItem3", "overview.eduItem4"], accent: "from-blue-500/20 via-cyan-400/10 to-transparent", progress: 92 },
{ emoji: "🤖", titleKey: "overview.aiToolTitle", subKey: "overview.aiToolSub", items: ["overview.aiToolItem1", "overview.aiToolItem2", "overview.aiToolItem3", "overview.aiToolItem4"], accent: "from-violet-500/20 via-purple-400/10 to-transparent", progress: 87 },
{ emoji: "👥", titleKey: "overview.alumniTitle", subKey: "overview.alumniSub", items: ["overview.alumniItem1", "overview.alumniItem2", "overview.alumniItem3", "overview.alumniItem4"], accent: "from-amber-500/20 via-orange-400/10 to-transparent", progress: 78 },
];
const container = { hidden: {}, show: { transition: { staggerChildren: 0.08 } } };
const item = { hidden: { opacity: 0, y: 20 }, show: { opacity: 1, y: 0, transition: { duration: 0.5 } } };
function AnimatedStat({ h, index }: { h: typeof highlights[0]; index: number }) {
const { count, ref } = useCountUp(h.num, 1800);
return (
<motion.div
ref={ref}
variants={item}
className={`group relative p-6 flex flex-col items-center text-center cursor-pointer transition-all duration-500 hover:scale-[1.02] ${
index < highlights.length - 1 ? "md:border-r border-border/30" : ""
}`}
whileHover={{ scale: 1.04 }}
>
<motion.div
className={`w-11 h-11 rounded-2xl bg-gradient-to-br ${h.color} flex items-center justify-center mb-3 shadow-lg`}
whileHover={{ scale: 1.15, rotate: 8 }}
transition={{ type: "spring", stiffness: 400, damping: 15 }}
>
<h.icon className="w-5 h-5 text-white" />
</motion.div>
<div className="text-2xl font-bold text-foreground tracking-tight mb-1">
{h.display ?? `${count}${h.suffix ?? ""}`}
</div>
<div className="font-semibold text-foreground text-xs">{h.titleKey}</div>
<div className="text-[10px] text-muted-foreground mt-0.5 leading-snug">{h.subKey}</div>
</motion.div>
);
}
interface PlatformOverviewProps {
onNavigateToCourse?: (courseKey: string) => void;
onNavigateToTab?: (tabKey: string) => void;
}
export default function PlatformOverview({ onNavigateToCourse, onNavigateToTab }: PlatformOverviewProps) {
const { t } = useLang();
const [expandedPillar, setExpandedPillar] = useState<number | null>(null);
const eduCourseKeys = ["biz", "product", "marketing", "ops"];
return (
<div className="space-y-10">
{/* Stats ribbon with animated counters */}
<motion.div
variants={container}
initial="hidden"
whileInView="show"
viewport={{ once: true, amount: 0.3 }}
className="glass-card-elevated p-2 rounded-3xl"
>
<div className="grid grid-cols-2 md:grid-cols-4">
{highlights.map((h, i) => (
<AnimatedStat key={h.titleKey} h={{...h, titleKey: t(h.titleKey), subKey: t(h.subKey)}} index={i} />
))}
</div>
</motion.div>
{/* Mission — with reveal animation */}
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.7 }}
className="glass-card-elevated p-10 md:p-14 relative overflow-hidden group"
>
<motion.div
className="absolute -top-20 -right-20 w-80 h-80 bg-gradient-to-bl from-primary/8 via-accent/5 to-transparent rounded-full blur-3xl"
animate={{ scale: [1, 1.15, 1], rotate: [0, 8, 0] }}
transition={{ duration: 10, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
className="absolute -bottom-16 -left-16 w-56 h-56 bg-gradient-to-tr from-accent/8 to-transparent rounded-full blur-2xl"
animate={{ scale: [1, 1.1, 1], rotate: [0, -5, 0] }}
transition={{ duration: 8, repeat: Infinity, ease: "easeInOut", delay: 1 }}
/>
<div className="relative flex flex-col md:flex-row md:items-center gap-8">
<div className="flex-1">
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.2 }}
className="section-tag mb-5 w-fit"
>
🏛 {t("overview.missionTag")}
</motion.div>
<h3 className="text-3xl md:text-4xl font-bold text-foreground tracking-tight mb-4 leading-tight">{t("overview.mission")}</h3>
<p className="text-sm md:text-base text-muted-foreground leading-relaxed max-w-2xl">{t("overview.missionText")}</p>
</div>
<motion.div
className="hidden md:flex shrink-0 w-28 h-28 rounded-3xl bg-gradient-to-br from-primary/10 to-accent/10 items-center justify-center text-5xl"
whileHover={{ rotate: 12, scale: 1.1 }}
transition={{ type: "spring", stiffness: 300, damping: 15 }}
>
🏛
</motion.div>
</div>
</motion.div>
{/* Interactive pillars with expandable details and progress bars */}
<motion.div
variants={container}
initial="hidden"
whileInView="show"
viewport={{ once: true, amount: 0.2 }}
className="grid md:grid-cols-5 gap-5"
>
{/* Large pillar card */}
<motion.div variants={item} className="md:col-span-3 glass-card overflow-hidden group cursor-pointer">
<div className={`bg-gradient-to-r ${pillars[0].accent} p-8 pb-5`}>
<div className="flex items-start justify-between">
<motion.div
className="text-4xl"
whileHover={{ scale: 1.2, rotate: -12 }}
transition={{ type: "spring", stiffness: 300 }}
>
{pillars[0].emoji}
</motion.div>
<ArrowUpRight className="w-5 h-5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-all duration-300" />
</div>
<h4 className="font-bold text-foreground text-xl mt-4 mb-1">{t(pillars[0].titleKey)}</h4>
<p className="text-xs text-muted-foreground leading-relaxed">{t(pillars[0].subKey)}</p>
{/* Progress bar */}
<div className="mt-4 h-1.5 bg-border/40 rounded-full overflow-hidden">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-blue-500 to-cyan-400"
initial={{ width: 0 }}
whileInView={{ width: `${pillars[0].progress}%` }}
viewport={{ once: true }}
transition={{ duration: 1.2, delay: 0.3, ease: "easeOut" }}
/>
</div>
<div className="flex justify-between mt-1.5 text-[10px] text-muted-foreground">
<span>{t("overview.completion")}</span>
<span>{pillars[0].progress}%</span>
</div>
</div>
<div className="p-8 pt-5">
<div className="grid grid-cols-2 gap-3">
{pillars[0].items.map((itm, idx) => (
<motion.div
key={itm}
className="glass-pill !rounded-xl px-4 py-3 text-xs text-muted-foreground font-medium flex items-center gap-2.5 cursor-pointer"
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: idx * 0.1 }}
whileHover={{ x: 4, backgroundColor: "hsla(220, 80%, 58%, 0.06)" }}
onClick={() => onNavigateToCourse?.(eduCourseKeys[idx])}
>
<span className="w-6 h-6 rounded-lg bg-primary/8 flex items-center justify-center text-[10px] font-bold text-primary shrink-0">
{idx + 1}
</span>
{t(itm)}
<ArrowUpRight className="w-3 h-3 ml-auto opacity-0 group-hover:opacity-60 transition-opacity" />
</motion.div>
))}
</div>
</div>
</motion.div>
{/* Right column — expandable stacked cards */}
<div className="md:col-span-2 flex flex-col gap-5 relative z-10">
{pillars.slice(1).map((p, i) => (
<motion.div
key={p.titleKey}
variants={item}
className="glass-card overflow-visible cursor-pointer"
onMouseEnter={() => setExpandedPillar(i)}
onMouseLeave={() => setExpandedPillar(null)}
onClick={() => onNavigateToTab?.(i === 0 ? "ai" : "alumni")}
whileHover={{ y: -3 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
<div className={`bg-gradient-to-r ${p.accent} p-6 pb-4`}>
<div className="flex items-center gap-3">
<motion.div
className="text-2xl"
animate={expandedPillar === i ? { rotate: [0, 10, -10, 0] } : {}}
transition={{ duration: 0.5 }}
>
{p.emoji}
</motion.div>
<div className="flex-1">
<h4 className="font-bold text-foreground text-base">{t(p.titleKey)}</h4>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5">{t(p.subKey)}</p>
</div>
<motion.div
animate={{ rotate: expandedPillar === i ? 180 : 0 }}
transition={{ duration: 0.3 }}
>
<ChevronDown className="w-4 h-4 text-muted-foreground" />
</motion.div>
</div>
{/* Progress bar */}
<div className="mt-3 h-1 bg-border/40 rounded-full overflow-hidden">
<motion.div
className={`h-full rounded-full bg-gradient-to-r ${i === 0 ? "from-violet-500 to-purple-400" : "from-amber-500 to-orange-400"}`}
initial={{ width: 0 }}
whileInView={{ width: `${p.progress}%` }}
viewport={{ once: true }}
transition={{ duration: 1, delay: 0.5 }}
/>
</div>
</div>
<motion.div
initial={false}
animate={{ height: expandedPillar === i ? "auto" : 0, opacity: expandedPillar === i ? 1 : 0 }}
transition={{ duration: 0.35, ease: "easeInOut" }}
className="overflow-hidden bg-card/95 backdrop-blur-sm rounded-b-2xl"
>
<div className="p-5 pt-3">
<ul className="space-y-2">
{p.items.map((itm, idx) => (
<motion.li
key={itm}
className="text-xs text-muted-foreground flex items-center gap-2.5"
initial={{ opacity: 0, x: -10 }}
animate={expandedPillar === i ? { opacity: 1, x: 0 } : { opacity: 0, x: -10 }}
transition={{ delay: idx * 0.08 }}
>
<span className="w-5 h-5 rounded-md bg-primary/8 flex items-center justify-center text-[9px] font-bold text-primary shrink-0">
{idx + 1}
</span>
{t(itm)}
</motion.li>
))}
</ul>
</div>
</motion.div>
</motion.div>
))}
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { useState } from "react";
import { useLang } from "@/contexts/LanguageContext";
import { ArrowUpRight } from "lucide-react";
import { motion } from "framer-motion";
import ComingSoonModal from "@/components/ComingSoonModal";
const features = [
{ emoji: "🌐", titleKey: "alumni.network", subKey: "alumni.networkSub", items: ["alumni.networkItem1", "alumni.networkItem2", "alumni.networkItem3"], accent: "from-blue-500/10 to-cyan-500/5", members: "12k+" },
{ emoji: "🤝", titleKey: "alumni.resource", subKey: "alumni.resourceSub", items: ["alumni.resourceItem1", "alumni.resourceItem2", "alumni.resourceItem3"], accent: "from-violet-500/10 to-purple-500/5", members: "8k+" },
{ emoji: "🎉", titleKey: "alumni.events", subKey: "alumni.eventsSub", items: ["alumni.eventsItem1", "alumni.eventsItem2", "alumni.eventsItem3"], accent: "from-amber-500/10 to-orange-500/5", members: "5k+" },
{ emoji: "❓", titleKey: "alumni.qa", subKey: "alumni.qaSub", items: ["alumni.qaItem1", "alumni.qaItem2", "alumni.qaItem3"], accent: "from-emerald-500/10 to-teal-500/5", members: "15k+" },
{ emoji: "🎓", titleKey: "alumni.mentorship", subKey: "alumni.mentorshipSub", items: ["alumni.mentorshipItem1", "alumni.mentorshipItem2", "alumni.mentorshipItem3"], accent: "from-rose-500/10 to-pink-500/5", members: "3k+" },
{ emoji: "🏅", titleKey: "alumni.cert", subKey: "alumni.certSub", items: ["alumni.certItem1", "alumni.certItem2", "alumni.certItem3"], accent: "from-indigo-500/10 to-blue-500/5", members: "7k+" },
];
const container = { hidden: {}, show: { transition: { staggerChildren: 0.1 } } };
const item = { hidden: { opacity: 0, y: 30, scale: 0.95 }, show: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.5 } } };
export default function YouweiAlumni() {
const { t, lang } = useLang();
const [flippedCard, setFlippedCard] = useState<number | null>(null);
const [comingSoonOpen, setComingSoonOpen] = useState(false);
return (
<div className="space-y-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
>
<div className="section-tag w-fit mb-4">👥 {t("alumni.tag")}</div>
<h3 className="text-3xl font-bold text-foreground tracking-tight max-w-2xl">{t("alumni.title")}</h3>
<p className="text-sm text-muted-foreground mt-3 max-w-lg">{t("alumni.subtitle")}</p>
</motion.div>
<motion.div
variants={container}
initial="hidden"
whileInView="show"
viewport={{ once: true, amount: 0.15 }}
className="grid md:grid-cols-2 lg:grid-cols-3 gap-5"
>
{features.map((f, i) => (
<motion.div
key={f.titleKey}
variants={item}
className={`glass-card overflow-hidden cursor-pointer relative ${i === 0 ? "lg:col-span-2" : ""}`}
style={{ perspective: 800 }}
whileHover={{ y: -4 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
onClick={() => setFlippedCard(flippedCard === i ? null : i)}
>
{/* Front face */}
<motion.div
animate={{ rotateY: flippedCard === i ? 90 : 0 }}
transition={{ duration: 0.25 }}
style={{ backfaceVisibility: "hidden" }}
className={flippedCard === i ? "pointer-events-none" : ""}
>
<div className={`bg-gradient-to-br ${f.accent} p-6 pb-3`}>
<div className="flex items-start justify-between">
<motion.div
className="text-3xl"
whileHover={{ scale: 1.2, rotate: 10 }}
transition={{ type: "spring", stiffness: 300 }}
>
{f.emoji}
</motion.div>
<div className="flex items-center gap-2">
<span className="glass-pill px-2.5 py-1 text-[10px] font-medium text-muted-foreground">
{f.members}
</span>
<ArrowUpRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-all duration-300" />
</div>
</div>
</div>
<div className="p-6 pt-3">
<h5 className="font-bold text-foreground text-base mb-1">{t(f.titleKey)}</h5>
<p className="text-xs text-muted-foreground mb-4 leading-relaxed">{t(f.subKey)}</p>
<ul className="space-y-2">
{f.items.map((itm, idx) => (
<motion.li
key={itm}
className="text-xs text-muted-foreground flex items-center gap-2.5"
initial={{ opacity: 0, x: -8 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: idx * 0.08 }}
>
<motion.span
className="w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0"
animate={{ scale: [1, 1.5, 1] }}
transition={{ duration: 2, repeat: Infinity, delay: idx * 0.3 }}
/>
{t(itm)}
</motion.li>
))}
</ul>
</div>
</motion.div>
{/* Back face — revealed on click */}
{flippedCard === i && (
<motion.div
initial={{ rotateY: -90, opacity: 0 }}
animate={{ rotateY: 0, opacity: 1 }}
transition={{ duration: 0.25, delay: 0.2 }}
className="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5 p-6 flex flex-col items-center justify-center text-center"
>
<div className="text-5xl mb-4">{f.emoji}</div>
<h5 className="font-bold text-foreground text-lg mb-2">{t(f.titleKey)}</h5>
<p className="text-sm text-muted-foreground mb-5 max-w-xs">{t(f.subKey)}</p>
<motion.button
className="btn-primary !text-xs !px-6"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.97 }}
onClick={() => setComingSoonOpen(true)}
>
{lang === "zh" ? "了解更多 →" : "Learn More →"}
</motion.button>
</motion.div>
)}
</motion.div>
))}
</motion.div>
<ComingSoonModal open={comingSoonOpen} onClose={() => setComingSoonOpen(false)} />
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { useState } from "react";
import { useLang } from "@/contexts/LanguageContext";
import { BookOpen, Users, ArrowRight, Play } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import CourseDetail from "./CourseDetail";
const courses = [
{ emoji: "📊", key: "biz", lessons: 32, students: "1.2k", color: "from-blue-500 to-cyan-400", completion: 85 },
{ emoji: "💡", key: "product", lessons: 28, students: "980", color: "from-violet-500 to-purple-400", completion: 72 },
{ emoji: "📢", key: "marketing", lessons: 36, students: "1.5k", color: "from-amber-500 to-orange-400", completion: 91 },
{ emoji: "⚙️", key: "ops", lessons: 24, students: "860", color: "from-emerald-500 to-teal-400", completion: 68 },
];
const container = { hidden: {}, show: { transition: { staggerChildren: 0.12 } } };
const item = { hidden: { opacity: 0, y: 30, scale: 0.95 }, show: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.5 } } };
interface YouweiEducationProps {
initialCourse?: string | null;
}
export default function YouweiEducation({ initialCourse = null }: YouweiEducationProps) {
const { t } = useLang();
const [hoveredCard, setHoveredCard] = useState<number | null>(null);
const [selectedCourse, setSelectedCourse] = useState<string | null>(initialCourse);
if (selectedCourse) {
return (
<AnimatePresence mode="wait">
<CourseDetail
key={selectedCourse}
courseKey={selectedCourse}
onBack={() => setSelectedCourse(null)}
/>
</AnimatePresence>
);
}
return (
<div className="space-y-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4"
>
<div>
<div className="section-tag w-fit mb-4">📚 {t("edu.tag")}</div>
<h3 className="text-3xl font-bold text-foreground tracking-tight">{t("edu.title")}</h3>
<p className="text-sm text-muted-foreground mt-2 max-w-lg">{t("edu.subtitle")}</p>
</div>
<p className="text-xs font-semibold text-primary uppercase tracking-widest">{t("edu.core")}</p>
</motion.div>
<motion.div
variants={container}
initial="hidden"
whileInView="show"
viewport={{ once: true, amount: 0.2 }}
className="grid md:grid-cols-2 gap-5"
>
{courses.map((c, i) => (
<motion.div
key={c.key}
variants={item}
className="glass-card overflow-hidden group cursor-pointer relative"
onMouseEnter={() => setHoveredCard(i)}
onMouseLeave={() => setHoveredCard(null)}
onClick={() => setSelectedCourse(c.key)}
whileHover={{ y: -5 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
<motion.div
className={`h-1.5 bg-gradient-to-r ${c.color}`}
initial={{ scaleX: 0 }}
whileInView={{ scaleX: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: i * 0.1 }}
style={{ transformOrigin: "left" }}
/>
<div className="p-7">
<div className="flex items-start justify-between mb-4">
<motion.div
className={`w-12 h-12 rounded-2xl bg-gradient-to-br ${c.color} flex items-center justify-center text-xl shadow-lg relative`}
whileHover={{ scale: 1.15, rotate: 8 }}
transition={{ type: "spring", stiffness: 400 }}
>
{c.emoji}
<motion.div
className="absolute inset-0 bg-black/30 rounded-2xl flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: hoveredCard === i ? 1 : 0 }}
transition={{ duration: 0.2 }}
>
<Play className="w-5 h-5 text-white fill-white" />
</motion.div>
</motion.div>
<span className="step-number !text-5xl">0{i + 1}</span>
</div>
<h5 className="font-bold text-foreground text-lg mb-2">{t(`edu.${c.key}`)}</h5>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">{t(`edu.${c.key}Desc`)}</p>
<div className="mb-5">
<div className="flex justify-between text-[10px] text-muted-foreground mb-1.5">
<span>{t("edu.completion") ?? "Completion"}</span>
<span>{c.completion}%</span>
</div>
<div className="h-1.5 bg-border/40 rounded-full overflow-hidden">
<motion.div
className={`h-full rounded-full bg-gradient-to-r ${c.color}`}
initial={{ width: 0 }}
whileInView={{ width: `${c.completion}%` }}
viewport={{ once: true }}
transition={{ duration: 1.2, delay: 0.3 + i * 0.1, ease: "easeOut" }}
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<motion.span
className="flex items-center gap-1.5 glass-pill px-3 py-1.5"
whileHover={{ scale: 1.05 }}
>
<BookOpen className="w-3 h-3" /> {c.lessons} {t("edu.lessons")}
</motion.span>
<motion.span
className="flex items-center gap-1.5 glass-pill px-3 py-1.5"
whileHover={{ scale: 1.05 }}
>
<Users className="w-3 h-3" /> {c.students} {t("edu.students")}
</motion.span>
</div>
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: hoveredCard === i ? 1 : 0, x: hoveredCard === i ? 0 : -10 }}
>
<ArrowRight className="w-4 h-4 text-muted-foreground" />
</motion.div>
</div>
</div>
</motion.div>
))}
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,104 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
),
);
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@@ -0,0 +1,38 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,90 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
),
);
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return <Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props} />;
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,47 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,224 @@
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>(
({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
{...props}
/>
);
},
);
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = "CarouselNext";
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };

303
src/components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,303 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,132 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,178 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -0,0 +1,95 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,87 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-50 bg-black/80", className)} {...props} />
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,179 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent focus:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />;
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

129
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,129 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
},
);
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return <p ref={ref} id={formDescriptionId} className={cn("text-sm text-muted-foreground", className)} {...props} />;
},
);
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p ref={ref} id={formMessageId} className={cn("text-sm font-medium text-destructive", className)} {...props}>
{body}
</p>
);
},
);
FormMessage.displayName = "FormMessage";
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -0,0 +1,61 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Dot } from "lucide-react";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<React.ElementRef<typeof OTPInput>, React.ComponentPropsWithoutRef<typeof OTPInput>>(
({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
),
);
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center", className)} {...props} />,
);
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
),
);
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,17 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,207 @@
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn("flex h-10 items-center space-x-1 rounded-md border bg-background p-1", className)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</MenubarPrimitive.Portal>
));
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
MenubarShortcut.displayname = "MenubarShortcut";
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};

View File

@@ -0,0 +1,120 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn("group flex flex-1 list-none items-center justify-center space-x-1", className)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto",
className,
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};

View File

@@ -0,0 +1,81 @@
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import { ButtonProps, buttonVariants } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
),
);
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
));
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">;
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span aria-hidden className={cn("flex h-9 w-9 items-center justify-center", className)} {...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,37 @@
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -0,0 +1,38 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,143 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,20 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

107
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,107 @@
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
),
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};

View File

@@ -0,0 +1,637 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar:state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContext = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", className)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
});
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
});
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
},
);
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 hover:after:bg-sidebar-border sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
},
);
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<React.ElementRef<typeof Input>, React.ComponentProps<typeof Input>>(
({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
},
);
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="header" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="footer" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<React.ElementRef<typeof Separator>, React.ComponentProps<typeof Separator>>(
({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
},
);
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.ComponentProps<"div"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
},
);
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
},
);
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div ref={ref} data-sidebar="group-content" className={cn("w-full text-sm", className)} {...props} />
),
);
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(({ className, ...props }, ref) => (
<ul ref={ref} data-sidebar="menu" className={cn("flex w-full min-w-0 flex-col gap-1", className)} {...props} />
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} data-sidebar="menu-item" className={cn("group/menu-item relative", className)} {...props} />
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
);
});
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ ...props }, ref) => (
<li ref={ref} {...props} />
));
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,7 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
}
export { Skeleton };

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,28 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
position="top-center"
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:backdrop-blur-2xl group-[.toaster]:bg-background/60 group-[.toaster]:text-foreground group-[.toaster]:border group-[.toaster]:border-border/30 group-[.toaster]:shadow-[0_8px_32px_rgba(0,0,0,0.12)]",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster, toast };

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -0,0 +1,72 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
);
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
),
);
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
),
);
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn("border-b transition-colors data-[state=selected]:bg-muted hover:bg-muted/50", className)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
),
);
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
),
);
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
),
);
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View File

@@ -0,0 +1,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };

111
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,111 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />;
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors group-[.destructive]:border-muted/40 hover:bg-secondary group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 group-[.destructive]:focus:ring-destructive disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 hover:text-foreground group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:outline-none focus:ring-2 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -0,0 +1,24 @@
import { useToast } from "@/hooks/use-toast";
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root ref={ref} className={cn("flex items-center justify-center gap-1", className)} {...props}>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@@ -0,0 +1,37 @@
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root ref={ref} className={cn(toggleVariants({ variant, size, className }))} {...props} />
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };

View File

@@ -0,0 +1,28 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,3 @@
import { useToast, toast } from "@/hooks/use-toast";
export { useToast, toast };

View File

@@ -0,0 +1,359 @@
import { createContext, useContext, useState, ReactNode } from "react";
type Lang = "en" | "zh";
interface LanguageContextType {
lang: Lang;
setLang: (lang: Lang) => void;
t: (key: string) => string;
}
const translations: Record<string, Record<Lang, string>> = {
// Navbar
"nav.brand": { en: "Youwei Business School", zh: "有维商学" },
"nav.frontPage": { en: "Front Page", zh: "首页" },
"nav.aiAgent": { en: "AI Intelligent Agent", zh: "AI智能体" },
"nav.learning": { en: "Learning Center", zh: "学习中心" },
"nav.community": { en: "Alumni Community", zh: "校友社区" },
// Welcome
"welcome.title": { en: "Welcome back", zh: "欢迎回来" },
"welcome.subtitle": { en: "Today is a good day for studying, keep it up!", zh: "今天是学习的好日子,继续加油!" },
"welcome.tagline": { en: "AI-Driven Academy", zh: "AI驱动学院" },
"welcome.explore": { en: "Explore Platform", zh: "探索平台" },
"welcome.plans": { en: "View Plans", zh: "查看方案" },
// Tabs
"tab.overview": { en: "Platform Overview", zh: "平台概览" },
"tab.education": { en: "Youwei Education", zh: "有维教育" },
"tab.aiTools": { en: "AI Tools", zh: "AI工具" },
"tab.alumni": { en: "Youwei Alumni", zh: "有维校友" },
"tab.membership": { en: "Membership System", zh: "会员体系" },
// Platform Overview
"overview.opc": { en: "OPC Empowerment", zh: "OPC赋能" },
"overview.opcSub": { en: "Platform Positioning", zh: "平台定位" },
"overview.ai": { en: "AI-driven", zh: "AI驱动" },
"overview.aiSub": { en: "Core Values", zh: "核心价值" },
"overview.membership": { en: "Membership", zh: "会员制" },
"overview.membershipSub": { en: "Service Model", zh: "服务模式" },
"overview.count": { en: "2000+", zh: "2000+" },
"overview.countSub": { en: "Service OPC", zh: "服务OPC" },
"overview.mission": { en: "Platform Mission", zh: "平台使命" },
"overview.missionTag": { en: "Mission", zh: "使命" },
"overview.missionText": { en: "Youwei Business School is committed to becoming the most trusted growth partner for OPC entrepreneurs, helping every OPC achieve their business dreams through education, tools, and community connections.", zh: "有维商学院致力于成为OPC创业者最信赖的成长伙伴通过教育、工具和社区连接帮助每一位OPC实现创业梦想。" },
"overview.eduTitle": { en: "Youwei Education", zh: "有维教育" },
"overview.eduSub": { en: "Systematic business curriculum", zh: "系统化商业课程" },
"overview.eduItem1": { en: "Business model design", zh: "商业模式设计" },
"overview.eduItem2": { en: "Product Development Methodology", zh: "产品开发方法论" },
"overview.eduItem3": { en: "Marketing strategy", zh: "营销策略" },
"overview.eduItem4": { en: "Operations Management System", zh: "运营管理体系" },
"overview.aiToolTitle": { en: "AI Tools", zh: "AI工具" },
"overview.aiToolSub": { en: "Intelligent agents empower the entire entrepreneurial process", zh: "智能体赋能全创业流程" },
"overview.aiToolItem1": { en: "AISM Intelligent Customer Service", zh: "AISM智能客服" },
"overview.aiToolItem2": { en: "Business plan generation", zh: "商业计划生成" },
"overview.aiToolItem3": { en: "Market research and analysis", zh: "市场研究与分析" },
"overview.aiToolItem4": { en: "Financial model calculation", zh: "财务模型计算" },
"overview.alumniTitle": { en: "Youwei Alumni", zh: "有维校友" },
"overview.alumniSub": { en: "A community of entrepreneurs that helps each other and achieves win-win results", zh: "互帮互助、共赢共利的创业者社区" },
"overview.alumniItem1": { en: "Alumni Network Connection", zh: "校友人脉连接" },
"overview.alumniItem2": { en: "Precise matching of resources", zh: "精准资源匹配" },
"overview.alumniItem3": { en: "Online and offline activities", zh: "线上线下活动" },
"overview.alumniItem4": { en: "One-on-one tutoring", zh: "一对一辅导" },
// Education
"edu.title": { en: "Youwei Education: Unity of Knowledge and Action", zh: "有维教育:知行合一" },
"edu.subtitle": { en: "From cognitive upgrades to practical application, a systematic business education system is provided for OPC entrepreneurs.", zh: "从认知升级到实践应用为OPC创业者提供系统化的商业教育体系。" },
"edu.tag": { en: "Education", zh: "教育" },
"edu.core": { en: "Core Curriculum System", zh: "核心课程体系" },
"edu.biz": { en: "Business Model Design", zh: "商业模式设计" },
"edu.bizDesc": { en: "Master the Business Canvas and value proposition design to build a sustainable business model.", zh: "掌握商业画布和价值主张设计,构建可持续的商业模式。" },
"edu.product": { en: "Product Development Methodology", zh: "产品开发方法论" },
"edu.productDesc": { en: "Learn Lean Startup and MVP validation to quickly build market-accepted products.", zh: "学习精益创业和MVP验证快速构建市场接受的产品。" },
"edu.marketing": { en: "Marketing Strategy", zh: "营销策略" },
"edu.marketingDesc": { en: "Digital marketing, content operations, growth hacking, and omnichannel customer acquisition methodologies.", zh: "数字营销、内容运营、增长黑客和全渠道获客方法论。" },
"edu.ops": { en: "Operations Management System", zh: "运营管理体系" },
"edu.opsDesc": { en: "Team building, process optimization, and data-driven approaches to improve operational efficiency.", zh: "团队建设、流程优化和数据驱动的方法提升运营效率。" },
"edu.lessons": { en: "lessons", zh: "节课" },
"edu.students": { en: "students", zh: "名学员" },
"edu.completion": { en: "Completion", zh: "完成度" },
// Course Detail
"course.hours": { en: "hours of learning", zh: "小时学习时长" },
"course.info": { en: "Course Information", zh: "课程信息" },
"course.objective": { en: "Course Objective", zh: "课程目标" },
"course.audience": { en: "Target Audience", zh: "适合人群" },
"course.syllabus": { en: "Course Syllabus", zh: "课程大纲" },
"course.resources": { en: "Learning Resources", zh: "配套学习资源" },
"course.video": { en: "Video", zh: "视频" },
"course.videoPlaceholder": { en: "Video content coming soon", zh: "视频内容即将上线" },
"course.adminVideos": { en: "Latest uploads", zh: "最新上传视频" },
"course.noAdminVideos": { en: "No uploaded videos for this course yet.", zh: "本课程暂无管理员上传的视频。" },
"course.biz.objective": { en: "Help OPC entrepreneurs systematically master the Business Canvas tool, precisely define value propositions, and design implementable and scalable business models.", zh: "帮助OPC创业者系统掌握商业画布工具精准定义价值主张设计可落地、可扩张的商业模式。" },
"course.biz.audience": { en: "Independent entrepreneurs, freelancers, super individuals, small business founders.", zh: "独立创业者、自由职业者、超级个体、小微企业创始人。" },
"course.biz.mod1": { en: "Module 1: Business Model Cognition & Business Canvas", zh: "模块1商业模式认知与商业画布" },
"course.biz.mod1.l1": { en: "Lesson 1: The Essence and Core Logic of Business Models", zh: "第1课商业模式的本质与核心逻辑" },
"course.biz.mod1.l2": { en: "Lesson 2: Deconstructing the 9 Modules of the Business Canvas", zh: "第2课商业画布9大模块拆解与应用" },
"course.biz.mod1.hw": { en: "Homework: Draft your personal project Business Canvas", zh: "作业:绘制个人项目商业画布初稿" },
"course.biz.mod2": { en: "Module 2: Value Proposition Design", zh: "模块2价值主张设计" },
"course.biz.mod2.l1": { en: "Lesson 3: Customer Segment Analysis", zh: "第3课客户细分分析" },
"course.biz.mod2.l2": { en: "Lesson 4: Value Proposition Canvas Application", zh: "第4课价值主张画布应用" },
"course.product.objective": { en: "Enable non-technical entrepreneurs to independently complete product planning, prototyping, requirement documentation, and iteration management.", zh: "让非技术出身创业者也能独立完成产品规划、原型制作、需求文档与迭代管理。" },
"course.product.audience": { en: "Product newcomers, independent developers, startup project leaders, operations-to-product personnel.", zh: "产品新人、独立开发者、创业项目负责人、运营转产品人员。" },
"course.product.mod1": { en: "Module 1: Product Demand & User Research", zh: "模块1产品需求与用户研究" },
"course.product.mod1.l1": { en: "Lesson 1: User Research and Demand Mining Methods", zh: "第1课用户调研与需求挖掘方法" },
"course.product.mod1.l2": { en: "Lesson 2: Writing Product Requirement Documents (PRD)", zh: "第2课产品需求文档(PRD)撰写" },
"course.marketing.objective": { en: "Build a comprehensive marketing system for low-cost customer acquisition, high-conversion sales, and long-term repeat purchases.", zh: "搭建全域营销体系,实现低成本获客、高转化成交与长期复购。" },
"course.marketing.audience": { en: "Marketing managers, entrepreneurs, growth hackers, content operators.", zh: "营销经理、创业者、增长黑客、内容运营人员。" },
"course.marketing.mod1": { en: "Module 1: Omni-channel Traffic Layout", zh: "模块1全域流量布局" },
"course.marketing.mod1.l1": { en: "Lesson 1: Public + Private Traffic Loop Design", zh: "第1课公域+私域流量闭环设计" },
"course.marketing.mod1.l2": { en: "Lesson 2: Content Marketing Strategy", zh: "第2课内容营销策略" },
"course.ops.objective": { en: "Build standardized operational processes, improve efficiency, reduce costs, and achieve automated business operations.", zh: "构建标准化运营流程,提升效率、降低成本,实现业务自动化运转。" },
"course.ops.audience": { en: "Operations managers, startup team leaders, process optimization personnel.", zh: "运营经理、创业团队负责人、流程优化人员。" },
"course.ops.mod1": { en: "Module 1: Standardized Operation Processes", zh: "模块1标准化运营流程" },
"course.ops.mod1.l1": { en: "Lesson 1: SOP Process Setup and Implementation", zh: "第1课SOP流程搭建与落地执行" },
"course.ops.mod1.l2": { en: "Lesson 2: Data-Driven Operations Management", zh: "第2课数据驱动的运营管理" },
"course.res.ppt": { en: "Course PPT Download", zh: "课程PPT下载" },
"course.res.canvas": { en: "Business Canvas Toolkit", zh: "商业画布工具包" },
"course.res.prd": { en: "PRD Template Pack", zh: "PRD模板包" },
"course.res.strategy": { en: "Marketing Strategy Toolkit", zh: "营销策略工具包" },
"course.res.sop": { en: "SOP Template Pack", zh: "SOP模板包" },
// AI Tools
"ai.title": { en: "AI Intelligent Agent Portal", zh: "AI智能体门户" },
"ai.subtitle": { en: "Choose the AI assistant you need and start your intelligent work experience.", zh: "选择您需要的AI助手开启智能化工作体验。" },
"ai.tag": { en: "AI Agents", zh: "AI智能体" },
"ai.search": { en: "Search agent...", zh: "搜索智能体..." },
"ai.all": { en: "All", zh: "全部" },
"ai.customerService": { en: "Customer Service", zh: "客户服务" },
"ai.efficiency": { en: "Efficiency", zh: "效率" },
"ai.analyze": { en: "Analyze", zh: "分析" },
"ai.startChat": { en: "Start conversation", zh: "开始对话" },
"ai.hr": { en: "Human Resources", zh: "人力资源" },
"ai.legal": { en: "Legal Compliance", zh: "法律合规" },
"ai.marketingCat": { en: "Marketing", zh: "营销" },
"ai.admin": { en: "Administrative Management", zh: "行政管理" },
"ai.education": { en: "Educational Scenarios", zh: "教育场景" },
"ai.health": { en: "Health & Wellness", zh: "健康养生" },
"ai.industrial": { en: "Industrial Sector", zh: "工业领域" },
// AI Agent names
"ai.aism.name": { en: "AISM Platform Intelligent Customer Service", zh: "AISM平台智能客服" },
"ai.aism.tag": { en: "Customer Service", zh: "客户服务" },
"ai.aism.desc": { en: "We offer 24/7 online service to answer your questions about platform usage, provide intelligent customer support, and respond quickly to your needs.", zh: "我们提供7x24小时在线服务解答您关于平台使用的问题提供智能客户支持快速响应您的需求。" },
"ai.meeting.name": { en: "Meeting Minutes Assistant", zh: "会议纪要助手" },
"ai.meeting.tag": { en: "Productivity Tools", zh: "生产力工具" },
"ai.meeting.desc": { en: "Automatically record meeting content, intelligently generate structured minutes, extract key decisions and action items, and improve meeting efficiency.", zh: "自动记录会议内容,智能生成结构化纪要,提取关键决策和行动项,提升会议效率。" },
"ai.deepseek.name": { en: "Deepseek Q&A Assistant", zh: "Deepseek问答助手" },
"ai.deepseek.tag": { en: "Knowledge Q&A", zh: "知识问答" },
"ai.deepseek.desc": { en: "A deep learning-based intelligent question-answering system can answer various professional questions you may have regarding business, technology, and management.", zh: "基于深度学习的智能问答系统,能够回答您关于商业、技术和管理方面的各种专业问题。" },
"ai.adminAssist.name": { en: "Administrative Assistant", zh: "行政助手" },
"ai.adminAssist.tag": { en: "Daily Office", zh: "日常办公" },
"ai.adminAssist.desc": { en: "Handle routine administrative tasks such as scheduling, document organization, and reminders to make office work more efficient.", zh: "处理日常行政任务,如日程安排、文档整理和提醒,让办公更高效。" },
"ai.strategic.name": { en: "Strategic Target Analyst", zh: "战略目标分析师" },
"ai.strategic.tag": { en: "Strategic Consulting", zh: "战略咨询" },
"ai.strategic.desc": { en: "Help with the formulation and breakdown of strategic objectives, SWOT analysis, competitor research, and market insights.", zh: "帮助制定和分解战略目标SWOT分析竞争对手研究和市场洞察。" },
"ai.bizplan.name": { en: "Business Plan Generator", zh: "商业计划书生成器" },
"ai.bizplan.tag": { en: "Startup Tools", zh: "创业工具" },
"ai.bizplan.desc": { en: "Quickly generate professional business plans, including market analysis, financial forecasts, and other complete content.", zh: "快速生成专业商业计划书,包括市场分析、财务预测等完整内容。" },
"ai.marketReport.name": { en: "Market Analysis Report AI Agent", zh: "市场分析报告AI智能体" },
"ai.marketReport.tag": { en: "Market Intelligence", zh: "市场情报" },
"ai.marketReport.desc": {
en: "Synthesize industry trends, competitive landscape, and demand signals into structured market analysis reports for decision-making.",
zh: "整合行业趋势、竞争格局与需求信号,输出结构化市场分析报告,支撑业务决策。",
},
"ai.techReport.name": { en: "Technical Analysis Report AI Agent", zh: "技术分析报告AI智能体" },
"ai.techReport.tag": { en: "Technical Research", zh: "技术研究" },
"ai.techReport.desc": {
en: "Turn technical materials and benchmarks into clear analysis reports covering feasibility, risks, and implementation paths.",
zh: "将技术资料与对标信息转化为清晰的技术分析报告,涵盖可行性、风险与落地路径。",
},
"ai.factorySourcing.name": { en: "Factory Sourcing AI Agent", zh: "工厂寻源AI智能体" },
"ai.factorySourcing.tag": { en: "Supply Chain", zh: "供应链" },
"ai.factorySourcing.desc": {
en: "Match and shortlist manufacturing partners by capability, capacity, certification, and geography for sourcing projects.",
zh: "按能力、产能、资质与区域匹配制造伙伴,为寻源项目筛选与推荐工厂。",
},
"ai.patentCompare.name": { en: "Patent Innovation Point Comparison AI Agent", zh: "专利创新点比对AI智能体" },
"ai.patentCompare.tag": { en: "IP Analysis", zh: "知识产权" },
"ai.patentCompare.desc": {
en: "Compare claims and technical features across patents to highlight innovation differences and overlap.",
zh: "对比专利权利要求与技术特征,突出创新差异与重叠点,辅助专利研判。",
},
"ai.zoneInspection.name": { en: "Special Zone Product Innovation Inspection AI Agent", zh: "专区产品创新稽查AI智能体" },
"ai.zoneInspection.tag": { en: "Product Compliance", zh: "产品合规" },
"ai.zoneInspection.desc": {
en: "Audit listed products in dedicated zones for innovation claims, consistency, and policy alignment.",
zh: "针对专区上架产品开展创新点、表述一致性与政策符合性的智能稽查。",
},
"ai.techPrice.name": { en: "Tech Transaction Price Evaluation AI Agent (Patent)", zh: "技术交易价格评估AI智能体/专利价" },
"ai.techPrice.tag": { en: "Valuation", zh: "价值评估" },
"ai.techPrice.desc": {
en: "Estimate reasonable ranges for technology or patent transactions using comparables, licensing context, and risk factors.",
zh: "结合可比交易、许可场景与风险因素,估算技术或专利交易的合理价格区间。",
},
"ai.patentMicroNav.name": { en: "Patent Micro-navigation Analysis Report Agent", zh: "专利微导航分析报告智能体" },
"ai.patentMicroNav.tag": { en: "Patent Landscape", zh: "专利导航" },
"ai.patentMicroNav.desc": {
en: "Map citation networks and technology branches to produce micro-navigation style patent landscape reports.",
zh: "梳理引用关系与技术分支,输出专利微导航式分析报告,看清技术脉络。",
},
// AI agent tags (translatable)
"ai.tag.instantResponse": { en: "Instant response", zh: "即时响应" },
"ai.tag.multiRound": { en: "Multiple rounds", zh: "多轮对话" },
"ai.tag.problemTriage": { en: "Problem triage", zh: "问题分流" },
"ai.tag.speechToText": { en: "Speech-to-text", zh: "语音转文字" },
"ai.tag.keyPoints": { en: "Key points", zh: "要点提取" },
"ai.tag.taskAllocation": { en: "Task allocation", zh: "任务分配" },
"ai.tag.deepUnderstanding": { en: "Deep understanding", zh: "深度理解" },
"ai.tag.accurate": { en: "Accurate", zh: "精准" },
"ai.tag.knowledgeable": { en: "Knowledgeable", zh: "博学" },
"ai.tag.scheduling": { en: "Scheduling", zh: "日程安排" },
"ai.tag.documents": { en: "Documents", zh: "文档整理" },
"ai.tag.reminders": { en: "Reminders", zh: "提醒" },
"ai.tag.swotAnalysis": { en: "SWOT analysis", zh: "SWOT分析" },
"ai.tag.competitorResearch": { en: "Competitor research", zh: "竞争分析" },
"ai.tag.marketInsights": { en: "Market insights", zh: "市场洞察" },
"ai.tag.templates": { en: "Templates", zh: "模板" },
"ai.tag.dataAnalysis": { en: "Data Analysis", zh: "数据分析" },
"ai.tag.oneClickExport": { en: "One-click export", zh: "一键导出" },
"ai.tag.trendScan": { en: "Trend scan", zh: "趋势扫描" },
"ai.tag.sectorBenchmark": { en: "Sector benchmark", zh: "行业对标" },
"ai.tag.reportStructured": { en: "Structured reports", zh: "结构化报告" },
"ai.tag.techFeasibility": { en: "Feasibility", zh: "可行性" },
"ai.tag.riskReview": { en: "Risk review", zh: "风险梳理" },
"ai.tag.implementationPath": { en: "Implementation path", zh: "落地路径" },
"ai.tag.capacityMatch": { en: "Capacity match", zh: "产能匹配" },
"ai.tag.certAudit": { en: "Certification audit", zh: "资质审核" },
"ai.tag.geoSourcing": { en: "Regional sourcing", zh: "区域寻源" },
"ai.tag.claimDiff": { en: "Claim diff", zh: "权利要求对比" },
"ai.tag.featureOverlap": { en: "Feature overlap", zh: "特征重叠" },
"ai.tag.priorArtHint": { en: "Prior-art hints", zh: "现有技术提示" },
"ai.tag.innovationClaims": { en: "Innovation claims", zh: "创新点核查" },
"ai.tag.listingConsistency": { en: "Listing consistency", zh: "上架一致性" },
"ai.tag.policyAlignment": { en: "Policy alignment", zh: "政策符合" },
"ai.tag.comparableTx": { en: "Comparable deals", zh: "可比交易" },
"ai.tag.licensingContext": { en: "Licensing context", zh: "许可场景" },
"ai.tag.priceRange": { en: "Price range", zh: "价格区间" },
"ai.tag.citationMap": { en: "Citation map", zh: "引用图谱" },
"ai.tag.techBranches": { en: "Tech branches", zh: "技术分支" },
"ai.tag.landscapeBrief": { en: "Landscape brief", zh: "全景简报" },
// Alumni
"alumni.title": { en: "Youwei Alumni: A Community of Entrepreneurs Building Through Mutual Support and Win-Win Cooperation", zh: "有维校友:互助互利、合作共赢的创业者社区" },
"alumni.subtitle": { en: "Connecting 2000+ high-quality OPC entrepreneurs, sharing resources, helping each other grow, and creating value together.", zh: "连接2000+优质OPC创业者共享资源、互助成长、共创价值。" },
"alumni.tag": { en: "Alumni", zh: "校友" },
"alumni.network": { en: "Alumni Network", zh: "校友人脉" },
"alumni.networkSub": { en: "2000+ precise contacts to expand business opportunities", zh: "2000+精准人脉拓展商机" },
"alumni.networkItem1": { en: "Precise industry classification", zh: "精准行业分类" },
"alumni.networkItem2": { en: "Wide geographical coverage", zh: "广泛地域覆盖" },
"alumni.networkItem3": { en: "Resource complementarity and docking", zh: "资源互补对接" },
"alumni.resource": { en: "Resource Matching", zh: "资源匹配" },
"alumni.resourceSub": { en: "Supply and demand matching, win-win cooperation", zh: "供需匹配,合作共赢" },
"alumni.resourceItem1": { en: "Project cooperation", zh: "项目合作" },
"alumni.resourceItem2": { en: "Channel Integration", zh: "渠道整合" },
"alumni.resourceItem3": { en: "Investment and financing opportunities", zh: "投融资机会" },
"alumni.events": { en: "Exclusive Events", zh: "专属活动" },
"alumni.eventsSub": { en: "100+ monthly events learn and socialize simultaneously!", zh: "每月100+活动——学习社交两不误!" },
"alumni.eventsItem1": { en: "Industry Salon", zh: "行业沙龙" },
"alumni.eventsItem2": { en: "Entrepreneurship Competition", zh: "创业大赛" },
"alumni.eventsItem3": { en: "Annual Summit", zh: "年度峰会" },
"alumni.qa": { en: "Mutual Help Q&A", zh: "互助问答" },
"alumni.qaSub": { en: "Experience sharing and mutual assistance", zh: "经验分享与互助" },
"alumni.qaItem1": { en: "Practical experience sharing", zh: "实战经验分享" },
"alumni.qaItem2": { en: "Difficult problems overcome collectively", zh: "难题集体攻克" },
"alumni.qaItem3": { en: "Case study review", zh: "案例复盘" },
"alumni.mentorship": { en: "Mentorship", zh: "导师辅导" },
"alumni.mentorshipSub": { en: "One-on-one guidance from senior mentors", zh: "资深导师一对一指导" },
"alumni.mentorshipItem1": { en: "Industry experts", zh: "行业专家" },
"alumni.mentorshipItem2": { en: "Successful entrepreneurs", zh: "成功创业者" },
"alumni.mentorshipItem3": { en: "Investor Mentor", zh: "投资人导师" },
"alumni.cert": { en: "Alumni Certification", zh: "校友认证" },
"alumni.certSub": { en: "Exclusive identity identifier, endorsement of trust", zh: "专属身份标识,信任背书" },
"alumni.certItem1": { en: "Official certification mark", zh: "官方认证标识" },
"alumni.certItem2": { en: "Credit system", zh: "信用体系" },
"alumni.certItem3": { en: "Priority rights", zh: "优先权益" },
// Membership
"member.title": { en: "Membership System", zh: "会员体系" },
"member.tag": { en: "Membership", zh: "会员" },
"member.subtitle": { en: "Join Youwei and start your entrepreneurial growth journey!", zh: "加入有维,开启创业成长之旅吧" },
"member.basic": { en: "Basic Version", zh: "基础版" },
"member.free": { en: "Free", zh: "免费" },
"member.basicDesc": { en: "Suitable for new users who are just getting to know the platform", zh: "适合刚了解平台的新用户" },
"member.basicItem1": { en: "Basic course preview", zh: "基础课程预览" },
"member.basicItem2": { en: "AI agent experience (3 times daily)", zh: "AI智能体体验每日3次" },
"member.basicItem3": { en: "Alumni Community Browsing", zh: "校友社区浏览" },
"member.basicItem4": { en: "Platform basic functions", zh: "平台基础功能" },
"member.basicBtn": { en: "Experience it now", zh: "立即体验" },
"member.pro": { en: "Professional Version", zh: "专业版" },
"member.proPrice": { en: "¥5,999 / year", zh: "¥5,999 / 年" },
"member.proDesc": { en: "Suitable for OPC entrepreneurs in the growth stage", zh: "适合成长期的OPC创业者" },
"member.proItem1": { en: "Unlimited access to all courses", zh: "全部课程无限制观看" },
"member.proItem2": { en: "AI agents can be used indefinitely", zh: "AI智能体无限使用" },
"member.proItem3": { en: "Alumni Community Full Functionality", zh: "校友社区全功能" },
"member.proItem4": { en: "Monthly Online Activities", zh: "每月线上活动" },
"member.proItem5": { en: "Exclusive study group", zh: "专属学习小组" },
"member.proItem6": { en: "Mentor Q&A (twice a month)", zh: "导师问答(每月两次)" },
"member.proBtn": { en: "Subscribe now", zh: "立即订阅" },
"member.recommended": { en: "Recommended", zh: "推荐" },
"member.business": { en: "Business Edition", zh: "商业版" },
"member.bizPrice": { en: "¥9,999 / year", zh: "¥9,999 / 年" },
"member.bizDesc": { en: "For growing OPC teams seeking expansion", zh: "适合寻求扩展的OPC团队" },
"member.bizItem1": { en: "Includes all benefits of the professional version", zh: "包含专业版所有权益" },
"member.bizItem2": { en: "Team accounts (maximum 5 people)", zh: "团队账号最多5人" },
"member.bizItem3": { en: "Priority participation in offline events", zh: "线下活动优先参与" },
"member.bizItem4": { en: "One-on-one tutoring", zh: "一对一辅导" },
"member.bizItem5": { en: "Dedicated Account Manager", zh: "专属客户经理" },
"member.bizItem6": { en: "Customized training programs", zh: "定制化培训方案" },
"member.bizBtn": { en: "Contact sales", zh: "联系销售" },
"member.custom": { en: "Custom Edition", zh: "定制版" },
"member.customPrice": { en: "From ¥49,999 / year", zh: "¥49,999 / 年起" },
"member.customDesc": { en: "Fully tailored solutions for large-scale OPC organizations", zh: "适合规模化发展的OPC团队定制方案" },
"member.customItem1": { en: "Includes all benefits of the business version", zh: "包含商业版所有权益" },
"member.customItem2": { en: "Unlimited team accounts", zh: "无限团队账号" },
"member.customItem3": { en: "Dedicated success manager", zh: "专属成功经理" },
"member.customItem4": { en: "Custom curriculum design", zh: "定制课程设计" },
"member.customItem5": { en: "On-site training support", zh: "驻场培训支持" },
"member.customItem6": { en: "Enterprise API access", zh: "企业API接入" },
"member.customBtn": { en: "Contact sales", zh: "联系销售" },
// Customer service
"course.addService": { en: "Add Service for More Courses", zh: "添加客服获取更多课程" },
"course.addServiceTitle": { en: "Add Service for More Course Info", zh: "添加客服信息获取更多课程信息" },
"course.scanQr": { en: "Scan the QR code to contact us", zh: "扫描二维码添加客服微信" },
// Language toggle
"lang.en": { en: "EN", zh: "EN" },
"lang.zh": { en: "中文", zh: "中文" },
"lang.settings": { en: "Language", zh: "语言" },
};
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export function LanguageProvider({ children }: { children: ReactNode }) {
const [lang, setLang] = useState<Lang>("zh");
const t = (key: string): string => {
return translations[key]?.[lang] ?? key;
};
return (
<LanguageContext.Provider value={{ lang, setLang, t }}>
{children}
</LanguageContext.Provider>
);
}
export function useLang() {
const context = useContext(LanguageContext);
if (!context) throw new Error("useLang must be used within LanguageProvider");
return context;
}

View File

@@ -0,0 +1,24 @@
import { createContext, useContext, useState, ReactNode } from "react";
interface UserContextType {
username: string;
setUsername: (name: string) => void;
}
const UserContext = createContext<UserContextType | null>(null);
export function UserProvider({ children }: { children: ReactNode }) {
const [username, setUsername] = useState("");
return (
<UserContext.Provider value={{ username, setUsername }}>
{children}
</UserContext.Provider>
);
}
export function useUser() {
const ctx = useContext(UserContext);
if (!ctx) throw new Error("useUser must be used within UserProvider");
return ctx;
}

19
src/hooks/use-mobile.tsx Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

186
src/hooks/use-toast.ts Normal file
View File

@@ -0,0 +1,186 @@
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

32
src/hooks/useCountUp.ts Normal file
View File

@@ -0,0 +1,32 @@
import { useState, useEffect, useRef } from "react";
export function useCountUp(end: number, duration = 2000, startOnView = true) {
const [count, setCount] = useState(0);
const [started, setStarted] = useState(!startOnView);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!startOnView || !ref.current) return;
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setStarted(true); },
{ threshold: 0.3 }
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [startOnView]);
useEffect(() => {
if (!started) return;
let startTime: number;
const step = (timestamp: number) => {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
setCount(Math.floor(eased * end));
if (progress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}, [started, end, duration]);
return { count, ref };
}

228
src/index.css Normal file
View File

@@ -0,0 +1,228 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 220 14% 96%;
--foreground: 220 20% 10%;
--card: 0 0% 100%;
--card-foreground: 220 20% 10%;
--popover: 0 0% 100%;
--popover-foreground: 220 20% 10%;
--primary: 220 80% 58%;
--primary-foreground: 0 0% 100%;
--secondary: 220 10% 94%;
--secondary-foreground: 220 10% 40%;
--muted: 220 10% 92%;
--muted-foreground: 220 10% 46%;
--accent: 260 60% 58%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 55%;
--destructive-foreground: 0 0% 100%;
--border: 220 10% 88%;
--input: 220 10% 88%;
--ring: 220 80% 58%;
--radius: 1.25rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 220 10% 40%;
--sidebar-primary: 220 80% 58%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 220 10% 96%;
--sidebar-accent-foreground: 220 20% 10%;
--sidebar-border: 220 10% 90%;
--sidebar-ring: 220 80% 58%;
--glass-bg: 0 0% 100% / 0.45;
--glass-border: 0 0% 100% / 0.65;
--glass-shadow: 220 60% 50% / 0.06;
--glass-inner: 0 0% 100% / 0.7;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-family: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
@layer components {
/* Apple-style liquid glass card */
.glass-card {
background: hsla(var(--glass-bg));
backdrop-filter: blur(60px) saturate(2);
-webkit-backdrop-filter: blur(60px) saturate(2);
border: 1px solid hsla(var(--glass-border));
box-shadow:
0 0 0 0.5px hsla(0 0% 100% / 0.5),
0 2px 8px hsla(var(--glass-shadow)),
0 12px 40px hsla(220 60% 50% / 0.04),
inset 0 1px 0 hsla(var(--glass-inner));
@apply rounded-2xl transition-all duration-500 ease-out;
}
.glass-card:hover {
background: hsla(0 0% 100% / 0.55);
box-shadow:
0 0 0 0.5px hsla(0 0% 100% / 0.6),
0 4px 12px hsla(var(--glass-shadow)),
0 20px 60px hsla(220 60% 50% / 0.07),
inset 0 1px 0 hsla(0 0% 100% / 0.8);
}
/* Elevated glass variant for highlighted cards */
.glass-card-elevated {
background: hsla(0 0% 100% / 0.6);
backdrop-filter: blur(80px) saturate(2.2);
-webkit-backdrop-filter: blur(80px) saturate(2.2);
border: 1.5px solid hsla(0 0% 100% / 0.7);
box-shadow:
0 0 0 0.5px hsla(0 0% 100% / 0.5),
0 4px 16px hsla(220 60% 50% / 0.08),
0 24px 60px hsla(220 60% 50% / 0.06),
inset 0 1px 0 hsla(0 0% 100% / 0.8),
inset 0 -1px 0 hsla(0 0% 0% / 0.02);
@apply rounded-3xl transition-all duration-500 ease-out;
}
.glass-card-elevated:hover {
transform: translateY(-3px);
box-shadow:
0 0 0 0.5px hsla(0 0% 100% / 0.6),
0 8px 24px hsla(220 60% 50% / 0.1),
0 32px 80px hsla(220 60% 50% / 0.08),
inset 0 1px 0 hsla(0 0% 100% / 0.9),
inset 0 -1px 0 hsla(0 0% 0% / 0.02);
}
.glass-nav {
background: hsla(0 0% 100% / 0.5);
backdrop-filter: blur(60px) saturate(2);
-webkit-backdrop-filter: blur(60px) saturate(2);
border: 1px solid hsla(0 0% 100% / 0.6);
box-shadow:
0 0 0 0.5px hsla(0 0% 100% / 0.4),
0 4px 30px hsla(220 60% 50% / 0.06),
inset 0 1px 0 hsla(0 0% 100% / 0.6);
}
.glass-pill {
background: hsla(0 0% 100% / 0.5);
backdrop-filter: blur(30px) saturate(1.6);
-webkit-backdrop-filter: blur(30px) saturate(1.6);
border: 1px solid hsla(0 0% 100% / 0.55);
box-shadow:
0 1px 4px hsla(220 60% 50% / 0.04),
inset 0 1px 0 hsla(0 0% 100% / 0.5);
@apply rounded-full;
}
.glass-tab-bar {
background: hsla(0 0% 100% / 0.35);
backdrop-filter: blur(60px) saturate(2);
-webkit-backdrop-filter: blur(60px) saturate(2);
border: 1px solid hsla(0 0% 100% / 0.5);
box-shadow:
0 2px 12px hsla(220 60% 50% / 0.04),
inset 0 1px 0 hsla(0 0% 100% / 0.5);
}
.nav-tab-active {
@apply relative px-4 py-2 text-sm font-medium transition-all;
color: hsl(var(--primary));
}
.nav-tab-inactive {
@apply px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors cursor-pointer;
}
.icon-circle {
@apply w-12 h-12 rounded-2xl flex items-center justify-center text-lg;
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--accent)));
color: white;
box-shadow: 0 4px 16px hsla(220 80% 58% / 0.25);
}
.icon-circle-muted {
@apply w-11 h-11 rounded-xl flex items-center justify-center text-lg;
background: hsla(220 60% 50% / 0.08);
color: hsl(var(--primary));
}
.section-tag {
@apply inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium;
color: hsl(var(--primary));
background: hsla(220 80% 58% / 0.08);
border: 1px solid hsla(220 80% 58% / 0.12);
}
.btn-primary {
@apply px-6 py-3 rounded-full text-sm font-medium transition-all duration-300;
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--accent)));
color: white;
box-shadow: 0 4px 16px hsla(220 80% 58% / 0.3);
}
.btn-primary:hover {
box-shadow: 0 8px 28px hsla(220 80% 58% / 0.4);
filter: brightness(1.08);
}
.btn-glass {
@apply px-6 py-3 rounded-full text-sm font-medium transition-all duration-300;
background: hsla(0 0% 100% / 0.6);
backdrop-filter: blur(20px);
border: 1px solid hsla(0 0% 100% / 0.5);
color: hsl(var(--foreground));
box-shadow: 0 2px 8px hsla(220 60% 50% / 0.04);
}
.btn-glass:hover {
background: hsla(0 0% 100% / 0.8);
transform: translateY(-1px);
box-shadow: 0 8px 24px hsla(220 60% 50% / 0.08);
}
/* Keep old classes for compatibility */
.btn-dark {
@apply btn-primary;
}
.btn-light {
@apply btn-glass;
}
.step-number {
@apply text-7xl font-bold tracking-tighter;
color: hsla(220 60% 50% / 0.05);
}
.gradient-text {
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--accent)));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
/* Hide scrollbar utility */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}

17
src/lib/adminSession.ts Normal file
View File

@@ -0,0 +1,17 @@
export const ADMIN_SESSION_KEY = "youwei_admin_session";
export function isAdminSession(): boolean {
try {
return sessionStorage.getItem(ADMIN_SESSION_KEY) === "1";
} catch {
return false;
}
}
export function clearAdminSession(): void {
try {
sessionStorage.removeItem(ADMIN_SESSION_KEY);
} catch {
/* ignore */
}
}

119
src/lib/api.ts Normal file
View File

@@ -0,0 +1,119 @@
/** API base: empty in dev (Vite proxies /api); set VITE_API_BASE_URL in production. */
export function getApiBaseUrl(): string {
return import.meta.env.VITE_API_BASE_URL ?? "";
}
export async function fetchApi(path: string, init?: RequestInit): Promise<Response> {
const base = getApiBaseUrl();
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
return fetch(url, init);
}
/** Verifies the Node API can reach MySQL (GET /api/db/ping). */
export async function pingDatabase(): Promise<{ ok: boolean }> {
try {
const res = await fetchApi("/api/db/ping");
if (!res.ok) return { ok: false };
const data = (await res.json()) as { ok?: boolean };
return { ok: Boolean(data.ok) };
} catch {
return { ok: false };
}
}
export type LoginUser = { id: string | number | null; displayName: string; login: string };
export type LoginResult = { ok: true; user: LoginUser } | { ok: false; error: string };
export async function loginWithDatabase(login: string, password: string): Promise<LoginResult> {
const res = await fetchApi("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ login, password }),
});
const data = (await res.json()) as { ok?: boolean; user?: LoginUser; error?: string };
if (res.ok && data.ok && data.user) {
return { ok: true, user: data.user };
}
return { ok: false, error: data.error || "Login failed" };
}
export async function verifyAdminPassword(password: string): Promise<{ ok: boolean; error?: string }> {
const res = await fetchApi("/api/auth/admin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = (await res.json()) as { ok?: boolean; error?: string };
if (res.ok && data.ok) return { ok: true };
return { ok: false, error: data.error || "Unauthorized" };
}
export type CourseVideo = {
id: string;
title: string;
courseKey: string;
courseLabelZh: string;
courseLabelEn: string;
moduleName: string;
videoUrl: string;
status: string;
views: number;
duration: string;
uploadDate: string;
};
/** Absolute URL for uploaded video paths (`/uploads/...`) in production. */
export function mediaUrl(pathOrUrl: string): string {
if (!pathOrUrl) return "";
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) return pathOrUrl;
const base = getApiBaseUrl();
const p = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
return `${base}${p}`;
}
export async function fetchPublishedCourseVideos(courseKey: string): Promise<CourseVideo[]> {
const res = await fetchApi(`/api/course-videos?courseKey=${encodeURIComponent(courseKey)}`);
const data = (await res.json()) as { ok?: boolean; videos?: CourseVideo[] };
if (res.ok && data.ok && data.videos) return data.videos;
return [];
}
export async function fetchAllCourseVideos(): Promise<CourseVideo[]> {
const res = await fetchApi("/api/course-videos?scope=admin");
const data = (await res.json()) as { ok?: boolean; videos?: CourseVideo[] };
if (res.ok && data.ok && data.videos) return data.videos;
return [];
}
export async function createCourseVideo(
formData: FormData,
): Promise<{ ok: boolean; video?: CourseVideo; error?: string }> {
const res = await fetchApi("/api/course-videos", { method: "POST", body: formData });
const data = (await res.json()) as { ok?: boolean; video?: CourseVideo; error?: string };
if (res.ok && data.ok && data.video) return { ok: true, video: data.video };
return { ok: false, error: data.error || "Upload failed" };
}
export async function deleteCourseVideo(id: string): Promise<boolean> {
const res = await fetchApi(`/api/course-videos/${encodeURIComponent(id)}`, { method: "DELETE" });
const data = (await res.json()) as { ok?: boolean };
return res.ok && Boolean(data.ok);
}
export async function updateCourseVideoStatus(
id: string,
status: "published" | "draft" | "processing",
): Promise<{ ok: boolean; video?: CourseVideo }> {
const res = await fetchApi(`/api/course-videos/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
const data = (await res.json()) as { ok?: boolean; video?: CourseVideo };
if (res.ok && data.ok) return { ok: true, video: data.video };
return { ok: false };
}
export function recordCourseVideoView(id: string): void {
fetchApi(`/api/course-videos/${encodeURIComponent(id)}/view`, { method: "POST" }).catch(() => {});
}

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));
}

6
src/main.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);

581
src/pages/Admin.tsx Normal file
View File

@@ -0,0 +1,581 @@
import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useLang } from "@/contexts/LanguageContext";
import { isAdminSession, clearAdminSession } from "@/lib/adminSession";
import { toast } from "sonner";
import {
type CourseVideo,
fetchAllCourseVideos,
createCourseVideo,
deleteCourseVideo,
updateCourseVideoStatus,
} from "@/lib/api";
import { motion, AnimatePresence } from "framer-motion";
import {
Upload, Video, Trash2, ArrowLeft, Eye,
Plus, FileVideo, Clock, Users, Search, Filter,
CheckCircle2, AlertCircle, Sparkles
} from "lucide-react";
import heroBg from "@/assets/hero-bg.jpg";
import logo from "@/assets/logo.png";
interface VideoItem {
id: string;
title: string;
course: string;
courseKey: string;
module: string;
duration: string;
uploadDate: string;
status: "published" | "draft" | "processing";
views: number;
thumbnail: string;
videoUrl?: string;
}
function thumbForCourseKey(key: string): string {
const m: Record<string, string> = {
biz: "📊",
product: "💡",
marketing: "📢",
ops: "⚙️",
};
return m[key] || "🎞";
}
function mapCourseVideoToItem(v: CourseVideo, lang: "en" | "zh"): VideoItem {
return {
id: v.id,
title: v.title,
course: lang === "zh" ? v.courseLabelZh : v.courseLabelEn,
courseKey: v.courseKey,
module: v.moduleName,
duration: v.duration,
uploadDate: v.uploadDate,
status: v.status as VideoItem["status"],
views: v.views,
thumbnail: thumbForCourseKey(v.courseKey),
videoUrl: v.videoUrl,
};
}
const courseOptions = [
{ key: "biz", label: { zh: "商业模式设计", en: "Business Model Design" } },
{ key: "product", label: { zh: "产品开发方法论", en: "Product Development" } },
{ key: "marketing", label: { zh: "营销策略", en: "Marketing Strategy" } },
{ key: "ops", label: { zh: "运营管理体系", en: "Operations Management" } },
];
export default function Admin() {
const { lang } = useLang();
const navigate = useNavigate();
useEffect(() => {
if (!isAdminSession()) {
navigate("/admin", { replace: true });
}
}, [navigate]);
const [videos, setVideos] = useState<VideoItem[]>([]);
const [listLoading, setListLoading] = useState(true);
const [showUpload, setShowUpload] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [filterCourse, setFilterCourse] = useState("all");
const [dragActive, setDragActive] = useState(false);
const [uploadTitle, setUploadTitle] = useState("");
const [uploadCourse, setUploadCourse] = useState("biz");
const [uploadModule, setUploadModule] = useState("");
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadBusy, setUploadBusy] = useState(false);
const refreshVideos = useCallback(async () => {
setListLoading(true);
try {
const list = await fetchAllCourseVideos();
setVideos(list.map((v) => mapCourseVideoToItem(v, lang)));
} catch {
toast.error(lang === "zh" ? "无法加载视频列表" : "Could not load videos");
} finally {
setListLoading(false);
}
}, [lang]);
useEffect(() => {
refreshVideos();
}, [refreshVideos]);
const resetUploadForm = () => {
setUploadTitle("");
setUploadCourse("biz");
setUploadModule(lang === "zh" ? "模块1" : "Module 1");
setUploadFile(null);
};
const buildUploadFormData = (status: "published" | "draft"): FormData => {
const co = courseOptions.find((c) => c.key === uploadCourse);
const fd = new FormData();
fd.append("file", uploadFile as File);
fd.append("title", uploadTitle.trim());
fd.append("courseKey", uploadCourse);
fd.append("courseLabelZh", co?.label.zh ?? "");
fd.append("courseLabelEn", co?.label.en ?? "");
fd.append("moduleName", uploadModule || (lang === "zh" ? "模块1" : "Module 1"));
fd.append("status", status);
return fd;
};
const handleSaveAsDraft = async () => {
if (!uploadFile) {
toast.error(lang === "zh" ? "请选择视频文件" : "Choose a video file");
return;
}
setUploadBusy(true);
try {
const r = await createCourseVideo(buildUploadFormData("draft"));
if (!r.ok) {
toast.error(r.error || (lang === "zh" ? "保存失败" : "Save failed"));
return;
}
toast.success(lang === "zh" ? "已保存草稿" : "Draft saved");
setShowUpload(false);
resetUploadForm();
await refreshVideos();
} catch {
toast.error(lang === "zh" ? "上传失败" : "Upload failed");
} finally {
setUploadBusy(false);
}
};
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (!uploadFile) {
toast.error(lang === "zh" ? "请选择视频文件" : "Choose a video file");
return;
}
setUploadBusy(true);
try {
const r = await createCourseVideo(buildUploadFormData("published"));
if (!r.ok) {
toast.error(r.error || (lang === "zh" ? "发布失败" : "Publish failed"));
return;
}
toast.success(lang === "zh" ? "已发布,学员可在对应课程页观看" : "Published — visible on the course page");
setShowUpload(false);
resetUploadForm();
await refreshVideos();
} catch {
toast.error(lang === "zh" ? "上传失败" : "Upload failed");
} finally {
setUploadBusy(false);
}
};
const handleDelete = async (id: string) => {
const ok = await deleteCourseVideo(id);
if (!ok) {
toast.error(lang === "zh" ? "删除失败" : "Delete failed");
return;
}
setVideos((prev) => prev.filter((v) => v.id !== id));
toast.success(lang === "zh" ? "已删除" : "Deleted");
};
const handlePublish = async (id: string) => {
const r = await updateCourseVideoStatus(id, "published");
if (!r.ok) {
toast.error(lang === "zh" ? "发布失败" : "Publish failed");
return;
}
toast.success(lang === "zh" ? "已发布" : "Published");
await refreshVideos();
};
const filteredVideos = videos.filter(v => {
const matchSearch = v.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
v.course.toLowerCase().includes(searchQuery.toLowerCase());
const matchCourse = filterCourse === "all" || v.courseKey === filterCourse;
return matchSearch && matchCourse;
});
const statusConfig = {
published: { color: "text-emerald-600 bg-emerald-500/10", icon: CheckCircle2, label: { zh: "已发布", en: "Published" } },
draft: { color: "text-amber-600 bg-amber-500/10", icon: AlertCircle, label: { zh: "草稿", en: "Draft" } },
processing: { color: "text-blue-600 bg-blue-500/10", icon: Clock, label: { zh: "处理中", en: "Processing" } },
};
// Admin Dashboard
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="relative">
<img src={heroBg} alt="" className="absolute inset-0 w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-b from-background/20 to-background" />
<div className="relative z-10 max-w-6xl mx-auto px-4 pt-4 pb-16">
<nav className="glass-nav rounded-2xl px-5 py-2.5 flex items-center justify-between mb-8">
<div className="flex items-center gap-2">
<img src={logo} alt="有维商学" className="w-7 h-7 rounded-xl object-contain" />
<span className="font-semibold text-foreground text-sm">
{lang === "zh" ? "管理后台" : "Admin Dashboard"}
</span>
</div>
<div className="flex items-center gap-3">
<span className="section-tag !py-1">
<Sparkles className="w-3 h-3" />
Admin
</span>
<button
onClick={() => {
clearAdminSession();
navigate("/home");
}}
className="glass-pill px-4 py-1.5 text-xs font-medium text-foreground hover:scale-105 transition-transform"
>
<ArrowLeft className="w-3 h-3 inline mr-1" />
{lang === "zh" ? "返回前台" : "Back to Site"}
</button>
</div>
</nav>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: lang === "zh" ? "总视频数" : "Total Videos", value: videos.length, icon: Video, color: "from-primary to-accent" },
{ label: lang === "zh" ? "已发布" : "Published", value: videos.filter(v => v.status === "published").length, icon: CheckCircle2, color: "from-emerald-500 to-teal-400" },
{ label: lang === "zh" ? "草稿" : "Drafts", value: videos.filter(v => v.status === "draft").length, icon: AlertCircle, color: "from-amber-500 to-orange-400" },
{ label: lang === "zh" ? "总播放量" : "Total Views", value: videos.reduce((sum, v) => sum + v.views, 0).toLocaleString(), icon: Users, color: "from-violet-500 to-purple-400" },
].map((stat, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="glass-card p-5"
>
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${stat.color} flex items-center justify-center text-white mb-3`}>
<stat.icon className="w-5 h-5" />
</div>
<p className="text-2xl font-bold text-foreground">{stat.value}</p>
<p className="text-xs text-muted-foreground mt-1">{stat.label}</p>
</motion.div>
))}
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-6xl mx-auto px-4 -mt-6 pb-16">
{/* Toolbar */}
<div className="glass-card rounded-2xl p-4 mb-6 flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={lang === "zh" ? "搜索视频..." : "Search videos..."}
className="w-full h-10 rounded-xl border border-border/50 bg-muted/20 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 transition-all"
/>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground" />
<select
value={filterCourse}
onChange={(e) => setFilterCourse(e.target.value)}
className="h-10 rounded-xl border border-border/50 bg-muted/20 pl-8 pr-4 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 transition-all appearance-none cursor-pointer"
>
<option value="all">{lang === "zh" ? "全部课程" : "All Courses"}</option>
{courseOptions.map(c => (
<option key={c.key} value={c.key}>{c.label[lang]}</option>
))}
</select>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => {
resetUploadForm();
setShowUpload(true);
}}
className="h-10 px-5 rounded-xl bg-gradient-to-r from-primary to-accent text-primary-foreground text-sm font-medium shadow-lg shadow-primary/20 flex items-center gap-2"
>
<Plus className="w-4 h-4" />
{lang === "zh" ? "上传视频" : "Upload Video"}
</motion.button>
</div>
</div>
{/* Video Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
{listLoading && (
<div className="col-span-full glass-card p-12 text-center text-muted-foreground text-sm">
{lang === "zh" ? "加载中…" : "Loading…"}
</div>
)}
{!listLoading && filteredVideos.map((video, i) => {
const status = statusConfig[video.status];
return (
<motion.div
key={video.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="glass-card overflow-hidden group"
whileHover={{ y: -4 }}
>
{/* Thumbnail */}
<div className="aspect-video bg-gradient-to-br from-muted/50 to-muted/30 flex items-center justify-center relative">
<span className="text-4xl">{video.thumbnail}</span>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
whileHover={{ opacity: 1, scale: 1 }}
className="w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<FileVideo className="w-5 h-5 text-primary-foreground" />
</motion.div>
</div>
<span className="absolute bottom-2 right-2 glass-pill px-2 py-0.5 text-[10px] font-medium text-foreground">
{video.duration}
</span>
</div>
{/* Info */}
<div className="p-4">
<div className="flex items-start justify-between gap-2 mb-2">
<h4 className="text-sm font-semibold text-foreground line-clamp-2">{video.title}</h4>
<button
onClick={() => handleDelete(video.id)}
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors shrink-0"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<p className="text-xs text-muted-foreground mb-3">
{video.course} · {video.module}
</p>
<div className="flex items-center justify-between gap-2 flex-wrap">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium ${status.color}`}>
<status.icon className="w-3 h-3" />
{status.label[lang]}
</span>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
<span className="flex items-center gap-1">
<Eye className="w-3 h-3" /> {video.views}
</span>
<span>{video.uploadDate}</span>
</div>
</div>
{(video.status === "draft" || video.status === "processing") && (
<button
type="button"
onClick={() => handlePublish(video.id)}
className="mt-3 w-full h-9 rounded-lg text-xs font-medium bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-500/25 transition-colors"
>
{lang === "zh" ? "发布到学员端" : "Publish to learners"}
</button>
)}
</div>
</motion.div>
);
})}
</div>
{!listLoading && filteredVideos.length === 0 && (
<div className="glass-card p-12 text-center">
<Video className="w-12 h-12 text-muted-foreground/30 mx-auto mb-4" />
<p className="text-muted-foreground">
{lang === "zh" ? "暂无视频" : "No videos found"}
</p>
</div>
)}
</div>
{/* Upload Modal */}
<AnimatePresence>
{showUpload && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4"
onClick={() => {
setShowUpload(false);
resetUploadForm();
}}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="w-full max-w-lg p-8 rounded-2xl border border-white/30 bg-white/80 dark:bg-white/15 backdrop-blur-2xl shadow-2xl shadow-primary/10"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-bold text-foreground mb-6 flex items-center gap-2">
<Upload className="w-5 h-5 text-primary" />
{lang === "zh" ? "上传新视频" : "Upload New Video"}
</h3>
<form onSubmit={handleUpload} className="space-y-4">
{/* Video Title */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{lang === "zh" ? "视频标题" : "Video Title"}
</label>
<input
type="text"
value={uploadTitle}
onChange={(e) => setUploadTitle(e.target.value)}
placeholder={lang === "zh" ? "输入视频标题" : "Enter video title"}
className="w-full h-11 rounded-xl border border-border bg-background px-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 shadow-sm transition-all"
required
/>
</div>
{/* Course Selection */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{lang === "zh" ? "所属课程" : "Course"}
</label>
<select
value={uploadCourse}
onChange={(e) => setUploadCourse(e.target.value)}
className="w-full h-11 rounded-xl border border-border bg-background px-4 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 shadow-sm transition-all"
>
{courseOptions.map(c => (
<option key={c.key} value={c.key}>{c.label[lang]}</option>
))}
</select>
</div>
{/* Module */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{lang === "zh" ? "所属模块" : "Module"}
</label>
<select
value={uploadModule}
onChange={(e) => setUploadModule(e.target.value)}
className="w-full h-11 rounded-xl border border-border bg-background px-4 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 shadow-sm transition-all"
>
{[1, 2, 3, 4, 5, 6].map(n => (
<option key={n} value={lang === "zh" ? `模块${n}` : `Module ${n}`}>
{lang === "zh" ? `模块${n}` : `Module ${n}`}
</option>
))}
</select>
</div>
{/* File Upload */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{lang === "zh" ? "视频文件" : "Video File"}
</label>
<div
onDragOver={(e) => { e.preventDefault(); setDragActive(true); }}
onDragLeave={() => setDragActive(false)}
onDrop={(e) => {
e.preventDefault();
setDragActive(false);
if (e.dataTransfer.files[0]) setUploadFile(e.dataTransfer.files[0]);
}}
className={`relative border-2 border-dashed rounded-xl p-6 text-center transition-all cursor-pointer ${
dragActive
? "border-primary bg-primary/10"
: "border-border hover:border-primary/40 bg-background shadow-sm"
}`}
>
<input
type="file"
accept="video/*"
onChange={(e) => e.target.files?.[0] && setUploadFile(e.target.files[0])}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
{uploadFile ? (
<div className="flex items-center justify-center gap-2">
<FileVideo className="w-5 h-5 text-primary" />
<span className="text-sm text-foreground font-medium">{uploadFile.name}</span>
</div>
) : (
<>
<Upload className="w-8 h-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{lang === "zh" ? "拖拽文件到这里或点击上传" : "Drag & drop or click to upload"}
</p>
<p className="text-xs text-muted-foreground/60 mt-1">MP4, MOV, AVI (max 2GB)</p>
</>
)}
</div>
</div>
{/* Draft Preview */}
{(uploadTitle || uploadFile) && (
<div className="rounded-xl border border-border bg-muted/30 p-4 space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{lang === "zh" ? "📋 上传预览" : "📋 Upload Preview"}
</p>
<div className="flex items-start gap-3">
<div className="w-20 h-14 rounded-lg bg-gradient-to-br from-muted/60 to-muted/30 flex items-center justify-center text-2xl shrink-0">
{courseOptions.find(c => c.key === uploadCourse)?.label.zh.charAt(0) === "商" ? "📊" : "📋"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">
{uploadTitle || (lang === "zh" ? "未命名视频" : "Untitled Video")}
</p>
<p className="text-xs text-muted-foreground">
{courseOptions.find(c => c.key === uploadCourse)?.label[lang]} · {uploadModule || (lang === "zh" ? "模块1" : "Module 1")}
</p>
{uploadFile && (
<p className="text-xs text-primary mt-1 flex items-center gap-1">
<FileVideo className="w-3 h-3" />
{uploadFile.name} ({(uploadFile.size / 1024 / 1024).toFixed(1)} MB)
</p>
)}
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
disabled={uploadBusy}
onClick={() => {
setShowUpload(false);
resetUploadForm();
}}
className="h-11 px-4 rounded-xl border border-border bg-background text-sm font-medium text-foreground hover:bg-muted/40 shadow-sm transition-colors disabled:opacity-50"
>
{lang === "zh" ? "取消" : "Cancel"}
</button>
<button
type="button"
disabled={uploadBusy}
onClick={handleSaveAsDraft}
className="flex-1 h-11 rounded-xl border border-amber-500/50 bg-amber-500/10 text-sm font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-500/20 shadow-sm transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
>
<AlertCircle className="w-4 h-4" />
{lang === "zh" ? "保存草稿" : "Save as Draft"}
</button>
<button
type="submit"
disabled={uploadBusy}
className="flex-1 h-11 rounded-xl bg-gradient-to-r from-primary to-accent text-primary-foreground text-sm font-medium shadow-lg shadow-primary/20 hover:shadow-xl transition-all flex items-center justify-center gap-2 disabled:opacity-50"
>
<Upload className="w-4 h-4" />
{uploadBusy ? (lang === "zh" ? "上传中…" : "Uploading…") : lang === "zh" ? "上传发布" : "Publish"}
</button>
</div>
</form>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

126
src/pages/AdminLogin.tsx Normal file
View File

@@ -0,0 +1,126 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useLang } from "@/contexts/LanguageContext";
import { motion } from "framer-motion";
import { Lock, Eye, EyeOff, ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import { verifyAdminPassword } from "@/lib/api";
import { ADMIN_SESSION_KEY } from "@/lib/adminSession";
import heroBg from "@/assets/hero-bg.jpg";
export default function AdminLogin() {
const { lang } = useLang();
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [submitting, setSubmitting] = useState(false);
return (
<div className="min-h-screen bg-background relative overflow-hidden">
<img src={heroBg} alt="" className="absolute inset-0 w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-b from-background/10 via-transparent to-background/30" />
<div className="absolute inset-0 bg-gradient-to-r from-background/20 via-transparent to-background/20" />
<div className="relative z-10 min-h-screen flex flex-col items-center justify-center px-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md"
>
<div className="glass-card rounded-2xl p-8 shadow-2xl shadow-black/10 backdrop-blur-2xl">
<div className="flex items-center gap-3 mb-6">
<div className="icon-circle !w-12 !h-12">
<Lock className="w-5 h-5" />
</div>
<div>
<h2 className="text-xl font-bold text-foreground">
{lang === "zh" ? "【有维商学】管理员登录" : "【YouWei】Admin Login"}
</h2>
<p className="text-sm text-muted-foreground">
{lang === "zh" ? "请输入管理员密码" : "Enter admin password"}
</p>
</div>
</div>
<form
onSubmit={async (e) => {
e.preventDefault();
if (!password.trim()) {
toast.error(lang === "zh" ? "请输入密码" : "Enter password");
return;
}
setSubmitting(true);
try {
const r = await verifyAdminPassword(password);
if (!r.ok) {
toast.error(
lang === "zh" ? r.error || "验证失败" : r.error || "Authentication failed",
);
return;
}
sessionStorage.setItem(ADMIN_SESSION_KEY, "1");
navigate("/admin/dashboard");
} catch {
toast.error(
lang === "zh"
? "无法连接 API。请运行 npm run dev同时启动网站与接口"
: "Cannot reach API. Run npm run dev (starts site + API).",
);
} finally {
setSubmitting(false);
}
}}
className="space-y-5"
>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{lang === "zh" ? "管理员密码" : "Admin Password"}
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={lang === "zh" ? "请输入密码" : "Enter password"}
className="w-full h-12 rounded-xl border border-border/50 bg-muted/20 px-4 pr-11 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
autoFocus
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<button
type="submit"
disabled={submitting}
className="w-full h-12 rounded-xl bg-gradient-to-r from-primary to-accent text-primary-foreground font-medium text-sm shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all duration-300 flex items-center justify-center gap-2 disabled:opacity-60 disabled:pointer-events-none"
>
<Lock className="w-4 h-4" />
{submitting
? lang === "zh"
? "验证中…"
: "Checking…"
: lang === "zh"
? "验证身份"
: "Authenticate"}
</button>
</form>
<button
onClick={() => navigate("/")}
className="mt-4 w-full text-center text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center justify-center gap-1"
>
<ArrowLeft className="w-3 h-3" />
{lang === "zh" ? "返回登录" : "Back to Login"}
</button>
</div>
</motion.div>
</div>
</div>
);
}

243
src/pages/Index.tsx Normal file
View File

@@ -0,0 +1,243 @@
import { useState, useRef } from "react";
import { useLang } from "@/contexts/LanguageContext";
import { Sparkles, ArrowRight, Zap, Brain, Users, GraduationCap } from "lucide-react";
import { toast } from "sonner";
import { motion, AnimatePresence } from "framer-motion";
import heroBg from "@/assets/hero-bg.jpg";
import Navbar from "@/components/Navbar";
import PlatformOverview from "@/components/sections/PlatformOverview";
import YouweiEducation from "@/components/sections/YouweiEducation";
import AITools from "@/components/sections/AITools";
import YouweiAlumni from "@/components/sections/YouweiAlumni";
import MembershipSystem from "@/components/sections/MembershipSystem";
import FloatingHelpdesk from "@/components/FloatingHelpdesk";
const tabs = [
{ key: "tab.overview", component: PlatformOverview, icon: Zap },
{ key: "tab.education", component: YouweiEducation, icon: GraduationCap },
{ key: "tab.aiTools", component: AITools, icon: Brain },
{ key: "tab.alumni", component: YouweiAlumni, icon: Users },
{ key: "tab.membership", component: MembershipSystem, icon: Sparkles },
];
const floatingOrbs = [
{ size: 120, x: "10%", y: "20%", delay: 0, color: "bg-primary/10" },
{ size: 80, x: "80%", y: "30%", delay: 1, color: "bg-accent/10" },
{ size: 60, x: "60%", y: "70%", delay: 2, color: "bg-primary/5" },
{ size: 100, x: "25%", y: "75%", delay: 0.5, color: "bg-accent/8" },
];
export default function Index() {
const { t } = useLang();
const [activeTab, setActiveTab] = useState("tab.overview");
const [initialCourse, setInitialCourse] = useState<string | null>(null);
const tabBarRef = useRef<HTMLDivElement>(null);
const ActiveComponent = tabs.find((tab) => tab.key === activeTab)?.component ?? PlatformOverview;
const handleNavClick = (tabKey: string | null) => {
const newTab = tabKey ?? "tab.overview";
setActiveTab(newTab);
setInitialCourse(null);
tabBarRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};
return (
<div className="min-h-screen bg-background overflow-hidden">
{/* Hero */}
<div className="relative min-h-[70vh] sm:min-h-[85vh] flex flex-col">
<img
src={heroBg}
alt=""
className="absolute inset-0 w-full h-full object-cover"
width={1920}
height={1080}
/>
<div className="absolute inset-0 bg-gradient-to-b from-background/10 via-transparent to-background" />
<div className="absolute inset-0 bg-gradient-to-r from-background/30 via-transparent to-background/30" />
{/* Floating orbs */}
{floatingOrbs.map((orb, i) => (
<motion.div
key={i}
className={`absolute rounded-full ${orb.color} blur-2xl pointer-events-none`}
style={{ width: orb.size, height: orb.size, left: orb.x, top: orb.y }}
animate={{ y: [0, -20, 0, 15, 0], x: [0, 10, -5, 8, 0], scale: [1, 1.1, 0.95, 1.05, 1] }}
transition={{ duration: 8, repeat: Infinity, delay: orb.delay, ease: "easeInOut" }}
/>
))}
<div className="relative z-10 flex-1 flex flex-col">
<Navbar onNavClick={handleNavClick} activeTab={activeTab} />
<div className="flex-1 flex items-center justify-center px-4">
<div className="max-w-3xl mx-auto text-center">
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.6 }}
className="section-tag mx-auto mb-8 w-fit backdrop-blur-xl"
>
<Sparkles className="w-3 h-3" />
{t("welcome.tagline")}
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.15 }}
className="text-3xl sm:text-5xl md:text-7xl lg:text-8xl font-bold tracking-tight leading-[1]"
>
<span className="text-foreground">{t("welcome.title")}</span>
<motion.span
className="inline-block ml-2 sm:ml-3 text-4xl sm:text-6xl md:text-7xl"
animate={{ rotate: [0, 14, -8, 14, -4, 10, 0] }}
transition={{ duration: 2.5, delay: 1, repeat: Infinity, repeatDelay: 4 }}
>
👋
</motion.span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="text-muted-foreground mt-4 sm:mt-6 text-base sm:text-lg md:text-xl max-w-lg mx-auto leading-relaxed px-2"
>
{t("welcome.subtitle")}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.45 }}
className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 mt-8 sm:mt-10 px-4"
>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.97 }}
onClick={() => {
setActiveTab("tab.overview");
tabBarRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}}
className="btn-primary group flex items-center gap-2 !px-6 sm:!px-8 !py-3 sm:!py-3.5 text-sm sm:text-base w-full sm:w-auto justify-center"
>
{t("welcome.explore")}
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.97 }}
onClick={() => {
setActiveTab("tab.membership");
tabBarRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}}
className="btn-glass !px-6 sm:!px-8 !py-3 sm:!py-3.5 text-sm sm:text-base w-full sm:w-auto justify-center"
>
{t("welcome.plans")}
</motion.button>
</motion.div>
{/* Floating stat pills */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.6 }}
className="flex items-center justify-center gap-2 sm:gap-3 mt-8 sm:mt-12 mb-6 sm:mb-0 flex-wrap px-4"
>
{["2000+ OPC", "50+ AI Agents", "100+ Courses"].map((stat, i) => (
<motion.div
key={stat}
className="glass-pill px-4 py-2 text-xs font-medium text-muted-foreground"
whileHover={{ scale: 1.08, y: -3 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
{stat}
</motion.div>
))}
</motion.div>
</div>
</div>
</div>
</div>
{/* Main content */}
<main className="max-w-6xl mx-auto px-3 sm:px-4 -mt-8 sm:-mt-12 relative z-10 pb-16 sm:pb-20">
{/* Tab bar */}
<div className="relative mb-8 sm:mb-12">
<motion.div
ref={tabBarRef}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.7 }}
className="glass-tab-bar rounded-xl sm:rounded-2xl p-1 sm:p-1.5 flex items-center gap-0.5 sm:gap-1 overflow-x-auto scrollbar-hide"
>
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`relative flex items-center gap-1.5 sm:gap-2 px-3 sm:px-5 py-2.5 sm:py-3 rounded-lg sm:rounded-xl text-xs sm:text-sm font-medium transition-all duration-300 whitespace-nowrap sm:flex-1 justify-center min-w-fit ${
activeTab === tab.key
? "text-background"
: "text-muted-foreground hover:text-foreground hover:bg-white/40"
}`}
>
{activeTab === tab.key && (
<motion.div
layoutId="activeTab"
className="absolute inset-0 bg-foreground rounded-xl shadow-lg shadow-foreground/10"
transition={{ type: "spring", stiffness: 380, damping: 30 }}
/>
)}
<span className="relative z-10 flex items-center gap-1 sm:gap-2">
<Icon className="w-3.5 h-3.5 sm:w-4 sm:h-4 shrink-0" />
<span>{t(tab.key)}</span>
</span>
</button>
);
})}
</motion.div>
{/* Scroll fade hint on mobile */}
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background/80 to-transparent pointer-events-none rounded-r-xl sm:hidden" />
</div>
{/* Content with animated transitions */}
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 20, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.98 }}
transition={{ duration: 0.35, ease: "easeOut" }}
>
{activeTab === "tab.overview" ? (
<PlatformOverview
onNavigateToCourse={(courseKey) => {
setInitialCourse(courseKey);
setActiveTab("tab.education");
tabBarRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}}
onNavigateToTab={(tabKey) => {
const tabMap: Record<string, string> = { ai: "tab.aiTools", alumni: "tab.alumni" };
setActiveTab(tabMap[tabKey] || "tab.overview");
tabBarRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}}
/>
) : activeTab === "tab.education" ? (
<YouweiEducation initialCourse={initialCourse} key={initialCourse ?? "edu"} />
) : (
<ActiveComponent />
)}
</motion.div>
</AnimatePresence>
</main>
{/* Footer gradient */}
<div className="h-32 bg-gradient-to-t from-background to-transparent" />
<FloatingHelpdesk />
</div>
);
}

275
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,275 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Sparkles, ArrowRight, Eye, EyeOff, BookOpen, Bot, Users2 } from "lucide-react";
import { useLang } from "@/contexts/LanguageContext";
import { useUser } from "@/contexts/UserContext";
import { motion, AnimatePresence } from "framer-motion";
import { toast } from "sonner";
import { loginWithDatabase } from "@/lib/api";
import heroBg from "@/assets/hero-bg.jpg";
import logo from "@/assets/logo.png";
const features = [
{
icon: BookOpen,
title: { zh: "有维教育", en: "Youwei Education" },
desc: { zh: "系统化商业课程,知行合一", en: "Systematic business courses" },
},
{
icon: Bot,
title: { zh: "AI工具", en: "AI Tools" },
desc: { zh: "智能体赋能,数字员工", en: "AI-powered digital workforce" },
},
{
icon: Users2,
title: { zh: "有维校友", en: "Youwei Alumni" },
desc: { zh: "创业者社群,互助共赢", en: "Entrepreneur community" },
},
];
export default function Login() {
const { lang } = useLang();
const { setUsername } = useUser();
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
const [phone, setPhone] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [submitting, setSubmitting] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const id = phone.trim();
if (!id || !password) {
toast.error(lang === "zh" ? "请输入手机号/邮箱和密码" : "Enter phone or email and password");
return;
}
setSubmitting(true);
try {
const result = await loginWithDatabase(id, password);
if (!result.ok) {
toast.error(
lang === "zh" ? result.error || "登录失败" : result.error || "Sign-in failed",
);
return;
}
setUsername(result.user.displayName || result.user.login);
if (rememberMe) {
try {
localStorage.setItem("youwei_login", result.user.login);
} catch {
/* ignore quota */
}
}
navigate("/home");
} catch {
toast.error(
lang === "zh"
? "无法连接 API端口 3001。请在本项目目录运行 npm run dev会同时启动网站与接口"
: "Cannot reach API (port 3001). Run npm run dev from the project root (starts site + API).",
);
} finally {
setSubmitting(false);
}
};
return (
<div className="min-h-screen bg-background relative overflow-hidden">
{/* Background */}
<img
src={heroBg}
alt=""
className="absolute inset-0 w-full h-full object-cover"
width={1920}
height={1080}
/>
<div className="absolute inset-0 bg-gradient-to-b from-background/10 via-transparent to-background/30" />
<div className="absolute inset-0 bg-gradient-to-r from-background/20 via-transparent to-background/20" />
{/* Content */}
<div className="relative z-10 min-h-screen flex flex-col">
{/* Top Nav */}
<div className="px-3 sm:px-4 pt-3 sm:pt-4">
<nav className="glass-nav rounded-2xl max-w-6xl mx-auto px-4 sm:px-5 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<img src={logo} alt="有维商学" className="w-7 h-7 sm:w-8 sm:h-8 rounded-xl object-contain" />
<span className="font-semibold text-foreground tracking-tight text-xs sm:text-sm">
{lang === "zh" ? "有维商学" : "Youwei Business School"}
</span>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-muted-foreground hidden sm:inline">
{lang === "zh" ? "还没有账户?" : "Don't have an account?"}
</span>
<button className="glass-pill px-3 sm:px-4 py-1.5 text-[10px] sm:text-xs font-medium text-foreground hover:scale-105 transition-transform duration-300">
{lang === "zh" ? "立即注册" : "Sign Up"}
</button>
</div>
</nav>
</div>
{/* Main Content */}
<div className="flex-1 flex items-center justify-center px-3 sm:px-4 py-6 sm:py-8">
<div className="max-w-6xl w-full mx-auto grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-center">
{/* Left Side - Hero Content */}
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.7 }}
className="hidden lg:block"
>
<div className="section-tag w-fit backdrop-blur-xl mb-6">
<Sparkles className="w-3 h-3" />
{lang === "zh" ? "OPC创业者赋能平台" : "OPC Entrepreneur Platform"}
</div>
<h1 className="text-4xl xl:text-5xl font-bold tracking-tight leading-tight mb-4">
<span className="text-foreground">
{lang === "zh" ? "有维商学" : "Youwei"}
</span>
<span className="gradient-text ml-1">
{lang === "zh" ? "三位一体" : "Trinity"}
</span>
</h1>
<p className="text-muted-foreground text-base leading-relaxed mb-10 max-w-md">
{lang === "zh"
? '基于"有维教育+AI工具+有维校友"三位一体的商业模式为OPC创业者提供全方位的成长支持'
: "An integrated platform combining education, AI tools, and alumni network to support OPC entrepreneurs"}
</p>
{/* Feature Cards */}
<div className="space-y-3">
{features.map((feat, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 + i * 0.15 }}
className="glass-card rounded-xl px-5 py-4 flex items-center gap-4 max-w-md"
>
<div className="icon-circle">
<feat.icon className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">{feat.title[lang]}</p>
<p className="text-xs text-muted-foreground">{feat.desc[lang]}</p>
</div>
</motion.div>
))}
</div>
</motion.div>
{/* Right Side - Login Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -6, scale: 1.01 }}
transition={{ duration: 0.6, delay: 0.2, type: "spring", stiffness: 300, damping: 20 }}
className="w-full max-w-md mx-auto lg:mx-0 lg:ml-auto"
>
<div className="glass-card rounded-2xl p-6 sm:p-8 shadow-2xl shadow-black/10 backdrop-blur-2xl">
<h2 className="text-2xl font-bold text-foreground mb-1">
{lang === "zh" ? "欢迎回来" : "Welcome Back"}
</h2>
<p className="text-sm text-muted-foreground mb-8">
{lang === "zh" ? "登录您的有维商学账户" : "Sign in to your Youwei account"}
</p>
<form onSubmit={handleLogin} className="space-y-5">
{/* Phone/Email */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{lang === "zh" ? "手机号/邮箱" : "Phone / Email"}
</label>
<input
type="text"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder={lang === "zh" ? "请输入手机号或邮箱" : "Enter phone or email"}
className="w-full h-12 rounded-xl border border-border/50 bg-muted/20 px-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
/>
</div>
{/* Password */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{lang === "zh" ? "密码" : "Password"}
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={lang === "zh" ? "请输入密码" : "Enter password"}
className="w-full h-12 rounded-xl border border-border/50 bg-muted/20 px-4 pr-11 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* Remember + Forgot */}
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 rounded border-border/50 text-primary focus:ring-primary/30 bg-muted/30"
/>
<span className="text-sm text-muted-foreground">
{lang === "zh" ? "记住我" : "Remember me"}
</span>
</label>
<button type="button" className="text-sm text-primary hover:text-primary/80 transition-colors">
{lang === "zh" ? "忘记密码?" : "Forgot password?"}
</button>
</div>
{/* Login Button */}
<button
type="submit"
disabled={submitting}
className="w-full h-12 rounded-xl bg-gradient-to-r from-primary to-accent text-white font-medium text-sm shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all duration-300 flex items-center justify-center gap-2 group disabled:opacity-60 disabled:pointer-events-none"
>
{submitting ? (lang === "zh" ? "登录中…" : "Signing in…") : lang === "zh" ? "登 录" : "Sign In"}
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</button>
</form>
{/* Divider */}
<div className="flex items-center gap-3 my-6">
<div className="flex-1 h-px bg-border/50" />
<span className="text-xs text-muted-foreground">
{lang === "zh" ? "或使用以下方式登录" : "Or continue with"}
</span>
<div className="flex-1 h-px bg-border/50" />
</div>
{/* Social Login */}
<div>
<button
type="button"
className="w-full h-11 rounded-xl border border-border/50 bg-muted/20 flex items-center justify-center gap-2 text-sm text-foreground hover:bg-muted/40 transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/>
<line x1="12" y1="18" x2="12" y2="18"/>
</svg>
{lang === "zh" ? "手机验证码" : "SMS Code"}
</button>
</div>
</div>
</motion.div>
</div>
</div>
</div>
</div>
);
}

24
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
const NotFound = () => {
const location = useLocation();
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
}, [location.pathname]);
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">404</h1>
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
<a href="/" className="text-primary underline hover:text-primary/90">
Return to Home
</a>
</div>
</div>
);
};
export default NotFound;

7
src/test/example.test.ts Normal file
View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from "vitest";
describe("example", () => {
it("should pass", () => {
expect(true).toBe(true);
});
});

Some files were not shown because too many files have changed in this diff Show More