Sprint 001 non-blocking 해결: - N6: npm audit high 패키지 업데이트 (@nestjs/config, @nestjs/cli) - S1: CORS origin 화이트리스트 (CORS_ORIGINS env) - S3: DATABASE_URL 미설정 시 즉시 throw - N2: SSH_KEY_PATH 미설정 시 즉시 throw (하드코딩 fallback 제거) - N3: API 응답에서 내부 IP 제거 - N7: SshService 에러 메시지 마스킹 (IP 노출 차단) - N4: theme.ts 색상 SisterCard/StatusBadge에 실제 적용 - N5: Sidebar-MainContent margin 연동 (SidebarContext) - N9: LayoutShell Client 컴포넌트로 분리 Sprint 002 본문: - TASK-004: GiteaService (org repo 목록, PR 조회) - TASK-004: GET /api/projects → Gitea + DB 병합 - TASK-004: GET /api/projects/:id → 상세 + open PRs - TASK-005: GET /api/projects/:id/tasks → Sprint/Task Ledger - TASK-005: POST /api/projects/:id/sprints → Sprint 생성 - TASK-005: PATCH /api/tasks/:id → Task 상태 업데이트 + ActivityLog - TASK-006: GET /api/activity, /api/projects/:id/activity - TASK-007: /projects/[id] 상세 페이지 (SprintAccordion + TaskTable + 활동탭) - TASK-007: 메인 대시보드에 프로젝트 섹션 + 활동 피드 추가 - 테스트 11/11 pass
144 lines
3.4 KiB
TypeScript
144 lines
3.4 KiB
TypeScript
'use client';
|
||
|
||
import React from 'react';
|
||
import styled from 'styled-components';
|
||
import Link from 'next/link';
|
||
import { usePathname } from 'next/navigation';
|
||
import { useSidebar } from '@/lib/SidebarContext';
|
||
|
||
const MENU_ITEMS = [
|
||
{ href: '/', icon: '⬛', label: '대시보드' },
|
||
{ href: '/projects', icon: '📁', label: '프로젝트' },
|
||
{ href: '/sisters', icon: '🦊', label: '자매' },
|
||
{ href: '/org', icon: '🏢', label: '조직도' },
|
||
{ href: '/settings', icon: '⚙️', label: '설정' },
|
||
];
|
||
|
||
const ADMIN_ITEMS = [
|
||
{ href: '/admin', icon: '🔧', label: '관리자' },
|
||
];
|
||
|
||
const Wrapper = styled.aside<{ $collapsed: boolean }>`
|
||
width: ${({ $collapsed }) => ($collapsed ? '60px' : '220px')};
|
||
height: 100vh;
|
||
background: rgba(13, 17, 23, 0.95);
|
||
backdrop-filter: blur(10px);
|
||
border-right: 1px solid rgba(240, 246, 252, 0.1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: fixed;
|
||
left: 0;
|
||
top: 0;
|
||
z-index: 100;
|
||
transition: width 0.2s ease;
|
||
overflow: hidden;
|
||
`;
|
||
|
||
const LogoArea = styled.div`
|
||
padding: 20px 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
border-bottom: 1px solid rgba(240, 246, 252, 0.08);
|
||
min-height: 64px;
|
||
`;
|
||
|
||
const LogoText = styled.span`
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #E6EDF3;
|
||
white-space: nowrap;
|
||
`;
|
||
|
||
const CollapseBtn = styled.button`
|
||
margin-left: auto;
|
||
background: none;
|
||
border: none;
|
||
color: #8B949E;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
padding: 4px;
|
||
border-radius: 4px;
|
||
flex-shrink: 0;
|
||
|
||
&:hover { color: #E6EDF3; background: rgba(240, 246, 252, 0.06); }
|
||
`;
|
||
|
||
const Nav = styled.nav`
|
||
flex: 1;
|
||
padding: 12px 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
`;
|
||
|
||
const NavItem = styled(Link)<{ $active: boolean }>`
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 10px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
color: ${({ $active }) => ($active ? '#E6EDF3' : '#8B949E')};
|
||
background: ${({ $active }) => ($active ? 'rgba(88, 166, 255, 0.12)' : 'transparent')};
|
||
transition: background 0.15s, color 0.15s;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
|
||
&:hover {
|
||
background: rgba(240, 246, 252, 0.06);
|
||
color: #E6EDF3;
|
||
}
|
||
`;
|
||
|
||
const NavIcon = styled.span`
|
||
font-size: 16px;
|
||
flex-shrink: 0;
|
||
`;
|
||
|
||
const Divider = styled.div`
|
||
height: 1px;
|
||
background: rgba(240, 246, 252, 0.08);
|
||
margin: 8px 8px;
|
||
`;
|
||
|
||
const AdminSection = styled.div`
|
||
padding: 8px;
|
||
`;
|
||
|
||
export default function Sidebar() {
|
||
const { collapsed, setCollapsed } = useSidebar();
|
||
const pathname = usePathname();
|
||
|
||
return (
|
||
<Wrapper $collapsed={collapsed}>
|
||
<LogoArea>
|
||
<span style={{ fontSize: '22px', flexShrink: 0 }}>🦊</span>
|
||
{!collapsed && <LogoText>하나랑</LogoText>}
|
||
<CollapseBtn onClick={() => setCollapsed(!collapsed)}>
|
||
{collapsed ? '›' : '‹'}
|
||
</CollapseBtn>
|
||
</LogoArea>
|
||
|
||
<Nav>
|
||
{MENU_ITEMS.map((item) => (
|
||
<NavItem key={item.href} href={item.href} $active={pathname === item.href}>
|
||
<NavIcon>{item.icon}</NavIcon>
|
||
{!collapsed && item.label}
|
||
</NavItem>
|
||
))}
|
||
</Nav>
|
||
|
||
<Divider />
|
||
<AdminSection>
|
||
{ADMIN_ITEMS.map((item) => (
|
||
<NavItem key={item.href} href={item.href} $active={pathname === item.href}>
|
||
<NavIcon>{item.icon}</NavIcon>
|
||
{!collapsed && item.label}
|
||
</NavItem>
|
||
))}
|
||
</AdminSection>
|
||
</Wrapper>
|
||
);
|
||
}
|