556 lines
22 KiB
TypeScript
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;
|