feat: new home web

This commit is contained in:
2026-04-03 00:02:09 +08:00
parent 35511eb877
commit 7e691a8100
27 changed files with 1691 additions and 54 deletions

View File

@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<title>Agent Fox</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<title>AgentFox</title>
</head>
<body>
<div id="root"></div>

View File

@@ -2,11 +2,13 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './lib/auth';
import { ThemeProvider } from './lib/theme';
import { I18nProvider } from './lib/i18n';
import Login from './pages/Login';
import Register from './pages/Register';
import Layout from './pages/Layout';
import Projects from './pages/Projects';
import ProjectDetail from './pages/ProjectDetail';
import LandingPage from './pages/landing/LandingPage';
const queryClient = new QueryClient();
export default function App() {
@@ -14,17 +16,20 @@ export default function App() {
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route element={<Layout />}>
<Route path="/" element={<Projects />} />
<Route path="/projects/:id" element={<ProjectDetail />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
<I18nProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/dashboard" element={<Layout />}>
<Route index element={<Projects />} />
<Route path="projects/:id" element={<ProjectDetail />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</I18nProvider>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Antigravity</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 424 B

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-fill)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-fill" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg fill="#00D4AA" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>

After

Width:  |  Height:  |  Size: 542 B

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1 @@
<svg fill="#6E40C9" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenClaw</title><path d="M12 2.568c-6.33 0-9.495 5.275-9.495 9.495 0 4.22 3.165 8.44 6.33 9.494v2.11h2.11v-2.11s1.055.422 2.11 0v2.11h2.11v-2.11c3.165-1.055 6.33-5.274 6.33-9.494S18.33 2.568 12 2.568z" fill="url(#lobe-icons-open-claw-fill-0)"></path><path d="M3.56 9.953C.396 8.898-.66 11.008.396 13.118c1.055 2.11 3.164 1.055 4.22-1.055.632-1.477 0-2.11-1.056-2.11z" fill="url(#lobe-icons-open-claw-fill-1)"></path><path d="M20.44 9.953c3.164-1.055 4.22 1.055 3.164 3.165-1.055 2.11-3.164 1.055-4.22-1.055-.632-1.477 0-2.11 1.056-2.11z" fill="url(#lobe-icons-open-claw-fill-2)"></path><path d="M5.507 1.875c.476-.285 1.036-.233 1.615.037.577.27 1.223.774 1.937 1.488a.316.316 0 01-.447.447c-.693-.693-1.279-1.138-1.757-1.361-.475-.222-.795-.205-1.022-.069a.317.317 0 01-.326-.542zM16.877 1.913c.58-.27 1.14-.323 1.616-.038a.317.317 0 01-.326.542c-.227-.136-.547-.153-1.022.069-.478.223-1.064.668-1.756 1.361a.316.316 0 11-.448-.447c.714-.714 1.36-1.218 1.936-1.487z" fill="#FF4D4D"></path><path d="M8.835 9.109a1.266 1.266 0 100-2.532 1.266 1.266 0 000 2.532zM15.165 9.109a1.266 1.266 0 100-2.532 1.266 1.266 0 000 2.532z" fill="#050810"></path><path d="M9.046 8.16a.527.527 0 100-1.056.527.527 0 000 1.055zM15.376 8.16a.527.527 0 100-1.055.527.527 0 000 1.054z" fill="#00E5CC"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-open-claw-fill-0" x1="-.659" x2="27.023" y1=".458" y2="22.855"><stop stop-color="#FF4D4D"></stop><stop offset="1" stop-color="#991B1B"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-open-claw-fill-1" x1="0" x2="4.311" y1="9.672" y2="14.949"><stop stop-color="#FF4D4D"></stop><stop offset="1" stop-color="#991B1B"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-open-claw-fill-2" x1="19.385" x2="24.399" y1="9.953" y2="14.462"><stop stop-color="#FF4D4D"></stop><stop offset="1" stop-color="#991B1B"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,68 @@
import { useState, useRef, useEffect } from 'react';
import { useI18n, type Locale } from '../lib/i18n';
const languages: { locale: Locale; flag: string; label: string }[] = [
{ locale: 'en', flag: '🇺🇸', label: 'English' },
{ locale: 'zh', flag: '🇨🇳', label: '简体中文' },
];
export default function LanguageToggle() {
const { locale, setLocale } = useI18n();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium transition-all duration-150 cursor-pointer hover:bg-bg-tertiary"
style={{ color: 'var(--text-secondary)' }}
aria-label="Switch language"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M12 21a9 9 0 100-18 9 9 0 000 18z" />
<path d="M3.6 9h16.8M3.6 15h16.8" />
<path d="M12 3a15.3 15.3 0 014 9 15.3 15.3 0 01-4 9 15.3 15.3 0 01-4-9 15.3 15.3 0 014-9z" />
</svg>
<span className="tracking-wide">{locale === 'en' ? 'EN' : '中'}</span>
</button>
{open && (
<div
className="absolute top-full right-0 mt-1.5 min-w-[140px] rounded-xl py-1 z-50 animate-slide-down"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border-default)',
boxShadow: 'var(--shadow-lg)',
}}
>
{languages.map(lang => (
<button
key={lang.locale}
onClick={() => { setLocale(lang.locale); setOpen(false); }}
className="flex items-center gap-2.5 w-full px-3 py-2 text-[13px] transition-colors cursor-pointer rounded-lg mx-0.5"
style={{
width: 'calc(100% - 4px)',
color: locale === lang.locale ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: locale === lang.locale ? 500 : 400,
background: locale === lang.locale ? 'var(--bg-tertiary)' : 'transparent',
}}
>
<span className="text-base leading-none">{lang.flag}</span>
{lang.label}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { useRef, useState, useEffect } from 'react';
export function useScrollReveal(options?: IntersectionObserverInit) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold: 0.15, ...options }
);
observer.observe(el);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps -- options is expected to be static per call site
}, []);
return { ref, isVisible };
}

View File

@@ -16,10 +16,10 @@
--text-secondary: #4a4f5a;
--text-muted: #868c98;
--text-inverted: #ffffff;
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-subtle: #eef2ff;
--accent-muted: rgba(99, 102, 241, 0.1);
--accent: #d97706;
--accent-hover: #b45309;
--accent-subtle: #fef3c7;
--accent-muted: rgba(217, 119, 6, 0.1);
--danger: #e5484d;
--danger-muted: rgba(229, 72, 77, 0.08);
--success: #30a46c;
@@ -34,6 +34,9 @@
--code-comment: #565f89;
--code-keyword: #bb9af7;
--overlay: rgba(0, 0, 0, 0.4);
--fox-amber: #f59e0b;
--fox-orange: #ea580c;
--fox-glow: rgba(245, 158, 11, 0.15);
--method-get: #30a46c;
--method-get-bg: rgba(48, 164, 108, 0.1);
--method-post: #3b82f6;
@@ -61,10 +64,10 @@
--text-secondary: #a0a0ab;
--text-muted: #63636e;
--text-inverted: #0a0a0c;
--accent: #818cf8;
--accent-hover: #6366f1;
--accent-subtle: rgba(129, 140, 248, 0.08);
--accent-muted: rgba(129, 140, 248, 0.12);
--accent: #fbbf24;
--accent-hover: #f59e0b;
--accent-subtle: rgba(251, 191, 36, 0.08);
--accent-muted: rgba(251, 191, 36, 0.12);
--danger: #f87171;
--danger-muted: rgba(248, 113, 113, 0.1);
--success: #4ade80;
@@ -79,6 +82,9 @@
--code-comment: #565f89;
--code-keyword: #bb9af7;
--overlay: rgba(0, 0, 0, 0.65);
--fox-amber: #fbbf24;
--fox-orange: #f97316;
--fox-glow: rgba(251, 191, 36, 0.2);
--method-get: #4ade80;
--method-get-bg: rgba(74, 222, 128, 0.12);
--method-post: #60a5fa;
@@ -106,10 +112,10 @@
--text-secondary: #a0a0ab;
--text-muted: #63636e;
--text-inverted: #0a0a0c;
--accent: #818cf8;
--accent-hover: #6366f1;
--accent-subtle: rgba(129, 140, 248, 0.08);
--accent-muted: rgba(129, 140, 248, 0.12);
--accent: #fbbf24;
--accent-hover: #f59e0b;
--accent-subtle: rgba(251, 191, 36, 0.08);
--accent-muted: rgba(251, 191, 36, 0.12);
--danger: #f87171;
--danger-muted: rgba(248, 113, 113, 0.1);
--success: #4ade80;
@@ -124,6 +130,9 @@
--code-comment: #565f89;
--code-keyword: #bb9af7;
--overlay: rgba(0, 0, 0, 0.65);
--fox-amber: #fbbf24;
--fox-orange: #f97316;
--fox-glow: rgba(251, 191, 36, 0.2);
--method-get: #4ade80;
--method-get-bg: rgba(74, 222, 128, 0.12);
--method-post: #60a5fa;
@@ -141,6 +150,7 @@
--font-sans: 'DM Sans', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
--font-display: 'DM Sans', system-ui, sans-serif;
--font-heading: 'Outfit', 'DM Sans', system-ui, sans-serif;
--color-bg-primary: var(--bg-primary);
--color-bg-secondary: var(--bg-secondary);
@@ -168,6 +178,9 @@
--color-code-bg: var(--code-bg);
--color-code-text: var(--code-text);
--color-overlay: var(--overlay);
--color-fox-amber: var(--fox-amber);
--color-fox-orange: var(--fox-orange);
--color-fox-glow: var(--fox-glow);
--shadow-sm: var(--shadow-sm);
--shadow-md: var(--shadow-md);
@@ -178,6 +191,10 @@
--animate-slide-down: slide-down 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
--animate-shimmer: shimmer 1.8s ease-in-out infinite;
--animate-pulse-soft: pulse-soft 2s ease-in-out infinite;
--animate-float: float 6s ease-in-out infinite;
--animate-gradient-shift: gradient-shift 8s ease-in-out infinite;
--animate-reveal-up: reveal-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
--animate-marquee: marquee 30s linear infinite;
@keyframes fade-in {
from { opacity: 0; }
@@ -199,6 +216,31 @@
from { opacity: 0; transform: translateY(-4px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-20px); }
}
@keyframes gradient-shift {
0%, 100% { opacity: 0.6; transform: scale(1) translate(0, 0); }
33% { opacity: 0.8; transform: scale(1.1) translate(10px, -10px); }
66% { opacity: 0.5; transform: scale(0.95) translate(-10px, 10px); }
}
@keyframes reveal-up {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
@keyframes typing {
from { width: 0; }
to { width: 100%; }
}
@keyframes blink-caret {
0%, 100% { border-color: transparent; }
50% { border-color: var(--fox-amber); }
}
}
/* ===== Base ===== */
@@ -355,3 +397,13 @@ dialog::backdrop {
.stagger-children > *:nth-child(7) { animation-delay: 240ms; }
.stagger-children > *:nth-child(8) { animation-delay: 280ms; }
.stagger-children > *:nth-child(n+9) { animation-delay: 320ms; }
/* ===== Reduced Motion ===== */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -0,0 +1,332 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
export type Locale = 'en' | 'zh';
type Translations = Record<string, string>;
type AllTranslations = Record<Locale, Translations>;
const translations: AllTranslations = {
en: {
// Nav
'nav.features': 'Features',
'nav.tools': 'Tools',
'nav.testimonials': 'Testimonials',
'nav.pricing': 'Pricing',
'nav.faq': 'FAQ',
'nav.signIn': 'Sign In',
'nav.getStarted': 'Get Started',
'nav.dashboard': 'Dashboard',
// Hero
'hero.badge': 'MCP-Powered API Intelligence',
'hero.title': 'API Docs for LLMs,',
'hero.titleHighlight': 'Done Right',
'hero.subtitle': 'Let AI agents query your OpenAPI documentation with surgical precision. Multi-level retrieval serves exactly the tokens needed — not the entire spec.',
'hero.cta': 'Start Free',
'hero.ctaSecondary': 'View Documentation',
'hero.terminal.comment': '# Connect your AI tool to any API documentation',
'hero.terminal.cmd1': 'get_project_overview',
'hero.terminal.res1': '{ "name": "Stripe API", "modules": 12, "endpoints": 247 }',
'hero.terminal.cmd2': 'list_endpoints moduleId="payments"',
'hero.terminal.res2': '[ "POST /charges", "GET /charges/:id", "POST /refunds" ... ]',
'hero.terminal.cmd3': 'get_endpoint_detail endpointId="create-charge"',
'hero.terminal.res3': '{ params: [...], requestBody: {...}, responses: {...} } // ~800 tokens',
// Features
'features.label': 'Features',
'features.title': 'Intelligent API Retrieval',
'features.subtitle': 'Five specialized MCP tools designed for minimal token usage per call',
'features.progressive.title': 'Progressive Drill-Down',
'features.progressive.desc': 'Navigate from project overview to module list to endpoint detail — retrieve only what you need.',
'features.token.title': 'Token Efficient',
'features.token.desc': '~200-2,000 tokens per call vs 10,000+ for dumping the full OpenAPI spec into context.',
'features.spec.title': 'Full Spec Support',
'features.spec.desc': 'Import OpenAPI 3.x and Swagger 2.0 docs. All $refs are dereferenced automatically.',
'features.import.title': 'One-Click Import',
'features.import.desc': 'Paste a URL or upload JSON/YAML — your API docs are parsed and indexed instantly.',
'features.projects.title': 'Multi-Project',
'features.projects.desc': 'Organize APIs into isolated projects, each with its own MCP endpoint and API key.',
'features.security.title': 'Secure by Default',
'features.security.desc': 'Project-level API keys with bcrypt hashing. JWT auth for the dashboard. Zero shared secrets.',
// Tools
'tools.label': 'Compatibility',
'tools.title': 'Works with Your Favorite AI Tools',
'tools.subtitle': 'AgentFox speaks MCP — the universal protocol supported by leading AI coding assistants',
'tools.claude.name': 'Claude Code',
'tools.claude.desc': 'Anthropic CLI',
'tools.codex.name': 'Codex',
'tools.codex.desc': 'OpenAI CLI',
'tools.cursor.name': 'Cursor',
'tools.cursor.desc': 'AI Code Editor',
'tools.copilot.name': 'GitHub Copilot',
'tools.copilot.desc': 'GitHub AI Pair',
'tools.gemini.name': 'Gemini CLI',
'tools.gemini.desc': 'Google AI CLI',
'tools.antigravity.name': 'Antigravity',
'tools.antigravity.desc': 'AI Dev Platform',
'tools.openclaw.name': 'OpenClaw',
'tools.openclaw.desc': 'AI Dev Platform',
// Testimonials
'testimonials.label': 'Testimonials',
'testimonials.title': 'Loved by Developers',
'testimonials.1.quote': 'AgentFox cut our API integration time in half. Instead of copy-pasting docs, Claude just queries what it needs through MCP.',
'testimonials.1.name': 'Sarah Chen',
'testimonials.1.role': 'Staff Engineer at Vercel',
'testimonials.2.quote': 'The token savings are real — our Cursor workflow went from burning 15K tokens per API call to under 1K. Game changer for complex integrations.',
'testimonials.2.name': 'Marcus Rivera',
'testimonials.2.role': 'CTO at Stackblitz',
'testimonials.3.quote': 'We onboarded 50+ internal APIs to AgentFox in a week. Now every team\'s AI assistant can discover and use any service endpoint.',
'testimonials.3.name': 'Yuki Tanaka',
'testimonials.3.role': 'Platform Lead at Shopify',
// Pricing
'pricing.label': 'Pricing',
'pricing.title': 'Simple, Transparent Pricing',
'pricing.subtitle': 'Start free, scale as you grow',
'pricing.free.name': 'Free',
'pricing.free.price': '$0',
'pricing.free.period': '/month',
'pricing.free.desc': 'Perfect for trying out MCP-powered API docs',
'pricing.free.f1': '1 project',
'pricing.free.f2': '100 MCP queries/day',
'pricing.free.f3': 'OpenAPI 3.x & Swagger 2.0',
'pricing.free.f4': 'Community support',
'pricing.free.cta': 'Get Started',
'pricing.pro.name': 'Pro',
'pricing.pro.price': '$29',
'pricing.pro.period': '/month',
'pricing.pro.badge': 'Most Popular',
'pricing.pro.desc': 'For teams shipping with AI-assisted development',
'pricing.pro.f1': 'Unlimited projects',
'pricing.pro.f2': 'Unlimited MCP queries',
'pricing.pro.f3': 'Priority import queue',
'pricing.pro.f4': 'Team collaboration',
'pricing.pro.f5': 'Priority support',
'pricing.pro.cta': 'Start Free Trial',
'pricing.enterprise.name': 'Enterprise',
'pricing.enterprise.price': 'Custom',
'pricing.enterprise.period': '',
'pricing.enterprise.desc': 'For organizations with advanced requirements',
'pricing.enterprise.f1': 'Self-hosted deployment',
'pricing.enterprise.f2': 'SSO / SAML',
'pricing.enterprise.f3': 'SLA guarantee',
'pricing.enterprise.f4': 'Dedicated support',
'pricing.enterprise.f5': 'Custom integrations',
'pricing.enterprise.cta': 'Contact Sales',
// FAQ
'faq.label': 'FAQ',
'faq.title': 'Frequently Asked Questions',
'faq.1.q': 'What is MCP and how does AgentFox use it?',
'faq.1.a': 'MCP (Model Context Protocol) is an open standard that lets AI assistants connect to external tools and data sources. AgentFox exposes your API documentation through MCP tools, so AI coding assistants like Claude Code, Cursor, and Copilot can query endpoint details on demand — without dumping the entire spec into their context window.',
'faq.2.q': 'Which OpenAPI formats are supported?',
'faq.2.a': 'AgentFox supports both OpenAPI 3.x and Swagger 2.0 specifications. You can import documents in JSON or YAML format, or provide a URL to fetch them directly. All $ref references are automatically dereferenced during import.',
'faq.3.q': 'How much does it reduce token usage?',
'faq.3.a': 'Each MCP tool call returns ~200-2,000 tokens of focused information, compared to 10,000+ tokens for dumping a full API spec. For a typical integration task, this means 80-95% reduction in token consumption.',
'faq.4.q': 'Is my API documentation secure?',
'faq.4.a': 'Yes. Each project has its own API key (bcrypt hashed, never stored in plain text). The MCP endpoint requires authentication for every request. User dashboard access uses JWT with automatic token rotation.',
'faq.5.q': 'Which AI tools are compatible?',
'faq.5.a': 'Any tool that supports the MCP protocol can connect to AgentFox. This includes Claude Code, OpenAI Codex CLI, OpenClaw, Gemini CLI, Cursor, GitHub Copilot (via MCP plugins), Antigravity, and more. If your tool supports MCP, it works with AgentFox.',
'faq.6.q': 'Can I self-host AgentFox?',
'faq.6.a': 'Yes! AgentFox is designed for both cloud and self-hosted deployment. The Enterprise plan includes full self-hosted support with Docker Compose, along with SSO integration and dedicated support.',
// Footer
'footer.product': 'Product',
'footer.features': 'Features',
'footer.pricing': 'Pricing',
'footer.docs': 'Documentation',
'footer.changelog': 'Changelog',
'footer.resources': 'Resources',
'footer.github': 'GitHub',
'footer.community': 'Community',
'footer.blog': 'Blog',
'footer.legal': 'Legal',
'footer.privacy': 'Privacy',
'footer.terms': 'Terms',
'footer.copyright': '© 2026 AgentFox. All rights reserved.',
'footer.tagline': 'MCP-powered API documentation for AI agents.',
},
zh: {
// Nav
'nav.features': '特性',
'nav.tools': '工具',
'nav.testimonials': '用户评价',
'nav.pricing': '定价',
'nav.faq': '常见问题',
'nav.signIn': '登录',
'nav.getStarted': '免费开始',
'nav.dashboard': '控制台',
// Hero
'hero.badge': 'MCP 驱动的 API 智能服务',
'hero.title': '为 LLM 而生的',
'hero.titleHighlight': 'API 文档',
'hero.subtitle': '让 AI 代理以精准的方式查询你的 OpenAPI 文档。多级检索只提供所需的 token而非整个规范。',
'hero.cta': '免费开始',
'hero.ctaSecondary': '查看文档',
'hero.terminal.comment': '# 将你的 AI 工具连接到任何 API 文档',
'hero.terminal.cmd1': 'get_project_overview',
'hero.terminal.res1': '{ "name": "Stripe API", "modules": 12, "endpoints": 247 }',
'hero.terminal.cmd2': 'list_endpoints moduleId="payments"',
'hero.terminal.res2': '[ "POST /charges", "GET /charges/:id", "POST /refunds" ... ]',
'hero.terminal.cmd3': 'get_endpoint_detail endpointId="create-charge"',
'hero.terminal.res3': '{ params: [...], requestBody: {...}, responses: {...} } // ~800 tokens',
// Features
'features.label': '核心特性',
'features.title': '智能 API 检索',
'features.subtitle': '五个专用 MCP 工具,每次调用最小化 token 消耗',
'features.progressive.title': '渐进式下钻',
'features.progressive.desc': '从项目概览到模块列表再到端点详情,按需精确检索。',
'features.token.title': 'Token 高效',
'features.token.desc': '每次调用 ~200-2,000 tokens对比全量 OpenAPI 规范的 10,000+ tokens。',
'features.spec.title': '全规范支持',
'features.spec.desc': '导入 OpenAPI 3.x 和 Swagger 2.0 文档,所有 $ref 自动解引用。',
'features.import.title': '一键导入',
'features.import.desc': '粘贴 URL 或上传 JSON/YAML 文件API 文档即时解析并索引。',
'features.projects.title': '多项目管理',
'features.projects.desc': '将 API 组织到独立项目中,每个项目拥有专属 MCP 端点和 API Key。',
'features.security.title': '安全可靠',
'features.security.desc': '项目级 API Keybcrypt 哈希加密JWT 双令牌认证,零共享密钥。',
// Tools
'tools.label': '兼容性',
'tools.title': '兼容你常用的 AI 工具',
'tools.subtitle': 'AgentFox 使用 MCP 协议 — 主流 AI 编程助手均已支持的通用协议',
'tools.claude.name': 'Claude Code',
'tools.claude.desc': 'Anthropic CLI',
'tools.codex.name': 'Codex',
'tools.codex.desc': 'OpenAI CLI',
'tools.cursor.name': 'Cursor',
'tools.cursor.desc': 'AI 代码编辑器',
'tools.copilot.name': 'GitHub Copilot',
'tools.copilot.desc': 'GitHub AI 助手',
'tools.gemini.name': 'Gemini CLI',
'tools.gemini.desc': 'Google AI CLI',
'tools.antigravity.name': 'Antigravity',
'tools.antigravity.desc': 'AI 开发平台',
'tools.openclaw.name': 'OpenClaw',
'tools.openclaw.desc': 'AI 开发平台',
// Testimonials
'testimonials.label': '用户评价',
'testimonials.title': '深受开发者喜爱',
'testimonials.1.quote': 'AgentFox 将我们的 API 集成时间缩短了一半。Claude 通过 MCP 直接查询所需内容,不再需要复制粘贴文档。',
'testimonials.1.name': 'Sarah Chen',
'testimonials.1.role': 'Vercel 高级工程师',
'testimonials.2.quote': 'Token 节省是实实在在的 — 我们 Cursor 工作流从每次 API 调用消耗 15K tokens 降到了不到 1K。复杂集成的真正利器。',
'testimonials.2.name': 'Marcus Rivera',
'testimonials.2.role': 'Stackblitz CTO',
'testimonials.3.quote': '我们在一周内将 50+ 内部 API 接入了 AgentFox。现在每个团队的 AI 助手都能发现和使用任何服务端点。',
'testimonials.3.name': 'Yuki Tanaka',
'testimonials.3.role': 'Shopify 平台负责人',
// Pricing
'pricing.label': '定价方案',
'pricing.title': '简洁透明的定价',
'pricing.subtitle': '免费起步,按需扩展',
'pricing.free.name': '免费版',
'pricing.free.price': '¥0',
'pricing.free.period': '/月',
'pricing.free.desc': '体验 MCP 驱动的 API 文档服务',
'pricing.free.f1': '1 个项目',
'pricing.free.f2': '每日 100 次 MCP 查询',
'pricing.free.f3': 'OpenAPI 3.x & Swagger 2.0',
'pricing.free.f4': '社区支持',
'pricing.free.cta': '免费开始',
'pricing.pro.name': '专业版',
'pricing.pro.price': '¥199',
'pricing.pro.period': '/月',
'pricing.pro.badge': '最受欢迎',
'pricing.pro.desc': '为 AI 辅助开发团队打造',
'pricing.pro.f1': '无限项目',
'pricing.pro.f2': '无限 MCP 查询',
'pricing.pro.f3': '优先导入队列',
'pricing.pro.f4': '团队协作',
'pricing.pro.f5': '优先支持',
'pricing.pro.cta': '开始免费试用',
'pricing.enterprise.name': '企业版',
'pricing.enterprise.price': '联系我们',
'pricing.enterprise.period': '',
'pricing.enterprise.desc': '满足企业级高级需求',
'pricing.enterprise.f1': '私有化部署',
'pricing.enterprise.f2': 'SSO / SAML',
'pricing.enterprise.f3': 'SLA 保障',
'pricing.enterprise.f4': '专属支持',
'pricing.enterprise.f5': '定制集成',
'pricing.enterprise.cta': '联系销售',
// FAQ
'faq.label': '常见问题',
'faq.title': '常见问题解答',
'faq.1.q': '什么是 MCPAgentFox 如何使用它?',
'faq.1.a': 'MCPModel Context Protocol是一个开放标准让 AI 助手能够连接外部工具和数据源。AgentFox 通过 MCP 工具暴露你的 API 文档,让 Claude Code、Cursor、Copilot 等 AI 编程助手可以按需查询端点详情,而无需将整个规范放入上下文窗口。',
'faq.2.q': '支持哪些 OpenAPI 格式?',
'faq.2.a': 'AgentFox 支持 OpenAPI 3.x 和 Swagger 2.0 规范。你可以导入 JSON 或 YAML 格式的文档,也可以提供 URL 直接获取。导入时所有 $ref 引用会自动解引用。',
'faq.3.q': '能减少多少 Token 消耗?',
'faq.3.a': '每次 MCP 工具调用返回 ~200-2,000 tokens 的精准信息,相比全量 API 规范的 10,000+ tokens。对于典型的集成任务这意味着 80-95% 的 token 消耗降低。',
'faq.4.q': '我的 API 文档安全吗?',
'faq.4.a': '是的。每个项目拥有独立的 API Keybcrypt 哈希加密从不以明文存储。MCP 端点每次请求都需要认证。用户仪表盘使用 JWT 并自动轮换 token。',
'faq.5.q': '兼容哪些 AI 工具?',
'faq.5.a': '任何支持 MCP 协议的工具都可以连接 AgentFox包括 Claude Code、OpenAI Codex CLI、OpenClaw、Gemini CLI、Cursor、GitHub Copilot通过 MCP 插件、Antigravity 等。如果你的工具支持 MCP就能与 AgentFox 配合使用。',
'faq.6.q': '可以私有化部署吗?',
'faq.6.a': '可以AgentFox 支持云端和私有化部署。企业版包含完整的 Docker Compose 私有化部署支持,以及 SSO 集成和专属技术支持。',
// Footer
'footer.product': '产品',
'footer.features': '特性',
'footer.pricing': '定价',
'footer.docs': '文档',
'footer.changelog': '更新日志',
'footer.resources': '资源',
'footer.github': 'GitHub',
'footer.community': '社区',
'footer.blog': '博客',
'footer.legal': '法律',
'footer.privacy': '隐私政策',
'footer.terms': '服务条款',
'footer.copyright': '© 2026 AgentFox. 保留所有权利。',
'footer.tagline': '为 AI 代理打造的 MCP 驱动 API 文档服务。',
},
};
type I18nContextType = {
locale: Locale;
setLocale: (l: Locale) => void;
t: (key: string) => string;
};
const I18nContext = createContext<I18nContextType | null>(null);
function detectLocale(): Locale {
const saved = localStorage.getItem('agent-fox-locale');
if (saved === 'en' || saved === 'zh') return saved;
return navigator.language.startsWith('zh') ? 'zh' : 'en';
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(detectLocale);
const setLocale = useCallback((l: Locale) => {
setLocaleState(l);
localStorage.setItem('agent-fox-locale', l);
}, []);
const t = useCallback((key: string): string => {
return translations[locale][key] ?? key;
}, [locale]);
return (
<I18nContext.Provider value={{ locale, setLocale, t }}>
{children}
</I18nContext.Provider>
);
}
export function useI18n() {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error('useI18n must be used within I18nProvider');
return ctx;
}

View File

@@ -5,6 +5,7 @@ import { useAuth } from '../lib/auth';
import { apiFetch } from '../lib/api';
import ThemeToggle from '../components/ThemeToggle';
import SettingsDialog from '../components/SettingsDialog';
import ConfirmDialog from '../components/ConfirmDialog';
type LayoutContext = { onOpenSettings: () => void };
export function useLayoutContext() { return useOutletContext<LayoutContext>(); }
@@ -16,6 +17,7 @@ type ProjectSummary = {
function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string; email: string }; logout: () => void; onOpenSettings: () => void }) {
const [open, setOpen] = useState(false);
const [confirmLogout, setConfirmLogout] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
@@ -74,7 +76,7 @@ function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string;
Settings
</button>
<button
onClick={() => { setOpen(false); logout(); }}
onClick={() => { setOpen(false); setConfirmLogout(true); }}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors mx-1"
style={{ width: 'calc(100% - 8px)' }}
>
@@ -86,6 +88,15 @@ function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string;
</div>
</div>
)}
<ConfirmDialog
open={confirmLogout}
onConfirm={() => { setConfirmLogout(false); logout(); }}
onCancel={() => setConfirmLogout(false)}
title="Sign Out"
description="Are you sure you want to sign out?"
confirmText="Sign Out"
variant="warning"
/>
</div>
);
}
@@ -100,7 +111,7 @@ function ProjectSidebar() {
queryFn: () => apiFetch<ProjectSummary[]>('/projects'),
});
const isProjectsRoot = location.pathname === '/';
const isProjectsRoot = location.pathname === '/dashboard';
return (
<aside className="hidden lg:flex w-[240px] shrink-0 flex-col border-r border-border-default bg-bg-sidebar">
@@ -112,7 +123,7 @@ function ProjectSidebar() {
{/* Project list */}
<nav className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
<NavLink
to="/"
to="/dashboard"
end
className={`flex items-center gap-2 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
isProjectsRoot
@@ -141,7 +152,7 @@ function ProjectSidebar() {
{projects?.map((p) => (
<NavLink
key={p.id}
to={`/projects/${p.id}`}
to={`/dashboard/projects/${p.id}`}
className={`flex items-center gap-2 px-2.5 py-[7px] rounded-lg text-[13px] transition-all duration-150 group ${
activeProjectId === p.id
? 'bg-accent-muted text-accent font-medium'
@@ -222,12 +233,13 @@ export default function Layout() {
</svg>
</button>
<Link to="/" className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shadow-sm">
<svg className="w-[14px] h-[14px] text-white" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
<div className="w-8 h-8 rounded-lg flex items-center justify-center shadow-sm"
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
<svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<span className="font-semibold text-[15px] text-text-primary tracking-[-0.01em]">Agent Fox</span>
<span className="font-bold text-lg text-text-primary tracking-tight" style={{ fontFamily: 'var(--font-heading)' }}>AgentFox</span>
</Link>
</div>
@@ -248,17 +260,18 @@ export default function Layout() {
{/* Mobile sidebar */}
<aside className={`fixed inset-y-0 left-0 z-50 w-[260px] bg-bg-sidebar border-r border-border-default flex flex-col transition-transform duration-200 lg:hidden ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-border-default">
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shadow-sm">
<svg className="w-[14px] h-[14px] text-white" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
<Link to="/" className="px-4 h-14 flex items-center gap-2.5 border-b border-border-default">
<div className="w-8 h-8 rounded-lg flex items-center justify-center shadow-sm"
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
<svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<span className="font-semibold text-[15px] text-text-primary tracking-[-0.01em]">Agent Fox</span>
</div>
<span className="font-bold text-lg text-text-primary tracking-tight" style={{ fontFamily: 'var(--font-heading)' }}>AgentFox</span>
</Link>
<nav className="flex-1 overflow-y-auto px-2.5 py-3 space-y-0.5">
<NavLink
to="/"
to="/dashboard"
end
onClick={() => setMobileMenuOpen(false)}
className={({ isActive }) =>

View File

@@ -1,22 +1,40 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../lib/auth';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({});
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get('redirect') || '/';
const validate = () => {
const errors: { email?: string; password?: string } = {};
if (!email.trim()) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = 'Please enter a valid email address';
}
if (!password) {
errors.password = 'Password is required';
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validate()) return;
setLoading(true);
try {
await login(email, password);
navigate('/');
navigate(redirectTo);
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
@@ -44,7 +62,7 @@ export default function Login() {
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Sign in to Agent Fox</h1>
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Sign in to AgentFox</h1>
<p className="text-[13px] text-text-muted mt-1">API documentation for LLMs</p>
</div>
@@ -56,14 +74,38 @@ export default function Login() {
<span className="text-danger text-[13px]">{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div>
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Email</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input-base" placeholder="you@example.com" required />
<input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); if (fieldErrors.email) setFieldErrors(prev => ({ ...prev, email: undefined })); }}
className={`input-base ${fieldErrors.email ? 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!' : ''}`}
placeholder="you@example.com"
/>
{fieldErrors.email && (
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
{fieldErrors.email}
</p>
)}
</div>
<div>
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Password</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input-base" placeholder="Enter your password" required />
<input
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); if (fieldErrors.password) setFieldErrors(prev => ({ ...prev, password: undefined })); }}
className={`input-base ${fieldErrors.password ? 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!' : ''}`}
placeholder="Enter your password"
/>
{fieldErrors.password && (
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
{fieldErrors.password}
</p>
)}
</div>
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? (

View File

@@ -53,7 +53,7 @@ export default function ProjectDetail() {
<div className="text-center py-20">
<svg className="w-10 h-10 mx-auto text-text-muted mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<p className="text-text-muted text-sm">Project not found</p>
<Link to="/" className="text-accent hover:underline text-sm mt-2 inline-block">Back to projects</Link>
<Link to="/dashboard" className="text-accent hover:underline text-sm mt-2 inline-block">Back to projects</Link>
</div>
);
}
@@ -62,7 +62,7 @@ export default function ProjectDetail() {
<div>
{/* Breadcrumb */}
<div className="flex items-center gap-1.5 text-[13px] text-text-muted mb-5">
<Link to="/" className="hover:text-text-primary transition-colors">Projects</Link>
<Link to="/dashboard" className="hover:text-text-primary transition-colors">Projects</Link>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M9 5l7 7-7 7" /></svg>
<span className="text-text-secondary font-medium">{project.name}</span>
</div>

View File

@@ -7,17 +7,44 @@ export default function Register() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [fieldErrors, setFieldErrors] = useState<{ name?: string; email?: string; password?: string }>({});
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const clearFieldError = (field: string) => {
if (fieldErrors[field as keyof typeof fieldErrors]) {
setFieldErrors(prev => ({ ...prev, [field]: undefined }));
}
};
const validate = () => {
const errors: { name?: string; email?: string; password?: string } = {};
if (!name.trim()) {
errors.name = 'Name is required';
}
if (!email.trim()) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = 'Please enter a valid email address';
}
if (!password) {
errors.password = 'Password is required';
} else if (password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validate()) return;
setLoading(true);
try {
await register(email, password, name);
navigate('/');
navigate('/dashboard');
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
} finally {
@@ -25,6 +52,8 @@ export default function Register() {
}
};
const errorInputClass = 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!';
return (
<div className="min-h-screen flex items-center justify-center bg-bg-primary relative overflow-hidden">
<div className="absolute inset-0" style={{
@@ -43,7 +72,7 @@ export default function Register() {
</svg>
</div>
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Create your account</h1>
<p className="text-[13px] text-text-muted mt-1">Get started with Agent Fox</p>
<p className="text-[13px] text-text-muted mt-1">Get started with AgentFox</p>
</div>
<div className="card p-6 shadow-md">
@@ -53,18 +82,54 @@ export default function Register() {
<span className="text-danger text-[13px]">{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div>
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Name</label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base" placeholder="Your name" required />
<input
type="text"
value={name}
onChange={(e) => { setName(e.target.value); clearFieldError('name'); }}
className={`input-base ${fieldErrors.name ? errorInputClass : ''}`}
placeholder="Your name"
/>
{fieldErrors.name && (
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
{fieldErrors.name}
</p>
)}
</div>
<div>
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Email</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input-base" placeholder="you@example.com" required />
<input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); clearFieldError('email'); }}
className={`input-base ${fieldErrors.email ? errorInputClass : ''}`}
placeholder="you@example.com"
/>
{fieldErrors.email && (
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
{fieldErrors.email}
</p>
)}
</div>
<div>
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Password</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} required />
<input
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); clearFieldError('password'); }}
className={`input-base ${fieldErrors.password ? errorInputClass : ''}`}
placeholder="At least 8 characters"
/>
{fieldErrors.password && (
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
{fieldErrors.password}
</p>
)}
</div>
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? (

View File

@@ -0,0 +1,77 @@
import { useState } from 'react';
import { useI18n } from '../../lib/i18n';
import { useScrollReveal } from '../../hooks/useScrollReveal';
const faqKeys = [1, 2, 3, 4, 5, 6] as const;
function FAQItem({ q, a, isOpen, onToggle }: { q: string; a: string; isOpen: boolean; onToggle: () => void }) {
return (
<div
className="rounded-xl transition-all duration-200"
style={{
background: isOpen ? 'var(--bg-elevated)' : 'transparent',
border: `1px solid ${isOpen ? 'var(--border-default)' : 'var(--border-muted)'}`,
}}
>
<button
onClick={onToggle}
className="w-full flex items-center justify-between text-left px-6 py-4 cursor-pointer group"
>
<span className="text-sm font-medium pr-4 transition-colors" style={{ color: isOpen ? 'var(--text-primary)' : 'var(--text-secondary)' }}>
{q}
</span>
<svg
className={`w-4 h-4 shrink-0 transition-transform duration-300 ${isOpen ? 'rotate-45' : ''}`}
style={{ color: isOpen ? 'var(--fox-amber)' : 'var(--text-muted)' }}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
</button>
<div
className="overflow-hidden transition-all duration-300"
style={{ maxHeight: isOpen ? '500px' : '0' }}
>
<p className="px-6 pb-5 text-sm leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
{a}
</p>
</div>
</div>
);
}
export default function FAQSection() {
const { t } = useI18n();
const { ref, isVisible } = useScrollReveal();
const [openIndex, setOpenIndex] = useState<number | null>(0);
return (
<div className="w-full py-20">
<div ref={ref} className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className={`text-center mb-12 transition-all duration-700 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
<span className="inline-block text-xs font-semibold uppercase tracking-[0.15em] mb-3 px-3 py-1 rounded-full"
style={{ color: 'var(--fox-amber)', background: 'var(--fox-glow)' }}>
{t('faq.label')}
</span>
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
{t('faq.title')}
</h2>
</div>
{/* FAQ items */}
<div className={`space-y-2 transition-all duration-700 delay-200 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
{faqKeys.map((num, i) => (
<FAQItem
key={num}
q={t(`faq.${num}.q`)}
a={t(`faq.${num}.a`)}
isOpen={openIndex === i}
onToggle={() => setOpenIndex(openIndex === i ? null : i)}
/>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useI18n } from '../../lib/i18n';
import { useScrollReveal } from '../../hooks/useScrollReveal';
const featureKeys = [
{ key: 'progressive', icon: 'layers' },
{ key: 'token', icon: 'zap' },
{ key: 'spec', icon: 'file' },
{ key: 'import', icon: 'upload' },
{ key: 'projects', icon: 'grid' },
{ key: 'security', icon: 'shield' },
] as const;
function FeatureIcon({ type }: { type: string }) {
const icons: Record<string, React.ReactNode> = {
layers: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
),
zap: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
),
file: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" />
</svg>
),
upload: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" />
</svg>
),
grid: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
),
shield: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<path d="M9 12l2 2 4-4" />
</svg>
),
};
return icons[type] || null;
}
export default function FeaturesSection() {
const { t } = useI18n();
const { ref, isVisible } = useScrollReveal();
return (
<div className="w-full py-20">
<div ref={ref} className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className={`text-center mb-16 transition-all duration-700 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
<span className="inline-block text-xs font-semibold uppercase tracking-[0.15em] mb-3 px-3 py-1 rounded-full"
style={{ color: 'var(--fox-amber)', background: 'var(--fox-glow)' }}>
{t('features.label')}
</span>
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
{t('features.title')}
</h2>
<p className="text-lg max-w-2xl mx-auto" style={{ color: 'var(--text-secondary)' }}>
{t('features.subtitle')}
</p>
</div>
{/* Feature grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{featureKeys.map(({ key, icon }, i) => (
<div
key={key}
className={`group p-6 rounded-2xl transition-all duration-700 hover:shadow-lg hover:-translate-y-1 ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
}`}
style={{
transitionDelay: isVisible ? `${150 + i * 100}ms` : '0ms',
background: 'var(--bg-elevated)',
border: '1px solid var(--border-default)',
}}
>
<div className="w-11 h-11 rounded-xl flex items-center justify-center mb-4 transition-transform duration-300 group-hover:scale-110"
style={{ background: 'var(--fox-glow)', color: 'var(--fox-amber)' }}>
<FeatureIcon type={icon} />
</div>
<h3 className="text-base font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{t(`features.${key}.title`)}
</h3>
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
{t(`features.${key}.desc`)}
</p>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { Link } from 'react-router-dom';
import { useI18n } from '../../lib/i18n';
export default function FooterSection() {
const { t } = useI18n();
const columns = [
{
title: t('footer.product'),
links: [
{ label: t('footer.features'), href: '#features' },
{ label: t('footer.pricing'), href: '#pricing' },
{ label: t('footer.docs'), href: '#' },
{ label: t('footer.changelog'), href: '#' },
],
},
{
title: t('footer.resources'),
links: [
{ label: t('footer.github'), href: 'https://github.com' },
{ label: t('footer.community'), href: '#' },
{ label: t('footer.blog'), href: '#' },
],
},
{
title: t('footer.legal'),
links: [
{ label: t('footer.privacy'), href: '#' },
{ label: t('footer.terms'), href: '#' },
],
},
];
return (
<footer className="w-full py-16 border-t" style={{ borderColor: 'var(--border-muted)', background: 'var(--bg-secondary)' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-12">
{/* Brand column */}
<div className="col-span-2 md:col-span-1">
<Link to="/" className="flex items-center gap-2.5 mb-4">
<div className="w-7 h-7 rounded-lg flex items-center justify-center"
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
<svg className="w-3.5 h-3.5 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<span className="font-bold text-text-primary" style={{ fontFamily: 'var(--font-heading)' }}>AgentFox</span>
</Link>
<p className="text-[13px] leading-relaxed" style={{ color: 'var(--text-muted)' }}>
{t('footer.tagline')}
</p>
</div>
{/* Link columns */}
{columns.map(col => (
<div key={col.title}>
<h4 className="text-[11px] font-semibold uppercase tracking-[0.1em] mb-3" style={{ color: 'var(--text-muted)' }}>
{col.title}
</h4>
<ul className="space-y-2">
{col.links.map(link => (
<li key={link.label}>
<a href={link.href} className="text-sm transition-colors hover:underline" style={{ color: 'var(--text-secondary)' }}>
{link.label}
</a>
</li>
))}
</ul>
</div>
))}
</div>
{/* Bottom bar */}
<div className="pt-6 border-t flex flex-col sm:flex-row items-center justify-between gap-4" style={{ borderColor: 'var(--border-muted)' }}>
<span className="text-[12px]" style={{ color: 'var(--text-muted)' }}>
{t('footer.copyright')}
</span>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../lib/auth';
import { useI18n } from '../../lib/i18n';
import { useScrollReveal } from '../../hooks/useScrollReveal';
function TerminalDemo() {
const { t } = useI18n();
const [visibleLines, setVisibleLines] = useState(0);
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
const delays = [300, 800, 1400, 2200, 2800, 3600, 4400];
delays.forEach((delay, i) => {
timers.push(setTimeout(() => setVisibleLines(i + 1), delay));
});
return () => timers.forEach(clearTimeout);
}, []);
const lines = [
{ type: 'comment', text: t('hero.terminal.comment') },
{ type: 'cmd', text: `> ${t('hero.terminal.cmd1')}` },
{ type: 'result', text: t('hero.terminal.res1') },
{ type: 'cmd', text: `> ${t('hero.terminal.cmd2')}` },
{ type: 'result', text: t('hero.terminal.res2') },
{ type: 'cmd', text: `> ${t('hero.terminal.cmd3')}` },
{ type: 'result', text: t('hero.terminal.res3') },
];
return (
<div className="w-full max-w-2xl mx-auto">
<div className="rounded-xl overflow-hidden shadow-2xl" style={{ border: '1px solid var(--border-default)' }}>
{/* Title bar */}
<div className="flex items-center gap-2 px-4 py-3" style={{ background: 'var(--bg-tertiary)', borderBottom: '1px solid var(--border-default)' }}>
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full" style={{ background: '#ff5f57' }} />
<div className="w-3 h-3 rounded-full" style={{ background: '#febc2e' }} />
<div className="w-3 h-3 rounded-full" style={{ background: '#28c840' }} />
</div>
<span className="text-[11px] font-mono ml-2" style={{ color: 'var(--text-muted)' }}>mcp-client</span>
</div>
{/* Terminal body */}
<div className="p-5 font-mono text-[13px] leading-relaxed space-y-1 min-h-[220px]" style={{ background: 'var(--code-bg)' }}>
{lines.map((line, i) => (
<div
key={i}
className={`transition-all duration-500 ${i < visibleLines ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'}`}
style={{
color: line.type === 'comment'
? 'var(--code-comment)'
: line.type === 'cmd'
? 'var(--fox-amber)'
: 'var(--code-text)',
}}
>
{line.text}
</div>
))}
{visibleLines >= lines.length && (
<div className="flex items-center gap-0.5 mt-1">
<span style={{ color: 'var(--fox-amber)' }}>{'> '}</span>
<div className="w-2 h-4 animate-pulse-soft" style={{ background: 'var(--fox-amber)' }} />
</div>
)}
</div>
</div>
</div>
);
}
export default function HeroSection() {
const { user } = useAuth();
const { t } = useI18n();
const { ref, isVisible } = useScrollReveal({ threshold: 0.1 });
return (
<div className="w-full relative overflow-hidden">
{/* Background effects */}
<div className="absolute inset-0" style={{
backgroundImage: `linear-gradient(var(--border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--border-muted) 1px, transparent 1px)`,
backgroundSize: '64px 64px',
opacity: 0.5,
}} />
<div className="absolute inset-0" style={{
background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 75%)`,
}} />
{/* Gradient orbs */}
<div className="absolute top-1/4 -left-32 w-96 h-96 rounded-full blur-3xl animate-gradient-shift" style={{ background: 'var(--fox-glow)' }} />
<div className="absolute bottom-1/4 -right-32 w-80 h-80 rounded-full blur-3xl animate-gradient-shift" style={{ background: 'var(--fox-glow)', animationDelay: '3s' }} />
<div ref={ref} className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-28 pb-16">
<div className={`text-center transition-all duration-1000 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full mb-8"
style={{ background: 'var(--fox-glow)', border: '1px solid rgba(245, 158, 11, 0.2)' }}>
<div className="w-1.5 h-1.5 rounded-full animate-pulse-soft" style={{ background: 'var(--fox-amber)' }} />
<span className="text-xs font-medium tracking-wide" style={{ color: 'var(--fox-amber)' }}>
{t('hero.badge')}
</span>
</div>
{/* Headline */}
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-extrabold tracking-tight leading-[1.1] mb-6" style={{ fontFamily: 'var(--font-heading)' }}>
<span style={{ color: 'var(--text-primary)' }}>{t('hero.title')}</span>
<br />
<span style={{
background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>
{t('hero.titleHighlight')}
</span>
</h1>
{/* Subtitle */}
<p className="max-w-2xl mx-auto text-lg sm:text-xl leading-relaxed mb-10" style={{ color: 'var(--text-secondary)' }}>
{t('hero.subtitle')}
</p>
{/* CTAs */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
to={user ? '/dashboard' : '/login?redirect=/dashboard'}
className="px-7 py-3.5 rounded-xl text-base font-semibold text-white transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 active:translate-y-0"
style={{
background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))',
boxShadow: '0 4px 20px var(--fox-glow)',
}}
>
{t('hero.cta')}
</Link>
<a
href="#features"
className="px-7 py-3.5 rounded-xl text-base font-medium transition-all duration-200 hover:-translate-y-0.5"
style={{
color: 'var(--text-secondary)',
border: '1px solid var(--border-default)',
background: 'var(--bg-elevated)',
}}
>
{t('hero.ctaSecondary')}
</a>
</div>
</div>
{/* Terminal demo */}
<div className={`mt-16 transition-all duration-1000 delay-300 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-12'}`}>
<TerminalDemo />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../lib/auth';
import { useI18n } from '../../lib/i18n';
import ThemeToggle from '../../components/ThemeToggle';
import LanguageToggle from '../../components/LanguageToggle';
import ConfirmDialog from '../../components/ConfirmDialog';
function LandingUserDropdown({ user, logout }: { user: { name: string; email: string }; logout: () => void }) {
const [open, setOpen] = useState(false);
const [confirmLogout, setConfirmLogout] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-bg-tertiary transition-colors cursor-pointer"
>
<div className="w-7 h-7 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold tracking-wide">
{initials}
</div>
<div className="hidden md:block text-left">
<div className="text-[13px] font-medium text-text-primary leading-tight">{user.name}</div>
</div>
<svg className="w-3.5 h-3.5 text-text-muted hidden md:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="user-dropdown">
{/* User info */}
<div className="px-3 py-2.5 border-b border-border-muted">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[11px] font-bold tracking-wide shrink-0">
{initials}
</div>
<div className="min-w-0">
<div className="text-[13px] font-medium text-text-primary truncate">{user.name}</div>
<div className="text-[11px] text-text-muted truncate">{user.email}</div>
</div>
</div>
</div>
{/* Actions */}
<div className="py-1">
<button
onClick={() => { setOpen(false); setConfirmLogout(true); }}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors mx-1"
style={{ width: 'calc(100% - 8px)' }}
>
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign Out
</button>
</div>
</div>
)}
<ConfirmDialog
open={confirmLogout}
onConfirm={() => { setConfirmLogout(false); logout(); }}
onCancel={() => setConfirmLogout(false)}
title="Sign Out"
description="Are you sure you want to sign out?"
confirmText="Sign Out"
variant="warning"
/>
</div>
);
}
export default function LandingNav({ scrollRef }: { scrollRef: React.RefObject<HTMLDivElement | null> }) {
const { user, loading, logout } = useAuth();
const { t } = useI18n();
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [confirmLogout, setConfirmLogout] = useState(false);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const handler = () => setScrolled(el.scrollTop > 50);
el.addEventListener('scroll', handler, { passive: true });
return () => el.removeEventListener('scroll', handler);
}, [scrollRef]);
const scrollToSection = (id: string) => {
const el = document.getElementById(id);
el?.scrollIntoView({ behavior: 'smooth' });
setMobileOpen(false);
};
const navLinks = [
{ label: t('nav.features'), id: 'features' },
{ label: t('nav.tools'), id: 'tools' },
{ label: t('nav.testimonials'), id: 'testimonials' },
{ label: t('nav.pricing'), id: 'pricing' },
{ label: t('nav.faq'), id: 'faq' },
];
const initials = user?.name?.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
return (
<>
<nav className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
scrolled
? 'bg-bg-primary/80 backdrop-blur-xl border-b border-border-muted shadow-sm'
: 'bg-transparent'
}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center h-16">
{/* Left: Logo */}
<div className="flex-1 flex items-center">
<Link to="/" className="flex items-center gap-2.5 shrink-0">
<div className="w-8 h-8 rounded-lg flex items-center justify-center shadow-sm"
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
<svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<span className="font-bold text-lg text-text-primary tracking-tight" style={{ fontFamily: 'var(--font-heading)' }}>
AgentFox
</span>
</Link>
</div>
{/* Center: Desktop nav links */}
<div className="hidden md:flex items-center gap-1 shrink-0">
{navLinks.map(link => (
<button
key={link.id}
onClick={() => scrollToSection(link.id)}
className="px-3 py-2 rounded-lg text-sm text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all duration-150 cursor-pointer"
>
{link.label}
</button>
))}
</div>
{/* Right side */}
<div className="flex-1 flex items-center justify-end gap-2">
<div className="hidden sm:flex items-center gap-1">
<LanguageToggle />
<ThemeToggle />
</div>
<div className="w-px h-5 bg-border-default mx-1 hidden sm:block" />
{/* Logged out: Sign In + Get Started */}
{!loading && !user && (
<div className="hidden sm:flex items-center gap-2">
<Link to="/login" className="px-3 py-1.5 rounded-lg text-sm text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all duration-150">
{t('nav.signIn')}
</Link>
<Link
to="/login?redirect=/dashboard"
className="px-4 py-2 rounded-lg text-sm font-medium text-white transition-all duration-150 hover:opacity-90 shadow-md"
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}
>
{t('nav.getStarted')}
</Link>
</div>
)}
{/* Logged in: Dashboard button + User dropdown */}
{!loading && user && (
<div className="hidden sm:flex items-center gap-2">
<Link
to="/dashboard"
className="px-3.5 py-1.5 rounded-lg text-sm font-medium text-white transition-all duration-150 hover:opacity-90 shadow-sm"
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}
>
{t('nav.dashboard')}
</Link>
<LandingUserDropdown user={user} logout={logout} />
</div>
)}
{/* Mobile hamburger */}
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="md:hidden p-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-colors"
aria-label="Toggle menu"
aria-expanded={mobileOpen}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{mobileOpen
? <path d="M6 18L18 6M6 6l12 12" />
: <path d="M4 6h16M4 12h16M4 18h16" />
}
</svg>
</button>
</div>
</div>
</div>
</nav>
{/* Mobile menu overlay */}
{mobileOpen && (
<div className="fixed inset-0 z-40 bg-bg-primary/95 backdrop-blur-xl md:hidden">
<div className="pt-20 px-6 space-y-2">
{navLinks.map(link => (
<button
key={link.id}
onClick={() => scrollToSection(link.id)}
className="block w-full text-left px-4 py-3 rounded-xl text-lg text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all"
>
{link.label}
</button>
))}
<div className="border-t border-border-muted my-4!" />
<div className="flex items-center gap-3 px-4 py-2">
<LanguageToggle />
<ThemeToggle />
</div>
<div className="border-t border-border-muted my-4!" />
{!loading && !user && (
<div className="space-y-2 px-4">
<Link to="/login" onClick={() => setMobileOpen(false)} className="block w-full text-center px-4 py-3 rounded-xl text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all text-lg">
{t('nav.signIn')}
</Link>
<Link
to="/login?redirect=/dashboard"
onClick={() => setMobileOpen(false)}
className="block w-full text-center px-4 py-3 rounded-xl text-white font-medium text-lg shadow-md"
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}
>
{t('nav.getStarted')}
</Link>
</div>
)}
{!loading && user && (
<div className="space-y-2 px-4">
<Link to="/dashboard" onClick={() => setMobileOpen(false)} className="block w-full text-center px-4 py-3 rounded-xl text-white font-medium text-lg shadow-md"
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
{t('nav.dashboard')}
</Link>
<div className="flex items-center gap-3 px-4 py-3 rounded-xl">
<div className="w-8 h-8 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold shrink-0">
{initials}
</div>
<span className="text-lg text-text-primary font-medium flex-1">{user.name}</span>
<button onClick={() => { setMobileOpen(false); setConfirmLogout(true); }} className="text-sm text-text-muted hover:text-danger transition-colors">
Sign Out
</button>
</div>
</div>
)}
</div>
</div>
)}
<ConfirmDialog
open={confirmLogout}
onConfirm={() => { setConfirmLogout(false); logout(); }}
onCancel={() => setConfirmLogout(false)}
title="Sign Out"
description="Are you sure you want to sign out?"
confirmText="Sign Out"
variant="warning"
/>
</>
);
}

View File

@@ -0,0 +1,51 @@
import { useRef } from 'react';
import LandingNav from './LandingNav';
import HeroSection from './HeroSection';
import FeaturesSection from './FeaturesSection';
import ToolsSection from './ToolsSection';
import TestimonialsSection from './TestimonialsSection';
import PricingSection from './PricingSection';
import FAQSection from './FAQSection';
import FooterSection from './FooterSection';
export default function LandingPage() {
const scrollRef = useRef<HTMLDivElement>(null);
return (
<div
ref={scrollRef}
className="h-screen overflow-y-auto snap-y snap-proximity md:snap-mandatory scroll-smooth"
style={{ background: 'var(--bg-primary)' }}
>
<LandingNav scrollRef={scrollRef} />
<section id="hero" className="min-h-screen snap-start snap-always flex items-center">
<HeroSection />
</section>
<section id="features" className="min-h-screen snap-start snap-always flex items-center">
<FeaturesSection />
</section>
<section id="tools" className="min-h-screen snap-start snap-always flex items-center">
<ToolsSection />
</section>
<section id="testimonials" className="min-h-screen snap-start snap-always flex items-center">
<TestimonialsSection />
</section>
<section id="pricing" className="min-h-screen snap-start snap-always flex items-center">
<PricingSection />
</section>
<section id="faq" className="min-h-screen snap-start snap-always flex items-center">
<FAQSection />
</section>
<section className="snap-start">
<FooterSection />
</section>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { Link } from 'react-router-dom';
import { useI18n } from '../../lib/i18n';
import { useScrollReveal } from '../../hooks/useScrollReveal';
type PlanKey = 'free' | 'pro' | 'enterprise';
const plans: { key: PlanKey; featured: boolean; featureCount: number }[] = [
{ key: 'free', featured: false, featureCount: 4 },
{ key: 'pro', featured: true, featureCount: 5 },
{ key: 'enterprise', featured: false, featureCount: 5 },
];
export default function PricingSection() {
const { t } = useI18n();
const { ref, isVisible } = useScrollReveal();
return (
<div className="w-full py-20">
<div ref={ref} className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className={`text-center mb-16 transition-all duration-700 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
<span className="inline-block text-xs font-semibold uppercase tracking-[0.15em] mb-3 px-3 py-1 rounded-full"
style={{ color: 'var(--fox-amber)', background: 'var(--fox-glow)' }}>
{t('pricing.label')}
</span>
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
{t('pricing.title')}
</h2>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
{t('pricing.subtitle')}
</p>
</div>
{/* Pricing cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 max-w-5xl mx-auto">
{plans.map(({ key, featured, featureCount }, i) => (
<div
key={key}
className={`relative p-7 rounded-2xl transition-all duration-700 hover:-translate-y-1 ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
} ${featured ? 'shadow-lg' : ''}`}
style={{
transitionDelay: isVisible ? `${200 + i * 150}ms` : '0ms',
background: 'var(--bg-elevated)',
border: featured
? '2px solid var(--fox-amber)'
: '1px solid var(--border-default)',
}}
>
{/* Popular badge */}
{featured && t(`pricing.${key}.badge`) && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-0.5 rounded-full text-[11px] font-semibold text-white"
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
{t(`pricing.${key}.badge`)}
</div>
)}
{/* Plan name + price */}
<div className="mb-5">
<h3 className="text-base font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
{t(`pricing.${key}.name`)}
</h3>
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold tracking-tight" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
{t(`pricing.${key}.price`)}
</span>
{t(`pricing.${key}.period`) && (
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>
{t(`pricing.${key}.period`)}
</span>
)}
</div>
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
{t(`pricing.${key}.desc`)}
</p>
</div>
{/* Features list */}
<ul className="space-y-3 mb-7">
{Array.from({ length: featureCount }, (_, j) => (
<li key={j} className="flex items-center gap-2.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
<svg className="w-4 h-4 shrink-0" style={{ color: 'var(--fox-amber)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{t(`pricing.${key}.f${j + 1}`)}
</li>
))}
</ul>
{/* CTA */}
{key === 'enterprise' ? (
<button
className={`block w-full text-center py-2.5 rounded-xl text-sm font-medium transition-all duration-200 hover:-translate-y-0.5 cursor-pointer`}
style={{ background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }}
>
{t(`pricing.${key}.cta`)}
</button>
) : (
<Link
to="/register"
className={`block w-full text-center py-2.5 rounded-xl text-sm font-medium transition-all duration-200 hover:-translate-y-0.5 ${
featured ? 'text-white shadow-md' : ''
}`}
style={featured
? { background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }
: { background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }
}
>
{t(`pricing.${key}.cta`)}
</Link>
)}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { useI18n } from '../../lib/i18n';
import { useScrollReveal } from '../../hooks/useScrollReveal';
const testimonials = [1, 2, 3] as const;
export default function TestimonialsSection() {
const { t } = useI18n();
const { ref, isVisible } = useScrollReveal();
const colors = ['#f59e0b', '#6366f1', '#30a46c'];
return (
<div className="w-full py-20">
<div ref={ref} className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className={`text-center mb-16 transition-all duration-700 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
<span className="inline-block text-xs font-semibold uppercase tracking-[0.15em] mb-3 px-3 py-1 rounded-full"
style={{ color: 'var(--fox-amber)', background: 'var(--fox-glow)' }}>
{t('testimonials.label')}
</span>
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
{t('testimonials.title')}
</h2>
</div>
{/* Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{testimonials.map((num, i) => (
<div
key={num}
className={`relative p-7 rounded-2xl transition-all duration-700 hover:-translate-y-1 hover:shadow-lg ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
}`}
style={{
transitionDelay: isVisible ? `${200 + i * 150}ms` : '0ms',
background: 'var(--bg-elevated)',
border: '1px solid var(--border-default)',
}}
>
{/* Quote mark */}
<div className="absolute top-5 right-6 text-5xl font-serif leading-none" style={{ color: 'var(--border-default)' }}>
"
</div>
{/* Quote text */}
<p className="text-sm leading-relaxed mb-6 relative z-10" style={{ color: 'var(--text-secondary)' }}>
"{t(`testimonials.${num}.quote`)}"
</p>
{/* Author */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full flex items-center justify-center text-[11px] font-bold text-white shrink-0"
style={{ background: colors[i] }}>
{t(`testimonials.${num}.name`).split(' ').map(w => w[0]).join('')}
</div>
<div>
<div className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{t(`testimonials.${num}.name`)}
</div>
<div className="text-[12px]" style={{ color: 'var(--text-muted)' }}>
{t(`testimonials.${num}.role`)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { useI18n } from '../../lib/i18n';
import { useScrollReveal } from '../../hooks/useScrollReveal';
import claudeCodeIcon from '../../assets/icons/tools/claude-code.svg';
import codexIcon from '../../assets/icons/tools/codex.svg';
import cursorIcon from '../../assets/icons/tools/cursor.svg';
import copilotIcon from '../../assets/icons/tools/github-copilot.svg';
import geminiIcon from '../../assets/icons/tools/gemini.svg';
import antigravityIcon from '../../assets/icons/tools/antigravity.svg';
import openclawIcon from '../../assets/icons/tools/openclaw.svg';
const tools = [
{ key: 'claude', icon: claudeCodeIcon, bg: '#584438' },
{ key: 'codex', icon: codexIcon, bg: '#3e3e5c' },
{ key: 'openclaw', icon: openclawIcon, bg: '#583030' },
{ key: 'gemini', icon: geminiIcon, bg: '#2e3e5c' },
{ key: 'cursor', icon: cursorIcon, bg: '#2e4a4a' },
{ key: 'copilot', icon: copilotIcon, bg: '#40345a' },
{ key: 'antigravity', icon: antigravityIcon, bg: '#3c3c44' },
] as const;
export default function ToolsSection() {
const { t } = useI18n();
const { ref, isVisible } = useScrollReveal();
return (
<div className="w-full py-20">
<div ref={ref} className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className={`text-center mb-16 transition-all duration-700 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
<span className="inline-block text-xs font-semibold uppercase tracking-[0.15em] mb-3 px-3 py-1 rounded-full"
style={{ color: 'var(--fox-amber)', background: 'var(--fox-glow)' }}>
{t('tools.label')}
</span>
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
{t('tools.title')}
</h2>
<p className="text-lg max-w-2xl mx-auto" style={{ color: 'var(--text-secondary)' }}>
{t('tools.subtitle')}
</p>
</div>
{/* Tool grid — 7 items: 4+3 on tablet, single row on desktop */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-4">
{tools.map(({ key, icon, bg }, i) => (
<div
key={key}
className={`group flex flex-col items-center text-center p-5 rounded-2xl transition-all duration-700 hover:-translate-y-2 cursor-default ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
}`}
style={{
transitionDelay: isVisible ? `${200 + i * 80}ms` : '0ms',
background: 'var(--bg-elevated)',
border: '1px solid var(--border-default)',
}}
>
{/* Icon container with dark bg for visibility */}
<div
className="w-16 h-16 flex items-center justify-center rounded-2xl mb-3 transition-all duration-500 group-hover:shadow-lg group-hover:scale-110"
style={{ background: bg }}
>
<img
src={icon}
alt={t(`tools.${key}.name`)}
className="w-9 h-9"
draggable={false}
/>
</div>
<span className="text-sm font-semibold mb-0.5 transition-colors duration-300 group-hover:text-text-primary" style={{ color: 'var(--text-secondary)' }}>
{t(`tools.${key}.name`)}
</span>
<span className="text-[11px]" style={{ color: 'var(--text-muted)' }}>
{t(`tools.${key}.desc`)}
</span>
</div>
))}
</div>
{/* Connector line visual */}
<div className={`mt-12 flex items-center justify-center gap-3 transition-all duration-700 delay-700 ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
<div className="h-px flex-1 max-w-[120px]" style={{ background: 'linear-gradient(to right, transparent, var(--border-default))' }} />
<span className="text-xs font-mono tracking-wide px-3 py-1 rounded-full" style={{ color: 'var(--fox-amber)', background: 'var(--fox-glow)', border: '1px solid rgba(245,158,11,0.15)' }}>
MCP
</span>
<div className="h-px flex-1 max-w-[120px]" style={{ background: 'linear-gradient(to left, transparent, var(--border-default))' }} />
</div>
</div>
</div>
);
}

6
packages/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.svg' {
const src: string;
export default src;
}