CASTAD-v0.1/components/Navbar.tsx

556 lines
22 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../src/contexts/AuthContext';
import { useLanguage } from '../src/contexts/LanguageContext';
import { useTheme, PALETTES, ColorPalette } from '../src/contexts/ThemeContext';
import {
LogOut,
User,
LayoutDashboard,
Menu,
Settings,
Globe,
ChevronDown,
Video,
Shield,
X,
Coins,
AlertCircle,
Sun,
Moon,
Palette,
Check
} from 'lucide-react';
import { CaStADLogo, CaStADLogoInline } from './CaStADLogo';
import { Language } from '../types';
import { cn } from '../src/lib/utils';
import { Button } from '../src/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../src/components/ui/dropdown-menu';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetClose,
} from '../src/components/ui/sheet';
import { Avatar, AvatarFallback } from '../src/components/ui/avatar';
import { Badge } from '../src/components/ui/badge';
import { Separator } from '../src/components/ui/separator';
const LANGUAGES = [
{ code: 'KO', label: '한국어', flag: 'kr' },
{ code: 'EN', label: 'English', flag: 'us' },
{ code: 'JP', label: '日本語', flag: 'jp' },
{ code: 'CN', label: '中文', flag: 'cn' },
{ code: 'TH', label: 'ไทย', flag: 'th' },
{ code: 'VN', label: 'Tiếng Việt', flag: 'vn' },
] as const;
const Navbar: React.FC = () => {
const { user, logout, token } = useAuth();
const { language, setLanguage, t } = useLanguage();
const { theme, palette, toggleTheme, setPalette } = useTheme();
const navigate = useNavigate();
const location = useLocation();
const [isScrolled, setIsScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [credits, setCredits] = useState<number | null>(null);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 10);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// 크레딧 조회
useEffect(() => {
const fetchCredits = async () => {
if (!user || !token) {
setCredits(null);
return;
}
try {
const backendPort = import.meta.env.VITE_BACKEND_PORT || '3001';
const res = await fetch(`http://localhost:${backendPort}/api/credits`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setCredits(data.credits);
}
} catch (err) {
console.error('Failed to fetch credits:', err);
}
};
fetchCredits();
// 주기적으로 업데이트 (30초마다)
const interval = setInterval(fetchCredits, 30000);
return () => clearInterval(interval);
}, [user, token]);
const handleLogout = () => {
logout();
navigate('/login');
setMobileOpen(false);
};
// Hide navbar during server rendering (autoplay mode)
const searchParams = new URLSearchParams(location.search);
if (searchParams.get('autoplay') === 'true') {
return null;
}
const currentLanguage = LANGUAGES.find(l => l.code === language) || LANGUAGES[0];
const NavLink = ({ to, children, icon: Icon }: { to: string; children: React.ReactNode; icon?: React.ElementType }) => {
const isActive = location.pathname === to;
return (
<Link
to={to}
onClick={() => setMobileOpen(false)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
{Icon && <Icon className="w-4 h-4" />}
{children}
</Link>
);
};
return (
<nav
className={cn(
"fixed top-0 left-0 right-0 z-50 transition-all duration-300",
isScrolled
? "bg-background/80 backdrop-blur-xl border-b border-border shadow-sm"
: "bg-background/50 backdrop-blur-md"
)}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link to="/" className="flex items-center group hover:opacity-90 transition-opacity">
<CaStADLogo size="sm" />
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-1">
<NavLink to="/" icon={Video}>
{t('menuCreate')}
</NavLink>
{user && (
<NavLink to="/dashboard" icon={LayoutDashboard}>
{t('menuLibrary')}
</NavLink>
)}
{user?.role === 'admin' && (
<NavLink to="/admin" icon={Shield}>
{t('menuAdmin')}
</NavLink>
)}
</div>
{/* Right Actions */}
<div className="flex items-center gap-2">
{/* Language Switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-muted-foreground hover:text-foreground"
>
<span className={`fi fi-${currentLanguage.flag} rounded-sm`} />
<span className="hidden sm:inline text-xs font-medium">
{currentLanguage.code}
</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t('settingLanguage') || 'Language'}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{LANGUAGES.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => setLanguage(lang.code as Language)}
className={cn(
"gap-2 cursor-pointer",
language === lang.code && "bg-accent"
)}
>
<span className={`fi fi-${lang.flag} rounded-sm`} />
<span className="text-sm">{lang.label}</span>
{language === lang.code && (
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5">
</Badge>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Theme Toggle */}
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="w-9 h-9 text-muted-foreground hover:text-foreground"
>
{theme === 'dark' ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
<span className="sr-only"> </span>
</Button>
{/* Palette Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-9 h-9 text-muted-foreground hover:text-foreground"
>
<Palette className="w-4 h-4" />
<span className="sr-only"> </span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel className="text-xs text-muted-foreground">
</DropdownMenuLabel>
<DropdownMenuSeparator />
{(Object.keys(PALETTES) as ColorPalette[]).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => setPalette(key)}
className="gap-2 cursor-pointer"
>
<div className={cn(
"w-5 h-5 rounded-full bg-gradient-to-r",
PALETTES[key].preview
)} />
<span className="text-sm">{PALETTES[key].name}</span>
{palette === key && (
<Check className="w-4 h-4 ml-auto text-primary" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* User Menu / Auth Buttons */}
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 pl-2">
<Avatar className="w-7 h-7">
<AvatarFallback className="bg-primary/10 text-primary text-xs font-semibold">
{user.name?.charAt(0).toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<span className="hidden sm:inline text-sm font-medium max-w-[100px] truncate">
{user.name}
</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">@{user.username}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* 크레딧 표시 */}
{user.role !== 'admin' && credits !== null && (
<>
<div className="px-2 py-2">
<div className={cn(
"flex items-center justify-between px-3 py-2 rounded-lg",
credits <= 0 ? "bg-destructive/10" : credits <= 3 ? "bg-yellow-500/10" : "bg-primary/10"
)}>
<div className="flex items-center gap-2">
<Coins className={cn(
"w-4 h-4",
credits <= 0 ? "text-destructive" : credits <= 3 ? "text-yellow-600" : "text-primary"
)} />
<span className="text-sm font-medium"></span>
</div>
<div className="flex items-center gap-1">
<span className={cn(
"text-lg font-bold",
credits <= 0 ? "text-destructive" : credits <= 3 ? "text-yellow-600" : "text-primary"
)}>
{credits}
</span>
<span className="text-xs text-muted-foreground"></span>
</div>
</div>
{credits <= 3 && (
<Link
to="/credits"
className="flex items-center gap-1.5 mt-2 text-xs text-muted-foreground hover:text-primary transition-colors"
>
<AlertCircle className="w-3 h-3" />
{credits <= 0 ? '크레딧이 부족합니다. 충전 요청하기' : '크레딧이 부족합니다'}
</Link>
)}
</div>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/dashboard" className="flex items-center gap-2">
<LayoutDashboard className="w-4 h-4" />
{t('menuLibrary')}
</Link>
</DropdownMenuItem>
{user.role === 'admin' && (
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/admin" className="flex items-center gap-2">
<Shield className="w-4 h-4" />
{t('menuAdmin')}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="w-4 h-4 mr-2" />
{t('menuLogout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="hidden md:flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to="/login">{t('menuLogin')}</Link>
</Button>
<Button size="sm" className="shadow-lg shadow-primary/20" asChild>
<Link to="/register">{t('menuStart')}</Link>
</Button>
</div>
)}
{/* Mobile Menu Button */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild className="md:hidden">
<Button variant="ghost" size="icon" className="shrink-0">
<Menu className="w-5 h-5" />
<span className="sr-only"> </span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px] sm:w-[350px]">
<SheetHeader className="text-left">
<SheetTitle>
<CaStADLogo size="sm" />
</SheetTitle>
</SheetHeader>
<div className="mt-8 flex flex-col gap-2">
{/* Mobile Navigation Links */}
<NavLink to="/" icon={Video}>
{t('menuCreate')}
</NavLink>
{user && (
<NavLink to="/dashboard" icon={LayoutDashboard}>
{t('menuLibrary')}
</NavLink>
)}
{user?.role === 'admin' && (
<NavLink to="/admin" icon={Shield}>
{t('menuAdmin')}
</NavLink>
)}
<Separator className="my-4" />
{/* Mobile User Section */}
{user ? (
<div className="space-y-4">
<div className="flex items-center gap-3 px-3 py-2">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-primary/10 text-primary font-semibold">
{user.name?.charAt(0).toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user.name}</p>
<p className="text-xs text-muted-foreground">@{user.username}</p>
</div>
{user.role === 'admin' && (
<Badge variant="secondary" className="text-[10px]">Admin</Badge>
)}
</div>
{/* 모바일 크레딧 표시 */}
{user.role !== 'admin' && credits !== null && (
<div className={cn(
"flex items-center justify-between px-3 py-3 rounded-lg mx-3",
credits <= 0 ? "bg-destructive/10" : credits <= 3 ? "bg-yellow-500/10" : "bg-primary/10"
)}>
<div className="flex items-center gap-2">
<Coins className={cn(
"w-5 h-5",
credits <= 0 ? "text-destructive" : credits <= 3 ? "text-yellow-600" : "text-primary"
)} />
<span className="font-medium"></span>
</div>
<div className="flex items-center gap-1">
<span className={cn(
"text-xl font-bold",
credits <= 0 ? "text-destructive" : credits <= 3 ? "text-yellow-600" : "text-primary"
)}>
{credits}
</span>
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
)}
{user.role !== 'admin' && credits !== null && credits <= 3 && (
<Link
to="/credits"
onClick={() => setMobileOpen(false)}
className="flex items-center justify-center gap-2 mx-3 px-3 py-2 rounded-lg bg-primary/10 text-primary text-sm font-medium hover:bg-primary/20 transition-colors"
>
<Coins className="w-4 h-4" />
</Link>
)}
<Button
variant="outline"
className="w-full justify-start gap-2 text-destructive hover:text-destructive"
onClick={handleLogout}
>
<LogOut className="w-4 h-4" />
{t('menuLogout')}
</Button>
</div>
) : (
<div className="flex flex-col gap-2">
<Button variant="outline" className="w-full" asChild>
<Link to="/login" onClick={() => setMobileOpen(false)}>
{t('menuLogin')}
</Link>
</Button>
<Button className="w-full" asChild>
<Link to="/register" onClick={() => setMobileOpen(false)}>
{t('menuStart')}
</Link>
</Button>
</div>
)}
<Separator className="my-4" />
{/* Mobile Theme Settings */}
<div className="px-3 space-y-4">
{/* Theme Toggle */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{theme === 'dark' ? '다크 모드' : '라이트 모드'}
</span>
<Button
variant="outline"
size="sm"
onClick={toggleTheme}
className="gap-2"
>
{theme === 'dark' ? (
<>
<Sun className="w-4 h-4" />
<span></span>
</>
) : (
<>
<Moon className="w-4 h-4" />
<span></span>
</>
)}
</Button>
</div>
{/* Palette Selection */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
</p>
<div className="flex gap-2">
{(Object.keys(PALETTES) as ColorPalette[]).map((key) => (
<button
key={key}
onClick={() => setPalette(key)}
className={cn(
"w-8 h-8 rounded-full bg-gradient-to-r transition-all",
PALETTES[key].preview,
palette === key
? "ring-2 ring-offset-2 ring-offset-background ring-primary scale-110"
: "hover:scale-105"
)}
title={PALETTES[key].name}
/>
))}
</div>
</div>
</div>
<Separator className="my-4" />
{/* Mobile Language Selection */}
<div className="px-3">
<p className="text-xs font-medium text-muted-foreground mb-2">
{t('settingLanguage') || 'Language'}
</p>
<div className="grid grid-cols-2 gap-2">
{LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => setLanguage(lang.code as Language)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors",
language === lang.code
? "bg-primary/10 text-primary border border-primary/20"
: "bg-accent/50 hover:bg-accent"
)}
>
<span className={`fi fi-${lang.flag} rounded-sm`} />
<span className="truncate">{lang.label}</span>
</button>
))}
</div>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;