feat: add layout components (MarketingNavbar, AppNavbar, Footer, layouts)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
13
src/components/layout/AppLayout.tsx
Normal file
13
src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import AppNavbar from './AppNavbar';
|
||||||
|
|
||||||
|
export default function AppLayout() {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-gray-50 font-sans text-gray-900 overflow-hidden">
|
||||||
|
<AppNavbar />
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
src/components/layout/AppNavbar.tsx
Normal file
194
src/components/layout/AppNavbar.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Mail, Users, MessageCircle, ChevronDown, Check, Heart, X, Languages, HelpCircle, Home } from 'lucide-react';
|
||||||
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
|
export default function AppNavbar() {
|
||||||
|
const { language, setLanguage, t } = useLanguage();
|
||||||
|
const [showContact, setShowContact] = useState(false);
|
||||||
|
const [showReward, setShowReward] = useState(false);
|
||||||
|
const [showLangMenu, setShowLangMenu] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const langMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleCopyQQ = async () => {
|
||||||
|
await navigator.clipboard.writeText('1018282100');
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setShowContact(false);
|
||||||
|
}
|
||||||
|
if (langMenuRef.current && !langMenuRef.current.contains(event.target as Node)) {
|
||||||
|
setShowLangMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 flex-shrink-0 z-[60] relative">
|
||||||
|
{/* Left: Logo + Home link */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/" className="flex items-center gap-2">
|
||||||
|
<img src="/texpixel-app-icon.svg" alt="TexPixel" className="w-8 h-8" />
|
||||||
|
<span className="text-xl font-bold text-gray-900 tracking-tight">TexPixel</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-gray-500 hover:text-gray-700 text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<Home size={14} />
|
||||||
|
<span className="hidden sm:inline">{t.marketing.nav.home}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Language Switcher */}
|
||||||
|
<div className="relative" ref={langMenuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLangMenu(!showLangMenu)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg text-gray-700 text-sm font-medium transition-colors"
|
||||||
|
title="Switch Language"
|
||||||
|
>
|
||||||
|
<Languages size={18} />
|
||||||
|
<span className="hidden sm:inline">{language === 'en' ? 'English' : '简体中文'}</span>
|
||||||
|
<ChevronDown size={14} className={`transition-transform duration-200 ${showLangMenu ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showLangMenu && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-32 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
<button
|
||||||
|
onClick={() => { setLanguage('en'); setShowLangMenu(false); }}
|
||||||
|
className={`w-full flex items-center justify-between px-4 py-2 text-sm transition-colors hover:bg-gray-50 ${language === 'en' ? 'text-blue-600 font-medium' : 'text-gray-700'}`}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
{language === 'en' && <Check size={14} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setLanguage('zh'); setShowLangMenu(false); }}
|
||||||
|
className={`w-full flex items-center justify-between px-4 py-2 text-sm transition-colors hover:bg-gray-50 ${language === 'zh' ? 'text-blue-600 font-medium' : 'text-gray-700'}`}
|
||||||
|
>
|
||||||
|
简体中文
|
||||||
|
{language === 'zh' && <Check size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Guide Button */}
|
||||||
|
<button
|
||||||
|
id="guide-button"
|
||||||
|
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg text-gray-700 text-sm font-medium transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
window.dispatchEvent(new CustomEvent('start-user-guide'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HelpCircle size={18} />
|
||||||
|
<span className="hidden sm:inline">{t.common.guide}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Reward Button */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowReward(!showReward)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-rose-500 to-pink-500 hover:from-rose-600 hover:to-pink-600 rounded-lg text-white text-sm font-medium transition-all shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
<Heart size={14} className="fill-white" />
|
||||||
|
<span>{t.common.reward}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showReward && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[70] p-4"
|
||||||
|
onClick={() => setShowReward(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-xl shadow-xl max-w-sm w-full p-6 animate-in fade-in zoom-in-95 duration-200"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-lg font-bold text-gray-900">{t.navbar.rewardTitle}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowReward(false)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<img
|
||||||
|
src="https://cdn.texpixel.com/public/rewardcode.png"
|
||||||
|
alt={t.navbar.rewardTitle}
|
||||||
|
className="w-64 h-64 object-contain rounded-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-500 text-center mt-4">
|
||||||
|
{t.navbar.rewardThanks}<br />
|
||||||
|
<span className="text-xs text-gray-400 mt-1 block">{t.navbar.rewardSubtitle}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Button with Dropdown */}
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowContact(!showContact)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-gray-700 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<MessageCircle size={14} />
|
||||||
|
<span>{t.common.contactUs}</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={`transition-transform duration-200 ${showContact ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showContact && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-2 z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
<a
|
||||||
|
href="mailto:yogecoder@gmail.com"
|
||||||
|
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Mail size={16} className="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500">{t.common.email}</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">yogecoder@gmail.com</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-all cursor-pointer ${copied ? 'bg-green-50' : ''}`}
|
||||||
|
onClick={handleCopyQQ}
|
||||||
|
>
|
||||||
|
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${copied ? 'bg-green-500' : 'bg-green-100'}`}>
|
||||||
|
{copied ? (
|
||||||
|
<Check size={16} className="text-white" />
|
||||||
|
) : (
|
||||||
|
<Users size={16} className="text-green-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`text-xs transition-colors ${copied ? 'text-green-600 font-medium' : 'text-gray-500'}`}>
|
||||||
|
{copied ? t.common.copied : t.common.qqGroup}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">1018282100</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/layout/Footer.tsx
Normal file
47
src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="bg-gray-900 text-gray-400 py-12 px-6">
|
||||||
|
<div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<img src="/texpixel-app-icon.svg" alt="TexPixel" className="w-6 h-6" />
|
||||||
|
<span className="text-white font-bold">TexPixel</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">{t.marketing.footer.tagline}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-semibold mb-3 text-sm">{t.marketing.footer.product}</h4>
|
||||||
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
|
<Link to="/app" className="hover:text-white transition-colors">{t.marketing.nav.launchApp}</Link>
|
||||||
|
<a href="/#pricing" className="hover:text-white transition-colors">{t.marketing.nav.pricing}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-semibold mb-3 text-sm">{t.marketing.footer.resources}</h4>
|
||||||
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
|
<Link to="/docs" className="hover:text-white transition-colors">{t.marketing.nav.docs}</Link>
|
||||||
|
<Link to="/blog" className="hover:text-white transition-colors">{t.marketing.nav.blog}</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-semibold mb-3 text-sm">{t.marketing.footer.contactTitle}</h4>
|
||||||
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
|
<a href="mailto:yogecoder@gmail.com" className="hover:text-white transition-colors">yogecoder@gmail.com</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto mt-8 pt-8 border-t border-gray-800 text-sm text-center">
|
||||||
|
© {new Date().getFullYear()} TexPixel. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/layout/MarketingLayout.tsx
Normal file
15
src/components/layout/MarketingLayout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import MarketingNavbar from './MarketingNavbar';
|
||||||
|
import Footer from './Footer';
|
||||||
|
|
||||||
|
export default function MarketingLayout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<MarketingNavbar />
|
||||||
|
<main className="flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/components/layout/MarketingNavbar.tsx
Normal file
124
src/components/layout/MarketingNavbar.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { Languages, ChevronDown, Check, Menu, X } from 'lucide-react';
|
||||||
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
|
export default function MarketingNavbar() {
|
||||||
|
const { language, setLanguage, t } = useLanguage();
|
||||||
|
const location = useLocation();
|
||||||
|
const [showLangMenu, setShowLangMenu] = useState(false);
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const langMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isHome = location.pathname === '/';
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ to: '/', label: t.marketing.nav.home },
|
||||||
|
{ to: '/docs', label: t.marketing.nav.docs },
|
||||||
|
{ to: '/blog', label: t.marketing.nav.blog },
|
||||||
|
];
|
||||||
|
|
||||||
|
const anchorLinks = isHome
|
||||||
|
? [
|
||||||
|
{ href: '#pricing', label: t.marketing.nav.pricing },
|
||||||
|
{ href: '#contact', label: t.marketing.nav.contact },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (langMenuRef.current && !langMenuRef.current.contains(event.target as Node)) {
|
||||||
|
setShowLangMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 flex-shrink-0 z-[60] relative">
|
||||||
|
<Link to="/" className="flex items-center gap-2">
|
||||||
|
<img src="/texpixel-app-icon.svg" alt="TexPixel" className="w-8 h-8" />
|
||||||
|
<span className="text-xl font-bold text-gray-900 tracking-tight">TexPixel</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center gap-6">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.to}
|
||||||
|
to={link.to}
|
||||||
|
className={`text-sm font-medium transition-colors ${
|
||||||
|
location.pathname === link.to ? 'text-blue-600' : 'text-gray-700 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{anchorLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative" ref={langMenuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLangMenu(!showLangMenu)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg text-gray-700 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Languages size={18} />
|
||||||
|
<span className="hidden sm:inline">{language === 'en' ? 'EN' : '中文'}</span>
|
||||||
|
<ChevronDown size={14} className={`transition-transform duration-200 ${showLangMenu ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{showLangMenu && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-32 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-50">
|
||||||
|
{(['en', 'zh'] as const).map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang}
|
||||||
|
onClick={() => { setLanguage(lang); setShowLangMenu(false); }}
|
||||||
|
className={`w-full flex items-center justify-between px-4 py-2 text-sm transition-colors hover:bg-gray-50 ${language === lang ? 'text-blue-600 font-medium' : 'text-gray-700'}`}
|
||||||
|
>
|
||||||
|
{lang === 'en' ? 'English' : '简体中文'}
|
||||||
|
{language === lang && <Check size={14} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/app"
|
||||||
|
className="hidden sm:inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{t.marketing.nav.launchApp}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button className="md:hidden p-2" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
|
||||||
|
{mobileMenuOpen ? <X size={20} /> : <Menu size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="absolute top-16 left-0 right-0 bg-white border-b border-gray-200 shadow-lg md:hidden z-50 py-4 px-6 flex flex-col gap-3">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link key={link.to} to={link.to} className="text-sm font-medium text-gray-700 py-2" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{anchorLinks.map((link) => (
|
||||||
|
<a key={link.href} href={link.href} className="text-sm font-medium text-gray-700 py-2" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<Link to="/app" className="text-sm font-medium text-blue-600 py-2" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
{t.marketing.nav.launchApp}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user