feat: Thêm header điều hướng responsive, bộ chuyển đổi ngôn ngữ, thành phần UI chuyển đổi chủ đề và phím tắt chuyển đổi chủ đề.
This commit is contained in:
@@ -182,4 +182,66 @@
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EN: Responsive Layout Utilities
|
||||
VI: Utilities cho responsive layout
|
||||
============================================ */
|
||||
|
||||
/* Mobile-first container */
|
||||
.container-responsive {
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container-responsive {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container-responsive {
|
||||
max-width: 768px;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container-responsive {
|
||||
max-width: 1024px;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.container-responsive {
|
||||
max-width: 1280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-optimized text sizes */
|
||||
.text-responsive-hero {
|
||||
font-size: 2.5rem;
|
||||
/* 40px mobile */
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.text-responsive-hero {
|
||||
font-size: 3.75rem;
|
||||
/* 60px desktop */
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch-friendly buttons on mobile */
|
||||
.btn-touch {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
161
apps/web-client/src/components/layout/navigation-header.tsx
Normal file
161
apps/web-client/src/components/layout/navigation-header.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { BrandLogo, BrandLogoLink } from '../ui/brand-logo';
|
||||
import { ThemeToggle } from '../ui/theme-toggle-enhanced';
|
||||
import { LanguageSwitcher } from '../ui/language-switcher';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* EN: Navigation Header props
|
||||
* VI: Props cho Navigation Header
|
||||
*/
|
||||
export interface NavigationHeaderProps {
|
||||
/** Show CTA button / Hiển thị button CTA */
|
||||
showCTA?: boolean;
|
||||
/** CTA button text / Text button CTA */
|
||||
ctaText?: string;
|
||||
/** CTA button onClick / onClick button CTA */
|
||||
onCTAClick?: () => void;
|
||||
/** Additional CSS classes / CSS classes bổ sung */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Navigation Header - Responsive header with brand logo, theme toggle, and language switcher
|
||||
* VI: Navigation Header - Header responsive với logo, theme toggle và language switcher
|
||||
*
|
||||
* Features:
|
||||
* - Sticky header with glassmorphism
|
||||
* - Responsive design (mobile menu)
|
||||
* - Brand logo integration
|
||||
* - Theme toggle
|
||||
* - Language switcher
|
||||
* - CTA button
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <NavigationHeader
|
||||
* showCTA
|
||||
* ctaText="Get Started"
|
||||
* onCTAClick={() => router.push('/auth/login')}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function NavigationHeader({
|
||||
showCTA = true,
|
||||
ctaText = 'Get Started',
|
||||
onCTAClick,
|
||||
className,
|
||||
}: NavigationHeaderProps) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 z-50 w-full border-b border-border-primary',
|
||||
'bg-bg-primary/80 backdrop-blur-glass',
|
||||
'transition-all duration-normal',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="container mx-auto px-4">
|
||||
{/* EN: Main header content / VI: Nội dung header chính */}
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* EN: Logo / VI: Logo */}
|
||||
<BrandLogoLink variant="wordmark" size="sm" href="/" />
|
||||
|
||||
{/* EN: Desktop Navigation / VI: Navigation desktop */}
|
||||
<nav className="hidden md:flex items-center gap-4">
|
||||
<ThemeToggle />
|
||||
<LanguageSwitcher />
|
||||
{showCTA && (
|
||||
<Button
|
||||
variant="brand"
|
||||
size="md"
|
||||
onClick={onCTAClick}
|
||||
className="ml-2"
|
||||
>
|
||||
{ctaText}
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* EN: Mobile Menu Button / VI: Button menu mobile */}
|
||||
<button
|
||||
className="md:hidden p-2 hover:bg-bg-tertiary rounded-md transition-colors"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
aria-label="Toggle mobile menu"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="h-6 w-6" />
|
||||
) : (
|
||||
<Menu className="h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* EN: Mobile Menu / VI: Menu mobile */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="md:hidden py-4 space-y-4 border-t border-border-primary animate-fadeIn"
|
||||
role="navigation"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<ThemeToggle />
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
{showCTA && (
|
||||
<Button
|
||||
variant="brand"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
onCTAClick?.();
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{ctaText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Minimal Navigation Header - Simplified version without CTA
|
||||
* VI: Navigation Header tối giản - Phiên bản đơn giản không có CTA
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MinimalNavigationHeader />
|
||||
* ```
|
||||
*/
|
||||
export function MinimalNavigationHeader({ className }: { className?: string }) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 z-50 w-full border-b border-border-primary',
|
||||
'bg-bg-primary/80 backdrop-blur-glass',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-14 items-center justify-between">
|
||||
<BrandLogo variant="icon" size="sm" />
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
172
apps/web-client/src/components/ui/language-switcher.tsx
Normal file
172
apps/web-client/src/components/ui/language-switcher.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Button } from './button';
|
||||
import { DropdownMenu } from './dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* EN: Language configuration
|
||||
* VI: Cấu hình ngôn ngữ
|
||||
*/
|
||||
const languages = [
|
||||
{ code: 'en', label: 'English', flag: '🇺🇸', nativeName: 'English' },
|
||||
{ code: 'vi', label: 'Tiếng Việt', flag: '🇻🇳', nativeName: 'Tiếng Việt' },
|
||||
] as const;
|
||||
|
||||
export type LanguageCode = typeof languages[number]['code'];
|
||||
|
||||
/**
|
||||
* EN: Language Switcher - Multi-language support with flag icons
|
||||
* VI: Bộ chuyển ngôn ngữ - Hỗ trợ đa ngôn ngữ với flag icons
|
||||
*
|
||||
* Features:
|
||||
* - Flag emoji icons for visual identification
|
||||
* - Dropdown menu for language selection
|
||||
* - Persists locale to localStorage
|
||||
* - Updates URL locale parameter
|
||||
* - Smooth language switching
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LanguageSwitcher />
|
||||
* ```
|
||||
*/
|
||||
export function LanguageSwitcher() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// EN: Extract current locale from pathname
|
||||
// VI: Trích xuất locale hiện tại từ pathname
|
||||
const currentLocale = pathname.startsWith('/vi') ? 'vi' : 'en';
|
||||
|
||||
const currentLanguage = languages.find(lang => lang.code === currentLocale) || languages[0];
|
||||
|
||||
/**
|
||||
* EN: Handle language change
|
||||
* VI: Xử lý thay đổi ngôn ngữ
|
||||
*/
|
||||
const handleLanguageChange = (newLocale: LanguageCode) => {
|
||||
// EN: Save to localStorage
|
||||
// VI: Lưu vào localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('preferredLocale', newLocale);
|
||||
}
|
||||
|
||||
// EN: Update URL
|
||||
// VI: Cập nhật URL
|
||||
let newPathname = pathname;
|
||||
|
||||
// Remove existing locale prefix
|
||||
if (pathname.startsWith('/en') || pathname.startsWith('/vi')) {
|
||||
newPathname = pathname.substring(3) || '/';
|
||||
}
|
||||
|
||||
// Add new locale prefix
|
||||
if (newLocale !== 'en') {
|
||||
newPathname = `/${newLocale}${newPathname}`;
|
||||
}
|
||||
|
||||
router.push(newPathname);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="gap-2 min-w-[100px]"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<span className="text-lg" role="img" aria-label={currentLanguage.label}>
|
||||
{currentLanguage.flag}
|
||||
</span>
|
||||
<span className="hidden sm:inline">{currentLanguage.code.toUpperCase()}</span>
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
className="bg-glass-bg backdrop-blur-glass border border-glass-border min-w-[180px]"
|
||||
>
|
||||
{languages.map((language) => {
|
||||
const isActive = currentLocale === language.code;
|
||||
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
key={language.code}
|
||||
onClick={() => handleLanguageChange(language.code)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors',
|
||||
'hover:bg-bg-tertiary focus:bg-bg-tertiary',
|
||||
isActive && 'bg-brand-primary/10 text-brand-primary'
|
||||
)}
|
||||
>
|
||||
<span className="text-lg" role="img" aria-label={language.label}>
|
||||
{language.flag}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{language.nativeName}</span>
|
||||
<span className="text-xs text-text-tertiary">{language.label}</span>
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className="ml-auto text-brand-primary">✓</span>
|
||||
)}
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Compact Language Switcher - Shows only flags
|
||||
* VI: Language Switcher nhỏ gọn - Chỉ hiển thị cờ
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LanguageSwitcherCompact />
|
||||
* ```
|
||||
*/
|
||||
export function LanguageSwitcherCompact() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const currentLocale = pathname.startsWith('/vi') ? 'vi' : 'en';
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLocale = currentLocale === 'en' ? 'vi' : 'en';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('preferredLocale', newLocale);
|
||||
}
|
||||
|
||||
let newPathname = pathname;
|
||||
if (pathname.startsWith('/en') || pathname.startsWith('/vi')) {
|
||||
newPathname = pathname.substring(3) || '/';
|
||||
}
|
||||
if (newLocale !== 'en') {
|
||||
newPathname = `/${newLocale}${newPathname}`;
|
||||
}
|
||||
|
||||
router.push(newPathname);
|
||||
};
|
||||
|
||||
const currentLanguage = languages.find(lang => lang.code === currentLocale) || languages[0];
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="w-10 h-10 p-0 text-lg"
|
||||
onClick={toggleLanguage}
|
||||
aria-label={`Switch to ${currentLocale === 'en' ? 'Vietnamese' : 'English'}`}
|
||||
>
|
||||
<span role="img" aria-label={currentLanguage.label}>
|
||||
{currentLanguage.flag}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
106
apps/web-client/src/components/ui/theme-toggle-enhanced.tsx
Normal file
106
apps/web-client/src/components/ui/theme-toggle-enhanced.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useTheme } from '@/contexts/theme-context';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
import { Button } from './button';
|
||||
import { DropdownMenu } from './dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* EN: Enhanced Theme Toggle - Professional theme switcher with brand styling
|
||||
* VI: Theme Toggle nâng cao - Bộ chuyển theme chuyên nghiệp với brand styling
|
||||
*
|
||||
* Features:
|
||||
* - Dropdown menu with 3 options: Light, Dark, System
|
||||
* - Glassmorphism styling
|
||||
* - Icons from lucide-react
|
||||
* - Smooth transitions
|
||||
* - Keyboard accessible
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ThemeToggle />
|
||||
* ```
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'light' as const, label: 'Light', icon: Sun },
|
||||
{ value: 'dark' as const, label: 'Dark', icon: Moon },
|
||||
{ value: 'system' as const, label: 'System', icon: Monitor },
|
||||
];
|
||||
|
||||
const currentIcon = resolvedTheme === 'dark' ? Moon : Sun;
|
||||
const CurrentIcon = currentIcon;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="w-10 h-10 p-0"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<CurrentIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
className="bg-glass-bg backdrop-blur-glass border border-glass-border min-w-[150px]"
|
||||
>
|
||||
{themeOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isActive = theme === option.value;
|
||||
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
key={option.value}
|
||||
onClick={() => setTheme(option.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors',
|
||||
'hover:bg-bg-tertiary focus:bg-bg-tertiary',
|
||||
isActive && 'bg-brand-primary/10 text-brand-primary'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{option.label}</span>
|
||||
{isActive && (
|
||||
<span className="ml-auto text-brand-primary">✓</span>
|
||||
)}
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Simple Theme Toggle Button - Toggles between light and dark only
|
||||
* VI: Button Toggle Theme đơn giản - Chỉ chuyển đổi giữa light và dark
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ThemeToggleButton />
|
||||
* ```
|
||||
*/
|
||||
export function ThemeToggleButton() {
|
||||
const { toggleTheme, resolvedTheme } = useTheme();
|
||||
const Icon = resolvedTheme === 'dark' ? Sun : Moon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="w-10 h-10 p-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
<Icon className="h-5 w-5 transition-transform duration-normal hover:rotate-12" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ const getSystemTheme = (): 'light' | 'dark' => {
|
||||
*/
|
||||
const applyTheme = (theme: 'light' | 'dark') => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
@@ -61,7 +61,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeMode>('system');
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => {
|
||||
if (typeof window === 'undefined') return 'dark';
|
||||
|
||||
|
||||
// EN: Load from localStorage or default to system
|
||||
// VI: Load từ localStorage hoặc mặc định là system
|
||||
const stored = localStorage.getItem('theme') as ThemeMode | null;
|
||||
@@ -75,7 +75,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
// VI: Khởi tạo theme từ localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
const stored = localStorage.getItem('theme') as ThemeMode | null;
|
||||
if (stored && (stored === 'light' || stored === 'dark' || stored === 'system')) {
|
||||
setThemeState(stored);
|
||||
@@ -119,7 +119,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
*/
|
||||
const setTheme = useCallback((newTheme: ThemeMode) => {
|
||||
setThemeState(newTheme);
|
||||
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
}
|
||||
@@ -139,6 +139,22 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
setTheme(newTheme);
|
||||
}, [resolvedTheme, setTheme]);
|
||||
|
||||
// EN: Keyboard shortcut: Ctrl+Shift+T to toggle theme
|
||||
// VI: Phím tắt: Ctrl+Shift+T để chuyển theme
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
||||
e.preventDefault();
|
||||
toggleTheme();
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
return () => window.removeEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
}, [toggleTheme]);
|
||||
|
||||
const value: ThemeContextValue = {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
|
||||
Reference in New Issue
Block a user