feat(shell): redesigned sidebar per design-01 — 7D.1
This commit is contained in:
@@ -1,196 +1,404 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { PERSONA_META } from '@/lib/constants';
|
||||
import { clearToken } from '@/lib/auth';
|
||||
import type { MeUser } from '@/lib/api';
|
||||
import type { MeUser, SubscriptionTier } from '@/lib/api';
|
||||
import { Icon, type IconName } from '@/components/ui/Icon';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
const NAV = [
|
||||
{ href: '/dashboard', label: '대시보드', icon: '🏠' },
|
||||
{ href: '/review', label: '복습', icon: '📖' },
|
||||
{ href: '/study', label: '학습 기록', icon: '✏️' },
|
||||
{ href: '/subjects', label: '과목', icon: '📚' },
|
||||
{ href: '/stats', label: '통계', icon: '📊' },
|
||||
{ href: '/profile', label: '프로필', icon: '👤' },
|
||||
const NAV_ITEMS: Array<{
|
||||
href: string;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
match: (pathname: string) => boolean;
|
||||
showBadge?: boolean;
|
||||
}> = [
|
||||
{
|
||||
href: '/dashboard',
|
||||
label: '대시보드',
|
||||
icon: 'squares-four',
|
||||
match: (pathname) => pathname.startsWith('/dashboard'),
|
||||
},
|
||||
{
|
||||
href: '/study/history',
|
||||
label: '학습 기록',
|
||||
icon: 'book-open',
|
||||
match: (pathname) => pathname.startsWith('/study/history'),
|
||||
},
|
||||
{
|
||||
href: '/review',
|
||||
label: '복습',
|
||||
icon: 'arrows-clockwise',
|
||||
match: (pathname) => pathname.startsWith('/review'),
|
||||
showBadge: true,
|
||||
},
|
||||
{
|
||||
href: '/stats',
|
||||
label: '통계',
|
||||
icon: 'trend-up',
|
||||
match: (pathname) => pathname.startsWith('/stats'),
|
||||
},
|
||||
{
|
||||
href: '/subjects',
|
||||
label: '과목 및 태그',
|
||||
icon: 'folders',
|
||||
match: (pathname) => pathname.startsWith('/subjects'),
|
||||
},
|
||||
];
|
||||
|
||||
export default function SideNav({ user }: { user: MeUser }) {
|
||||
const SUBSCRIPTION_LABELS: Record<SubscriptionTier, string> = {
|
||||
free: 'Free Plan',
|
||||
pro: 'Pro Plan',
|
||||
school: 'School Plan',
|
||||
};
|
||||
|
||||
interface SideNavProps {
|
||||
user: MeUser;
|
||||
reviewCount?: number;
|
||||
}
|
||||
|
||||
export default function SideNav({ user, reviewCount }: SideNavProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
clearToken();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
const personaMeta = PERSONA_META[user.persona];
|
||||
const initial = user.nickname.trim().charAt(0).toUpperCase() || 'U';
|
||||
|
||||
return (
|
||||
<Aside>
|
||||
<Brand>
|
||||
<Logo>ReLoop</Logo>
|
||||
<BrandSub>적응형 복습</BrandSub>
|
||||
</Brand>
|
||||
<TopSection>
|
||||
<Brand href="/dashboard">
|
||||
<LogoMark>
|
||||
<Icon name="infinity" weight="bold" size={18} color="#ffffff" />
|
||||
</LogoMark>
|
||||
<BrandText>ReLoop</BrandText>
|
||||
</Brand>
|
||||
|
||||
<UserCard>
|
||||
<PersonaEmoji $color={personaMeta.color}>{personaMeta.emoji}</PersonaEmoji>
|
||||
<UserInfo>
|
||||
<Nickname>{user.nickname}</Nickname>
|
||||
<UserMeta>
|
||||
{personaMeta.label} ·{' '}
|
||||
{user.currentGrade ? `${user.currentGrade}등급` : '미설정'}
|
||||
</UserMeta>
|
||||
</UserInfo>
|
||||
</UserCard>
|
||||
<NavList>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = item.match(pathname);
|
||||
const shouldShowBadge =
|
||||
item.showBadge && typeof reviewCount === 'number' && reviewCount > 0;
|
||||
|
||||
<NavList>
|
||||
{NAV.map((item) => (
|
||||
<NavItem
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
$active={pathname.startsWith(item.href)}
|
||||
>
|
||||
<Icon>{item.icon}</Icon>
|
||||
<span>{item.label}</span>
|
||||
</NavItem>
|
||||
))}
|
||||
</NavList>
|
||||
return (
|
||||
<NavItem key={item.href} href={item.href} $active={isActive}>
|
||||
<NavLabel>
|
||||
<NavIconWrap $active={isActive}>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
weight="regular"
|
||||
size={18}
|
||||
color={isActive ? theme.color.brandIndigo : 'currentColor'}
|
||||
/>
|
||||
</NavIconWrap>
|
||||
<span>{item.label}</span>
|
||||
</NavLabel>
|
||||
{shouldShowBadge ? <ReviewBadge>{reviewCount}</ReviewBadge> : null}
|
||||
</NavItem>
|
||||
);
|
||||
})}
|
||||
</NavList>
|
||||
</TopSection>
|
||||
|
||||
<Bottom>
|
||||
<LogoutBtn onClick={handleLogout}>로그아웃</LogoutBtn>
|
||||
</Bottom>
|
||||
<UserSection ref={menuRef}>
|
||||
<UserButton type="button" onClick={() => setMenuOpen((prev) => !prev)}>
|
||||
<Avatar>{initial}</Avatar>
|
||||
<UserInfo>
|
||||
<UserName>{user.nickname}</UserName>
|
||||
<PlanLabel>{SUBSCRIPTION_LABELS[user.subscriptionTier]}</PlanLabel>
|
||||
</UserInfo>
|
||||
<CaretWrap $open={menuOpen}>
|
||||
<Icon name="caret-up" size={10} weight="regular" />
|
||||
</CaretWrap>
|
||||
</UserButton>
|
||||
|
||||
{menuOpen ? (
|
||||
<Dropdown>
|
||||
<LogoutButton type="button" onClick={handleLogout}>
|
||||
로그아웃
|
||||
</LogoutButton>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
</UserSection>
|
||||
</Aside>
|
||||
);
|
||||
}
|
||||
|
||||
const Aside = styled.aside`
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
padding: ${theme.space.xl} ${theme.space.lg};
|
||||
border-right: 1px solid ${theme.color.border};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
width: 256px;
|
||||
min-height: 100vh;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(15, 15, 20, 0.5);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 16rem;
|
||||
width: 100%;
|
||||
background: radial-gradient(
|
||||
circle at top left,
|
||||
rgba(79, 70, 229, 0.16) 0%,
|
||||
rgba(79, 70, 229, 0.06) 35%,
|
||||
transparent 72%
|
||||
);
|
||||
content: '';
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Brand = styled.div``;
|
||||
|
||||
const Logo = styled.h1`
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: ${theme.color.textMain};
|
||||
letter-spacing: -0.02em;
|
||||
const TopSection = styled.div`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 24px;
|
||||
`;
|
||||
|
||||
const BrandSub = styled.span`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textMute};
|
||||
font-family: ${theme.font.mono};
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const UserCard = styled.div`
|
||||
display: flex;
|
||||
const Brand = styled(Link)`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.md};
|
||||
padding: ${theme.space.md};
|
||||
background: ${theme.color.surface};
|
||||
border: 1px solid ${theme.color.border};
|
||||
border-radius: ${theme.radius.md};
|
||||
gap: 8px;
|
||||
margin-bottom: 40px;
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const PersonaEmoji = styled.div<{ $color: string }>`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
const LogoMark = styled.div`
|
||||
display: inline-flex;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${({ $color }) => $color}22;
|
||||
border: 1px solid ${({ $color }) => $color}66;
|
||||
border-radius: ${theme.radius.md};
|
||||
border-radius: 10px;
|
||||
background: ${theme.color.brandGradient};
|
||||
box-shadow: ${theme.shadow.glowIndigo};
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
${Brand}:hover & {
|
||||
box-shadow: ${theme.shadow.glowIndigoStrong};
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
`;
|
||||
|
||||
const BrandText = styled.span`
|
||||
font-family: ${theme.font.display};
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const UserInfo = styled.div`
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Nickname = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textMain};
|
||||
`;
|
||||
|
||||
const UserMeta = styled.div`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
`;
|
||||
|
||||
const NavList = styled.nav`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const NavItem = styled(Link)<{ $active: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.sm};
|
||||
padding: 10px 14px;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border: 1px solid
|
||||
${({ $active }) => ($active ? 'rgba(255, 255, 255, 0.05)' : 'transparent')};
|
||||
border-radius: ${theme.radius.md};
|
||||
background: ${({ $active }) =>
|
||||
$active ? 'rgba(255, 255, 255, 0.05)' : 'transparent'};
|
||||
padding: 10px 14px;
|
||||
color: ${({ $active }) =>
|
||||
$active ? theme.color.textBright : 'rgba(255, 255, 255, 0.5)'};
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${({ $active }) => ($active ? theme.color.textMain : theme.color.textSub)};
|
||||
background: ${({ $active }) => ($active ? theme.color.surface : 'transparent')};
|
||||
border: 1px solid ${({ $active }) => ($active ? theme.color.border : 'transparent')};
|
||||
transition: color 0.15s, background 0.15s;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.color.textMain};
|
||||
background: ${theme.color.surface};
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: ${theme.color.textBright};
|
||||
}
|
||||
`;
|
||||
|
||||
const Icon = styled.span`
|
||||
font-size: 18px;
|
||||
width: 22px;
|
||||
const NavLabel = styled.span`
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const NavIconWrap = styled.span<{ $active: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
color: ${({ $active }) =>
|
||||
$active ? theme.color.brandIndigo : 'rgba(255, 255, 255, 0.5)'};
|
||||
transition: color 0.15s ease;
|
||||
|
||||
const Bottom = styled.div`
|
||||
margin-top: auto;
|
||||
`;
|
||||
|
||||
const LogoutBtn = styled.button`
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
border: 1px solid ${theme.color.border};
|
||||
border-radius: ${theme.radius.md};
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.color.danger};
|
||||
border-color: ${theme.color.danger};
|
||||
${NavItem}:hover & {
|
||||
color: ${({ $active }) =>
|
||||
$active ? theme.color.brandIndigo : 'rgba(255, 255, 255, 0.85)'};
|
||||
}
|
||||
`;
|
||||
|
||||
const ReviewBadge = styled.span`
|
||||
margin-left: auto;
|
||||
border-radius: ${theme.radius.pill};
|
||||
background: rgba(79, 70, 229, 0.2);
|
||||
padding: 2px 8px;
|
||||
color: #a5b4fc;
|
||||
font-family: ${theme.font.mono};
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const UserSection = styled.div`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-top: 1px solid ${theme.color.borderSoftAlpha};
|
||||
padding: 24px;
|
||||
`;
|
||||
|
||||
const UserButton = styled.button`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 0;
|
||||
border-radius: ${theme.radius.md};
|
||||
background: transparent;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
color: ${theme.color.textBright};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
`;
|
||||
|
||||
const Avatar = styled.div`
|
||||
display: inline-flex;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
border-radius: 999px;
|
||||
background: ${theme.color.surfaceDeep};
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const UserInfo = styled.div`
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const UserName = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const PlanLabel = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
font-family: ${theme.font.mono};
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const CaretWrap = styled.span<{ $open: boolean }>`
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
transform: ${({ $open }) => ($open ? 'rotate(0deg)' : 'rotate(180deg)')};
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
${UserButton}:hover & {
|
||||
color: rgba(255, 255, 255, 0.54);
|
||||
}
|
||||
`;
|
||||
|
||||
const Dropdown = styled.div`
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: calc(100% - 8px);
|
||||
min-width: 120px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
border-radius: ${theme.radius.md};
|
||||
background: rgba(21, 21, 28, 0.96);
|
||||
padding: 8px;
|
||||
box-shadow: ${theme.shadow.cardElevated};
|
||||
backdrop-filter: blur(18px);
|
||||
`;
|
||||
|
||||
const LogoutButton = styled.button`
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
padding: 8px 10px;
|
||||
color: rgba(239, 68, 68, 0.86);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: ${theme.color.danger};
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user