feat(shell): redesigned sidebar per design-01 — 7D.1

This commit is contained in:
reloop
2026-04-12 04:09:26 +09:00
parent 9c3efeb464
commit d35fc087c6

View File

@@ -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};
}
`;