TASK-020 버그 수정:
- BUG-1: adminFetch Authorization 헤더 우선순위 수정 (JWT > API Key)
하네스 저장 실패 에러 핸들링 개선 (401/non-ok 명시적 처리)
- BUG-2: /admin/logs useEffect 초기 로드 추가 (마운트 시 자동 조회)
- BUG-3: costs/record python3 → node/python3/jq fallback 체인
TASK-021 JWT BE:
- User 모델 추가 (Prisma schema)
- AuthService: register(초대코드 검증+bcrypt) / login / refresh / getMe
- JwtStrategy (passport-jwt), JwtGuard, CompositeGuard (JWT+API Key)
- AuthController: /api/auth/{register,login,refresh,me}
- ThrottlerModule: 로그인 5회/분 Rate limiting
- CompositeGuard로 AdminController, CostsController Guard 전환
- EventsGateway: Socket.IO handshake JWT 검증 추가
- 테스트 26/26 pass
TASK-022 FE:
- AuthContext (AuthProvider + useAuth)
- AppShell (AuthProvider + AuthGate 라우트 보호)
- /login 페이지 (터미널 UI)
- /register 페이지 (초대 코드 필드)
- Sidebar: 로그인 사용자명 + 로그아웃 버튼
- FE 16 routes build 성공
202 lines
5.1 KiB
TypeScript
202 lines
5.1 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import styled from 'styled-components';
|
|
import Link from 'next/link';
|
|
import { usePathname } from 'next/navigation';
|
|
import { useAuth } from '@/lib/AuthContext';
|
|
import { useSidebar } from '@/lib/SidebarContext';
|
|
|
|
const NAV_ITEMS = [
|
|
{ href: '/', label: '대시' },
|
|
{ href: '/activities', label: '활동' },
|
|
{ href: '/projects', label: '프로' },
|
|
{ href: '/sisters', label: '자매' },
|
|
{ href: '/org', label: '조직' },
|
|
{ href: '/settings', label: '설정' },
|
|
{ href: '/admin', label: '관리' },
|
|
];
|
|
|
|
// ─── Desktop Sidebar ───
|
|
const SidebarWrapper = styled.aside`
|
|
width: 240px;
|
|
flex-shrink: 0;
|
|
border-right: 1px solid var(--border-color);
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: var(--space-xl) var(--space-lg);
|
|
position: sticky;
|
|
top: 0;
|
|
height: 100vh;
|
|
overflow-y: auto;
|
|
|
|
@media (min-width: 768px) and (max-width: 1199px) {
|
|
width: 64px;
|
|
padding: var(--space-xl) var(--space-md);
|
|
align-items: center;
|
|
}
|
|
|
|
@media (max-width: 767px) {
|
|
display: none;
|
|
}
|
|
`;
|
|
|
|
const LogoContainer = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
margin-bottom: var(--space-xxl);
|
|
|
|
@media (min-width: 768px) and (max-width: 1199px) {
|
|
margin-bottom: var(--space-xl);
|
|
}
|
|
`;
|
|
|
|
const CircleFull = styled.div`
|
|
width: 20px;
|
|
height: 20px;
|
|
background-color: var(--text-primary);
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
`;
|
|
|
|
const CircleHalf = styled.div`
|
|
width: 10px;
|
|
height: 20px;
|
|
background-color: var(--text-primary);
|
|
border-radius: 0 20px 20px 0;
|
|
flex-shrink: 0;
|
|
`;
|
|
|
|
const NavMenu = styled.ul`
|
|
list-style: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-sm);
|
|
width: 100%;
|
|
`;
|
|
|
|
const NavItem = styled.li``;
|
|
|
|
const NavLink = styled(Link)<{ $active: boolean }>`
|
|
color: ${({ $active }) => $active ? 'var(--accent-hover)' : 'var(--text-secondary)'};
|
|
text-decoration: none;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
padding: var(--space-sm) 0;
|
|
transition: color 0.2s ease;
|
|
display: block;
|
|
width: 100%;
|
|
|
|
&:hover {
|
|
color: var(--accent-hover);
|
|
}
|
|
|
|
${({ $active }) => $active && `
|
|
&::before { content: "["; margin-right: 4px; color: var(--text-secondary); }
|
|
&::after { content: "]"; margin-left: 4px; color: var(--text-secondary); }
|
|
`}
|
|
|
|
@media (min-width: 768px) and (max-width: 1199px) {
|
|
font-size: 11px;
|
|
text-align: center;
|
|
padding: 6px 0;
|
|
}
|
|
`;
|
|
|
|
// ─── Mobile Bottom Tab Bar ───
|
|
const BottomTabBar = styled.nav`
|
|
display: none;
|
|
|
|
@media (max-width: 767px) {
|
|
display: flex;
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 56px;
|
|
background: var(--bg-main);
|
|
border-top: 1px solid var(--border-color);
|
|
z-index: 100;
|
|
overflow-x: auto;
|
|
scrollbar-width: none;
|
|
&::-webkit-scrollbar { display: none; }
|
|
}
|
|
`;
|
|
|
|
const TabItem = styled(Link)<{ $active: boolean }>`
|
|
flex: 1;
|
|
min-width: 48px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
|
|
text-decoration: none;
|
|
padding: 6px 4px;
|
|
transition: color 0.15s;
|
|
border-top: ${({ $active }) => $active ? '1px solid var(--text-primary)' : '1px solid transparent'};
|
|
|
|
&:hover { color: var(--text-primary); }
|
|
`;
|
|
|
|
export default function Sidebar() {
|
|
const pathname = usePathname();
|
|
const { user, logout } = useAuth();
|
|
|
|
const isActive = (href: string) =>
|
|
href === '/' ? pathname === '/' : pathname.startsWith(href);
|
|
|
|
return (
|
|
<>
|
|
<SidebarWrapper>
|
|
<LogoContainer>
|
|
<CircleFull />
|
|
<CircleHalf />
|
|
</LogoContainer>
|
|
<nav>
|
|
<NavMenu>
|
|
{NAV_ITEMS.map((item) => (
|
|
<NavItem key={item.href}>
|
|
<NavLink href={item.href} $active={isActive(item.href)}>
|
|
{item.label}
|
|
</NavLink>
|
|
</NavItem>
|
|
))}
|
|
</NavMenu>
|
|
</nav>
|
|
{user && (
|
|
<div style={{ marginTop: 'auto', paddingTop: 'var(--space-xl)' }}>
|
|
<div style={{ fontSize: '11px', color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)', marginBottom: 'var(--space-sm)' }}>
|
|
[{user.username}]
|
|
</div>
|
|
<button
|
|
onClick={logout}
|
|
style={{
|
|
background: 'transparent', border: 'none', color: 'var(--text-secondary)',
|
|
fontSize: '13px', cursor: 'pointer', padding: 0, textAlign: 'left',
|
|
transition: 'color 0.15s', fontFamily: 'inherit',
|
|
}}
|
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
|
|
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
|
|
>
|
|
로그아웃
|
|
</button>
|
|
</div>
|
|
)}
|
|
</SidebarWrapper>
|
|
|
|
<BottomTabBar>
|
|
{NAV_ITEMS.map((item) => (
|
|
<TabItem key={item.href} href={item.href} $active={isActive(item.href)}>
|
|
{item.label}
|
|
</TabItem>
|
|
))}
|
|
</BottomTabBar>
|
|
</>
|
|
);
|
|
}
|