From 2d9bf0a8e0691ece094bb2d6e3e56cc5da43dbdc Mon Sep 17 00:00:00 2001 From: leedano Date: Mon, 16 Mar 2026 10:34:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(design-system):=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=EC=B6=95,?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EA=B0=80=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN-SYSTEM.backup.md | 462 ++++++++++++++++++ docs/DESIGN-SYSTEM.md | 169 +++++++ frontend/src/App.tsx | 3 + .../src/common/components/layout/TopBar.tsx | 11 + frontend/src/common/components/ui/Badge.tsx | 17 + frontend/src/common/components/ui/Card.tsx | 21 + .../src/common/components/ui/DataTable.tsx | 94 ++++ frontend/src/common/components/ui/Modal.tsx | 73 +++ .../src/common/components/ui/Pagination.tsx | 123 +++++ .../src/common/components/ui/SidePanel.tsx | 45 ++ frontend/src/common/hooks/useSubMenu.ts | 19 +- frontend/src/common/styles/base.css | 16 + frontend/src/common/styles/components.css | 3 + frontend/src/common/styles/wing.css | 182 ++++++- frontend/src/common/types/navigation.ts | 2 +- .../aerial/components/MediaManagement.tsx | 8 +- .../assets/components/AssetManagement.tsx | 8 +- .../tabs/assets/components/AssetUpload.tsx | 4 +- .../tabs/assets/components/ShipInsurance.tsx | 4 +- .../tabs/board/components/BoardDetailView.tsx | 25 +- .../tabs/board/components/BoardListTable.tsx | 64 +-- .../src/tabs/board/components/BoardView.tsx | 148 ++---- .../tabs/board/components/BoardWriteForm.tsx | 14 +- .../src/tabs/hns/components/HNSLeftPanel.tsx | 46 +- .../tabs/hns/components/HNSRecalcModal.tsx | 10 +- .../tabs/hns/components/HNSScenarioView.tsx | 28 +- .../components/PredictionInputSection.tsx | 24 +- .../prediction/components/RecalcModal.tsx | 14 +- .../tabs/scat/components/ScatLeftPanel.tsx | 10 +- frontend/src/tabs/showcase/ShowcaseView.tsx | 207 ++++++++ frontend/src/tabs/showcase/index.ts | 1 + .../tabs/showcase/sections/BadgeSection.tsx | 23 + .../src/tabs/showcase/sections/Border.tsx | 48 ++ .../src/tabs/showcase/sections/BrandColor.tsx | 47 ++ .../src/tabs/showcase/sections/BrandLogo.tsx | 31 ++ .../tabs/showcase/sections/ButtonSection.tsx | 21 + .../tabs/showcase/sections/CardSection.tsx | 30 ++ .../showcase/sections/ComboBoxSection.tsx | 32 ++ .../showcase/sections/DataTableSection.tsx | 39 ++ frontend/src/tabs/showcase/sections/Icons.tsx | 31 ++ .../tabs/showcase/sections/InputSection.tsx | 53 ++ .../tabs/showcase/sections/ModalSection.tsx | 46 ++ .../showcase/sections/PaginationSection.tsx | 30 ++ .../showcase/sections/SidePanelSection.tsx | 67 +++ .../src/tabs/showcase/sections/Spacing.tsx | 25 + .../src/tabs/showcase/sections/Typography.tsx | 43 ++ frontend/tailwind.config.js | 7 + 47 files changed, 2175 insertions(+), 253 deletions(-) create mode 100644 docs/DESIGN-SYSTEM.backup.md create mode 100644 docs/DESIGN-SYSTEM.md create mode 100644 frontend/src/common/components/ui/Badge.tsx create mode 100644 frontend/src/common/components/ui/Card.tsx create mode 100644 frontend/src/common/components/ui/DataTable.tsx create mode 100644 frontend/src/common/components/ui/Modal.tsx create mode 100644 frontend/src/common/components/ui/Pagination.tsx create mode 100644 frontend/src/common/components/ui/SidePanel.tsx create mode 100644 frontend/src/tabs/showcase/ShowcaseView.tsx create mode 100644 frontend/src/tabs/showcase/index.ts create mode 100644 frontend/src/tabs/showcase/sections/BadgeSection.tsx create mode 100644 frontend/src/tabs/showcase/sections/Border.tsx create mode 100644 frontend/src/tabs/showcase/sections/BrandColor.tsx create mode 100644 frontend/src/tabs/showcase/sections/BrandLogo.tsx create mode 100644 frontend/src/tabs/showcase/sections/ButtonSection.tsx create mode 100644 frontend/src/tabs/showcase/sections/CardSection.tsx create mode 100644 frontend/src/tabs/showcase/sections/ComboBoxSection.tsx create mode 100644 frontend/src/tabs/showcase/sections/DataTableSection.tsx create mode 100644 frontend/src/tabs/showcase/sections/Icons.tsx create mode 100644 frontend/src/tabs/showcase/sections/InputSection.tsx create mode 100644 frontend/src/tabs/showcase/sections/ModalSection.tsx create mode 100644 frontend/src/tabs/showcase/sections/PaginationSection.tsx create mode 100644 frontend/src/tabs/showcase/sections/SidePanelSection.tsx create mode 100644 frontend/src/tabs/showcase/sections/Spacing.tsx create mode 100644 frontend/src/tabs/showcase/sections/Typography.tsx diff --git a/docs/DESIGN-SYSTEM.backup.md b/docs/DESIGN-SYSTEM.backup.md new file mode 100644 index 0000000..697a8ee --- /dev/null +++ b/docs/DESIGN-SYSTEM.backup.md @@ -0,0 +1,462 @@ +# WING-OPS 디자인 시스템 (백업) + +Tailwind CSS + @apply 기반 디자인 시스템. `wing-*` CSS 클래스와 React UI 컴포넌트로 구성. + +--- + +## 브랜드 (Brand) + +### 로고 + +해양 환경 위기대응을 위한 통합 솔루션 **WING**의 로고. + +- **파일**: `frontend/public/wing_logo_white.svg` +- **네이티브 크기**: 280 × 20px (비율 14:1) +- **색상**: 단색 흰색 (다크 배경 전용) + +#### 로고 규격 + +| 용도 | 높이 | Tailwind | 비고 | +|------|------|----------|------| +| Header | 14px | `h-3.5` | TopBar 52px 높이 내 사용 (현재) | +| Standard | 24px | `h-6` | 일반 UI, 문서 내 | +| Large | 32px | `h-8` | 로그인, 랜딩 화면 | +| **Minimum** | **14px** | `h-3.5` | 이보다 작게 사용 금지 | + +#### 여백 규칙 (Clear Space) +- 최소 여백: 로고 높이의 **50%** (상하좌우) +- 로고 주변에 다른 텍스트나 아이콘이 침범하지 않도록 유지 + +### 테마 컬러 + +다크 테마 기반. 게시판(Board) 메뉴에서 추출한 컬러 체계. + +#### 배경 (Background) — 딥 네이비 + +| 토큰 | CSS 변수 | HEX | 용도 | +|------|----------|-----|------| +| `bg-bg-0` | `--bg0` | `#0a0e1a` | 최하위 배경 (body, input) | +| `bg-bg-1` | `--bg1` | `#0f1524` | 패널, 모달, 푸터 배경 | +| `bg-bg-2` | `--bg2` | `#121929` | 테이블 헤더, elevated 영역 | +| `bg-bg-3` | `--bg3` | `#1a2236` | 카드, 보조 버튼, 비활성 요소 | +| `bg-hover` | `--bgH` | `#1e2844` | 행 hover | + +#### 텍스트 (Text) + +| 토큰 | CSS 변수 | HEX | 용도 | +|------|----------|-----|------| +| `text-text-1` | `--t1` | `#edf0f7` | 주 텍스트 (제목, 본문) | +| `text-text-2` | `--t2` | `#b0b8cc` | 보조 텍스트 (라벨, 설명) | +| `text-text-3` | `--t3` | `#8690a6` | 비활성/메타 (날짜, 조회수) | + +#### 브랜드 강조색 (Accent) + +| 토큰 | HEX | 용도 | +|------|-----|------| +| `primary-cyan` | `#06b6d4` | 주 강조색 — 활성 상태, 링크, CTA | +| `primary-blue` | `#3b82f6` | 보조 강조색 — 그라데이션 끝점 | +| **Primary Gradient** | `linear-gradient(135deg, #06b6d4, #3b82f6)` | Primary 버튼 | + +#### 시맨틱 (Semantic) + +| 토큰 | HEX | 용도 | +|------|-----|------| +| `red` / `danger` | `#ef4444` | 삭제, 필수 표시(*) | +| `orange` | `#f97316` | 경고 | +| `yellow` | `#eab308` | 주의 | +| `green` | `#22c55e` | 성공, 정상 | +| `purple` | `#a855f7` | 특수 강조 | + +#### 테두리 (Border) + +| 토큰 | CSS 변수 | HEX | 용도 | +|------|----------|-----|------| +| `border` | `--bd` | `#1e2a42` | 기본 구분선 | +| `border-light` | `--bdL` | `#2a3a5c` | 밝은 테두리 | + +#### 오버레이 (Overlay) + +| 토큰 | 값 | 용도 | +|------|-----|------| +| `overlay` | `rgba(0, 0, 0, 0.55)` | 모달 배경 오버레이 | + +--- + +## 디자인 원칙 + +### 컬러 사용 규칙 +- **상태 표현** (위험/정상/주의) → 컬러 배지 사용 (red, green, yellow) +- **단순 분류** (공지사항, 자료실, Q&A) → 기본 텍스트 컬러 또는 neutral 배지 +- **강조** (고정글, 선택 항목) → accent 컬러 (cyan) +- **원칙**: 색상은 정보를 전달할 때만 사용. 장식 목적 금지. + +--- + +## 1. 토큰 + +### 컬러 팔레트 + +| CSS 변수 | 값 | 용도 | +|----------|-----|------| +| `--bg0` | `#0a0e1a` | 최하위 배경 (body, input) | +| `--bg1` | `#0f1524` | 기본 패널 배경 | +| `--bg2` | `#121929` | 테이블 헤더, elevated | +| `--bg3` | `#1a2236` | 카드, 섹션 배경 | +| `--bgH` | `#1e2844` | hover 상태 | +| `--bd` | `#1e2a42` | 기본 테두리 | +| `--bdL` | `#2a3a5c` | 밝은 테두리 | +| `--t1` | `#edf0f7` | 기본 텍스트 (밝음) | +| `--t2` | `#b0b8cc` | 보조 텍스트 | +| `--t3` | `#8690a6` | 비활성/메타 텍스트 | +| `--cyan` | `#06b6d4` | Primary accent | +| `--blue` | `#3b82f6` | Secondary accent | +| `--purple` | `#a855f7` | 특수 강조 | +| `--red` | `#ef4444` | 위험/삭제 | +| `--orange` | `#f97316` | 경고 | +| `--yellow` | `#eab308` | 주의 | +| `--green` | `#22c55e` | 성공/정상 | + +### Z-Index 스케일 + +| 변수 | 값 | 용도 | +|------|-----|------| +| `--z-dropdown` | 100 | 드롭다운, 콤보박스 | +| `--z-sticky` | 200 | sticky 헤더 | +| `--z-overlay` | 1000 | 오버레이 | +| `--z-modal` | 10000 | 모달 | +| `--z-toast` | 10100 | 토스트 알림 | + +### 패널 너비 + +| 변수 | 값 | 용도 | +|------|-----|------| +| `--panel-narrow` | 280px | 좁은 사이드패널 | +| `--panel-default` | 300px | 기본 사이드패널 | +| `--panel-wide` | 340px | 넓은 사이드패널 | + +### 타이포그래피 (Tailwind) + +| 클래스 | 크기 | 용도 | +|--------|------|------| +| `text-wing-meta` | 9px | 메타 텍스트, 날짜, 부가 정보 | +| `text-wing-caption` | 10px | 캡션, 설명, 라벨 부연 | +| `text-wing-body` | 11px | 본문, 라벨, 값 (가장 많이 사용) | +| `text-wing-heading` | 13px | 섹션 헤더 | +| `text-wing-title` | 15px | 페이지/모달 타이틀 | + +--- + +## 2. CSS 클래스 (`wing-*`) + +모든 클래스는 `frontend/src/common/styles/wing.css`에 정의. + +### Layout + +| 클래스 | 설명 | +|--------|------| +| `wing-panel` | flex column, full height, overflow hidden | +| `wing-panel-scroll` | flex-1, overflow-y-auto, thin scrollbar | +| `wing-panel-right` | 우측 사이드패널 (border-l, 300px) | +| `wing-panel-left` | 좌측 사이드패널 (border-r, 300px) | +| `wing-header-bar` | 헤더 바 (flex between, border-b, px-5) | +| `wing-sidebar` | 사이드바 (flex col, border-r, bg1) | + +### Card / Section + +| 클래스 | 설명 | +|--------|------| +| `wing-card` | 카드 (rounded-md, p-4, border, bg3) | +| `wing-card-sm` | 작은 카드 (rounded-sm, p-3) | +| `wing-section` | 섹션 (rounded-md, p-4, mb-3) | +| `wing-section-header` | 섹션 제목 (13px bold) | +| `wing-section-desc` | 섹션 설명 (10px, t3 색상) | + +### Typography + +| 클래스 | 설명 | +|--------|------| +| `wing-title` | 15px bold korean | +| `wing-subtitle` | 10px korean, t3 색상 | +| `wing-label` | 11px semibold korean | +| `wing-value` | 11px semibold mono | +| `wing-meta` | 9px korean, t3 색상 | + +### Button + +| 클래스 | 설명 | +|--------|------| +| `wing-btn` | 기본 버튼 (px-3, py-1.5, 11px) | +| `wing-btn-primary` | cyan→blue gradient, white | +| `wing-btn-secondary` | bg3, border, t2 색상 | +| `wing-btn-outline` | transparent, border | +| `wing-btn-pdf` | blue 테마 | +| `wing-btn-danger` | red 테마 | + +### Input / Select / Textarea + +| 클래스 | 설명 | +|--------|------| +| `wing-input` | 기본 입력 (w-full, 11px, cyan focus) | +| `wing-select` | 셀렉트 (커스텀 화살표 포함) | +| `wing-textarea` | 텍스트영역 (resize-vertical, min-h 80px) | +| `wing-input-search` | 검색 입력 (256px 고정) | + +### Table + +| 클래스 | 설명 | +|--------|------| +| `wing-table` | 테이블 (w-full, 10px, collapse) | +| `wing-table-head` | 헤더 셀 (bg2, t3, bold) | +| `wing-table-cell` | 데이터 셀 (t2, border-b) | +| `wing-table-row` | 행 hover (bgH, cursor-pointer) | + +### Badge + +| 클래스 | 설명 | +|--------|------| +| `wing-badge` | 기본 배지 (inline-flex, 9px bold) | +| `wing-badge-neutral` | 회색 (단순 분류용 기본값) | +| `wing-badge-red` | 위험/삭제 | +| `wing-badge-blue` | 정보 | +| `wing-badge-green` | 성공/정상 | +| `wing-badge-yellow` | 주의 | +| `wing-badge-purple` | 특수 | +| `wing-badge-cyan` | 주요 | + +### Modal + +| 클래스 | 설명 | +|--------|------| +| `wing-overlay` | 오버레이 (fixed, blur, z-modal) | +| `wing-modal` | 모달 컨테이너 (rounded-xl, bg1) | +| `wing-modal-header` | 모달 헤더 (flex between, border-b) | +| `wing-modal-body` | 모달 본문 (flex-1, scroll) | +| `wing-modal-footer` | 모달 푸터 (flex end, border-t) | +| `wing-modal-sm` | 400px | +| `wing-modal-md` | 560px | +| `wing-modal-lg` | 720px | + +### Tab + +| 클래스 | 설명 | +|--------|------| +| `wing-tab-bar` | 탭 바 (flex, rounded-lg, border) | +| `wing-tab` | 탭 아이템 (flex-1, text-center) | +| `wing-tab.active` | 활성 탭 (cyan border/bg/text) | + +### Utility + +| 클래스 | 설명 | +|--------|------| +| `wing-divider` | 구분선 (1px, full width) | +| `wing-info-row` | 키-값 행 (flex between) | +| `wing-info-label` | 키 라벨 (10px, t3) | +| `wing-info-value` | 값 (11px, semibold mono) | + +--- + +## 3. React 컴포넌트 + +위치: `frontend/src/common/components/ui/` + +### Modal + +```tsx +import Modal from '@common/components/ui/Modal'; + + setIsOpen(false)} + title="제목" + size="md" + footer={ + <> + + + + } +> +

모달 내용

+
+``` + +| Prop | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `isOpen` | `boolean` | 필수 | 표시 여부 | +| `onClose` | `() => void` | 필수 | 닫기 콜백 | +| `title` | `string` | 필수 | 헤더 제목 | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 너비 (400/560/720px) | +| `children` | `ReactNode` | 필수 | 본문 | +| `footer` | `ReactNode` | - | 하단 버튼 영역 | +| `closeOnBackdrop` | `boolean` | `true` | 배경 클릭 닫기 | + +### Pagination + +```tsx +import Pagination from '@common/components/ui/Pagination'; + + +``` + +| Prop | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `currentPage` | `number` | 필수 | 현재 페이지 (1-based) | +| `totalPages` | `number` | 필수 | 전체 페이지 수 | +| `onPageChange` | `(page: number) => void` | 필수 | 페이지 변경 콜백 | +| `showFirstLast` | `boolean` | `true` | 처음/끝 버튼 표시 | + +### DataTable + +```tsx +import DataTable, { Column } from '@common/components/ui/DataTable'; + +interface Post { + id: number; + title: string; + author: string; + createdAt: string; +} + +const columns: Column[] = [ + { key: 'id', label: '번호', width: '60px', align: 'center' }, + { key: 'title', label: '제목' }, + { key: 'author', label: '작성자', width: '100px' }, + { key: 'createdAt', label: '작성일', width: '120px', + render: (val) => new Date(val as string).toLocaleDateString() }, +]; + + navigate(`/board/${post.id}`)} +/> +``` + +| Prop | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `columns` | `Column[]` | 필수 | 컬럼 정의 | +| `data` | `T[]` | 필수 | 데이터 배열 | +| `onRowClick` | `(row: T) => void` | - | 행 클릭 콜백 | +| `stickyHeader` | `boolean` | `true` | 헤더 고정 | +| `emptyMessage` | `string` | `'데이터가 없습니다.'` | 빈 상태 메시지 | + +### SidePanel + +```tsx +import SidePanel from '@common/components/ui/SidePanel'; + +상세 정보} + footer={ + + } +> +
+
+ 상태 + 정상 +
+
+
+``` + +| Prop | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `position` | `'left' \| 'right'` | 필수 | 배치 방향 | +| `width` | `'narrow' \| 'default' \| 'wide'` | `'default'` | 너비 (280/300/340px) | +| `header` | `ReactNode` | - | 헤더 영역 | +| `footer` | `ReactNode` | - | 하단 영역 | + +### Badge + +```tsx +import Badge from '@common/components/ui/Badge'; + +정상 {/* 상태 표현 */} +위험 {/* 상태 표현 */} +공지사항 {/* 단순 분류 → neutral */} +자료실 {/* 단순 분류 → neutral */} +``` + +| Prop | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `color` | `'red' \| 'blue' \| 'green' \| 'yellow' \| 'purple' \| 'cyan' \| 'neutral'` | `'neutral'` | 배지 색상 | + +--- + +## 4. 적용 예시: Before → After + +### Before (raw Tailwind 복붙) +```tsx +{/* 검색 */} + + +{/* 테이블 */} + + + + + + + + + + + +
제목
{item.title}
+ +{/* 배지 — 의미 없는 컬러 분화 */} + + 공지사항 + + +{/* 페이지네이션 — 직접 구현 */} + + +``` + +### After (디자인 시스템) +```tsx +{/* 검색 */} + + +{/* 테이블 — React 컴포넌트 */} + + +{/* 배지 — neutral로 통일 */} +공지사항 +자료실 +진행중 {/* 상태만 컬러 */} + +{/* 페이지네이션 — React 컴포넌트 */} + +``` + +--- + +## 5. 마이그레이션 가이드 + +### 작업 순서 +1. 파일 내 raw Tailwind 문자열을 `wing-*` 클래스로 교체 +2. 반복되는 행동 패턴 (모달, 페이지네이션, 테이블)을 React 컴포넌트로 교체 +3. 의미 없는 컬러 배지를 `` (neutral)로 교체 +4. 브라우저에서 시각적 동일성 확인 + +### 판단 기준 +``` +순수 시각 + 5회 이상 반복 → wing-* CSS 클래스 +동작 포함 + 5회 이상 반복 → React 컴포넌트 +1-2회 사용 → 인라인 Tailwind 유지 +도메인 전용 시각 → components.css (유지) +``` diff --git a/docs/DESIGN-SYSTEM.md b/docs/DESIGN-SYSTEM.md new file mode 100644 index 0000000..bc5a53b --- /dev/null +++ b/docs/DESIGN-SYSTEM.md @@ -0,0 +1,169 @@ +# WING 디자인 시스템 + +해양 환경 위기대응 통합 솔루션 **WING**의 디자인 시스템. + +--- + +## 브랜드 (Brand) + +### 로고 + +해양에서 발생하는 상황을 종합적으로 지원하는 솔루션 WING의 로고. + +- **파일**: `frontend/public/wing_logo_white.svg` +- **네이티브 크기**: 280 × 20px (비율 14:1) +- **색상**: 단색 흰색 (다크 배경 전용) + +#### 로고 규격 + +| 용도 | 높이 | Tailwind | 비고 | +|------|------|----------|------| +| Header | 14px | `h-3.5` | TopBar 52px 높이 내 사용 (현재) | +| Standard | 24px | `h-6` | 일반 UI, 문서 내 | +| Large | 32px | `h-8` | 로그인, 랜딩 화면 | +| **Minimum** | **14px** | `h-3.5` | 이보다 작게 사용 금지 | + +#### 여백 규칙 (Clear Space) + +- 최소 여백: 로고 높이의 **50%** (상하좌우) +- 로고 주변에 다른 텍스트나 아이콘이 침범하지 않도록 유지 + +--- + +## 파운데이션 (Foundations) + +### 브랜드 컬러 (Brand Color) + +현재 다크 테마만 구현되어 있으며, 라이트 팔레트는 향후 전환용으로 정의해 둔다. + +> **현재 상태**: 다크 테마 단일 고정 (테마 전환 인프라 미구축) + +#### 배경 (Background) + +| CSS 변수 | Dark | Light | 용도 | +|----------|------|-------|------| +| `--bg0` | `#0a0e1a` | `#ffffff` | 최하위 배경 (body, input) | +| `--bg1` | `#0f1524` | `#f8f9fb` | 패널, 모달, 푸터 배경 | +| `--bg2` | `#121929` | `#f0f2f5` | 테이블 헤더, elevated 영역 | +| `--bg3` | `#1a2236` | `#e8ebf0` | 카드, 보조 버튼, 비활성 요소 | +| `--bgH` | `#1e2844` | `#dde1e8` | 행 hover | + +#### 텍스트 (Text) + +| CSS 변수 | Dark | Light | 용도 | +|----------|------|-------|------| +| `--t1` | `#edf0f7` | `#1a1d26` | 주 텍스트 (제목, 본문) | +| `--t2` | `#b0b8cc` | `#4a5568` | 보조 텍스트 (라벨, 설명) | +| `--t3` | `#8690a6` | `#8690a6` | 비활성/메타 — 양 테마 공유 | + +#### Accent Color + +| CSS 변수 | Dark | Light | 용도 | +|----------|------|-------|------| +| `--cyan` | `#06b6d4` | `#0891b2` | 주 강조색 — 활성 상태, 링크, CTA | +| `--blue` | `#3b82f6` | `#2563eb` | 보조 강조색 — 그라데이션 끝점 | +| **Gradient** | `linear-gradient(135deg, #06b6d4, #3b82f6)` | `linear-gradient(135deg, #0891b2, #2563eb)` | Primary 버튼 | + +> 라이트 테마에서 강조색을 한 단계 진하게 적용하여 흰 배경 위 가독성을 확보한다. + +> **보조 컬러 검토**: 필요에 따라 시맨틱 컬러(red `#ef4444`, orange `#f97316`, yellow `#eab308`, green `#22c55e`, purple `#a855f7`)를 보조 컬러로 추가 검토 예정. + +--- + +### 타이포그래피 (Typography) + +#### 폰트 패밀리 + +| 토큰 | CSS 변수 | 폰트 | 용도 | +|------|----------|------|------| +| `font-sans` | — | Outfit, Noto Sans KR, sans-serif | 기본 UI (body) | +| `font-korean` | `--fK` | Noto Sans KR, sans-serif | 한글 강조 | +| `font-mono` | `--fM` | JetBrains Mono, monospace | 수치, 코드 | + +#### 폰트 스케일 + +| 클래스 | 크기 | Line Height | Weight | 용도 | +|--------|------|-------------|--------|------| +| `text-wing-meta` | 9px | 1.4 | 400 | 메타 텍스트, 날짜, 부가 정보 | +| `text-wing-caption` | 10px | 1.4 | 400 | 캡션, 설명, 라벨 부연 | +| `text-wing-body` | 11px | 1.5 | 400 | 본문, 라벨, 값 (가장 많이 사용) | +| `text-wing-heading` | 13px | 1.4 | 700 | 섹션 헤더 | +| `text-wing-title` | 15px | 1.3 | 700 | 페이지/모달 타이틀 | + +> 11px(`text-wing-body`)이 기본 본문 크기. 정보 밀도가 높은 운영 시스템 특성에 맞춘 compact 설계. + +--- + +### 아이콘 (Icons) + +- **라이브러리**: `lucide-react` (^0.564.0) +- **스타일**: Stroke 기반, 일관된 2px 선 두께 + +| 크기 | px | 용도 | +|------|-----|------| +| Small | 16px | 인라인 텍스트, 버튼 내부 | +| Default | 18px | 일반 UI 아이콘 | +| Large | 20px | 강조, 헤더 영역 | + +```tsx +import { Search, ChevronDown, X } from 'lucide-react'; + + {/* 인라인 */} + {/* 일반 */} + {/* 강조 */} +``` + +--- + +### 간격 (Spacing) + +4px 그리드 기반. Tailwind 유틸리티 클래스 사용. + +| 토큰 | 값 | Tailwind | 주요 사용처 | +|------|-----|----------|------------| +| `spacing-1` | 4px | `p-1`, `gap-1` | 최소 간격, 아이콘-텍스트 | +| `spacing-1.5` | 6px | `py-1.5`, `gap-1.5` | 인풋 수직 패딩, 버튼 수직 패딩 | +| `spacing-2` | 8px | `p-2`, `gap-2` | 테이블 셀, 버튼 그룹 간격 | +| `spacing-3` | 12px | `p-3`, `gap-3` | 소형 카드 패딩 | +| `spacing-4` | 16px | `p-4`, `gap-4` | 카드/섹션 패딩 | +| `spacing-5` | 20px | `px-5` | 헤더/모달 수평 패딩 | + +--- + +### 테두리 (Border) + +#### 색상 + +| CSS 변수 | Dark | Light | 용도 | +|----------|------|-------|------| +| `--bd` | `#1e2a42` | `#d0d5dd` | 기본 구분선 | +| `--bdL` | `#2a3a5c` | `#e0e4ea` | 밝은 테두리 | + +#### Radius + +| 토큰 | 값 | Tailwind | 용도 | +|------|-----|----------|------| +| `radius-xs` | 4px | `rounded` | 배지, 최소 요소 | +| `radius-sm` | 6px | `rounded-sm` | 버튼, 인풋, 소형 카드 (가장 많이 사용) | +| `radius-md` | 10px | `rounded-md` | 카드, 섹션 | +| `radius-lg` | 12px | `rounded-xl` | 모달 | + +> `rounded-sm`(6px)이 프로젝트 전반에서 지배적인 기본 반경값. + +--- + +### 단위 체계 (Units) + +> **현재 상태**: px 기반 단일 체계 +> **향후 방향**: rem 전환 검토 (접근성/반응성 고려) + +| 영역 | 현재 단위 | 비고 | +|------|-----------|------| +| font-size | px | `9px`~`15px`, Tailwind arbitrary `text-[11px]` | +| spacing/padding | px | raw CSS `6px 10px` 등 | +| layout 치수 | px | 패널 280~340px, 모달 400~720px | +| border-radius | px | `6px`, `10px`, `12px` | +| html font-size | 미설정 | rem 기준점 없음 (브라우저 기본 16px) | + +- Tailwind 기본 유틸리티(`px-5`, `text-sm` 등)는 내부적으로 rem을 사용하나, 이는 의도적 설계가 아닌 Tailwind 부산물 +- rem 전환 시 로드맵: `html { font-size }` 기준 설정 → Tailwind config rem 토큰 → wing.css 점진적 전환 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1f148c4..eff6783 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { IncidentsView } from '@tabs/incidents' import { AdminView } from '@tabs/admin' import { ScatView } from '@tabs/scat' import { RescueView } from '@tabs/rescue' +import { ShowcaseView } from '@tabs/showcase' const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '' @@ -97,6 +98,8 @@ function App() { return case 'rescue': return + case 'showcase': + return default: return
준비 중입니다...
} diff --git a/frontend/src/common/components/layout/TopBar.tsx b/frontend/src/common/components/layout/TopBar.tsx index 5695c75..002ea91 100755 --- a/frontend/src/common/components/layout/TopBar.tsx +++ b/frontend/src/common/components/layout/TopBar.tsx @@ -88,6 +88,17 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { + {hasPermission('admin') && ( + +
{children}
+ {footer &&
{footer}
} + + + ); +}; + +export default Modal; diff --git a/frontend/src/common/components/ui/Pagination.tsx b/frontend/src/common/components/ui/Pagination.tsx new file mode 100644 index 0000000..e8efa19 --- /dev/null +++ b/frontend/src/common/components/ui/Pagination.tsx @@ -0,0 +1,123 @@ +import { cn } from '@common/utils/cn'; + +interface PaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + showFirstLast?: boolean; + className?: string; +} + +const getPageNumbers = (currentPage: number, totalPages: number): (number | '...')[] => { + if (totalPages <= 5) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + const pages: (number | '...')[] = []; + const delta = 2; + const left = currentPage - delta; + const right = currentPage + delta; + + if (left > 1) { + pages.push(1); + if (left > 2) pages.push('...'); + } + + for (let i = Math.max(1, left); i <= Math.min(totalPages, right); i++) { + pages.push(i); + } + + if (right < totalPages) { + if (right < totalPages - 1) pages.push('...'); + pages.push(totalPages); + } + + return pages; +}; + +const Pagination = ({ + currentPage, + totalPages, + onPageChange, + showFirstLast = true, + className, +}: PaginationProps) => { + if (totalPages <= 1) return null; + + const isFirst = currentPage === 1; + const isLast = currentPage === totalPages; + const pageNumbers = getPageNumbers(currentPage, totalPages); + + const navBtnClass = (disabled: boolean) => + cn( + 'wing-btn wing-btn-secondary', + disabled && 'opacity-40 cursor-not-allowed', + ); + + return ( +
+ {showFirstLast && ( + + )} + + + {pageNumbers.map((page, idx) => + page === '...' ? ( + + … + + ) : ( + + ), + )} + + + {showFirstLast && ( + + )} +
+ ); +}; + +export default Pagination; diff --git a/frontend/src/common/components/ui/SidePanel.tsx b/frontend/src/common/components/ui/SidePanel.tsx new file mode 100644 index 0000000..c39cf11 --- /dev/null +++ b/frontend/src/common/components/ui/SidePanel.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { cn } from '@common/utils/cn'; + +interface SidePanelProps { + position: 'left' | 'right'; + width?: 'narrow' | 'default' | 'wide'; + header?: React.ReactNode; + footer?: React.ReactNode; + children: React.ReactNode; + className?: string; +} + +const WIDTH_MAP: Record, string> = { + narrow: 'var(--panel-narrow)', + default: 'var(--panel-default)', + wide: 'var(--panel-wide)', +}; + +const SidePanel = ({ + position, + width = 'default', + header, + footer, + children, + className, +}: SidePanelProps) => { + const panelClass = position === 'left' ? 'wing-panel-left' : 'wing-panel-right'; + + const style: React.CSSProperties = + width !== 'default' ? { width: WIDTH_MAP[width] } : {}; + + return ( +
+ {header &&
{header}
} +
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+ ); +}; + +export default SidePanel; diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index 34e8ed6..b4d204c 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useEffect, useSyncExternalStore, useCallback } from 'react' import type { MainTab } from '../types/navigation' import { useAuthStore } from '@common/store/authStore' import { API_BASE_URL } from '@common/services/api' @@ -91,17 +91,11 @@ function subscribe(listener: () => void) { } export function useSubMenu(mainTab: MainTab) { - const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab]) + const getSnapshot = useCallback(() => subMenuState[mainTab], [mainTab]) + const activeSubTab = useSyncExternalStore(subscribe, getSnapshot) const isAuthenticated = useAuthStore((s) => s.isAuthenticated) const hasPermission = useAuthStore((s) => s.hasPermission) - useEffect(() => { - const unsubscribe = subscribe(() => { - setActiveSubTabLocal(subMenuState[mainTab]) - }) - return unsubscribe - }, [mainTab]) - const setActiveSubTab = (subTab: string) => { setSubTab(mainTab, subTab) } @@ -112,6 +106,13 @@ export function useSubMenu(mainTab: MainTab) { hasPermission(`${mainTab}:${item.id}`) ) ?? null + // activeSubTab이 필터링된 목록에 없으면 첫 번째 항목 자동 선택 + useEffect(() => { + if (filteredConfig && filteredConfig.length > 0 && !filteredConfig.some(item => item.id === activeSubTab)) { + setSubTab(mainTab, filteredConfig[0].id) + } + }, [filteredConfig, activeSubTab, mainTab]) + // 서브탭 전환 시 자동 감사 로그 (N-depth 지원: 콜론 구분 경로) useEffect(() => { if (!isAuthenticated || !activeSubTab) return diff --git a/frontend/src/common/styles/base.css b/frontend/src/common/styles/base.css index aefe5f5..dc01259 100644 --- a/frontend/src/common/styles/base.css +++ b/frontend/src/common/styles/base.css @@ -23,6 +23,22 @@ --fM: JetBrains Mono, monospace; --rS: 6px; --rM: 8px; + + /* ── Z-index 스케일 ── */ + --z-dropdown: 100; + --z-sticky: 200; + --z-overlay: 1000; + --z-modal: 10000; + --z-toast: 10100; + + /* ── Panel Width ── */ + --panel-narrow: 280px; + --panel-default: 300px; + --panel-wide: 340px; + + /* ── Transition ── */ + --transition-fast: 0.15s ease; + --transition-normal: 0.2s ease; } * { diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 213da42..93f5e53 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -24,6 +24,7 @@ } /* ═══ Prediction Input Form ═══ */ + /* @deprecated: wing-input 사용 권장 */ .prd-i { width: 100%; padding: 6px 10px; @@ -45,6 +46,7 @@ } /* Select Dropdown */ + /* @deprecated: wing-select 사용 권장 */ select.prd-i { cursor: pointer; padding-right: 30px; @@ -219,6 +221,7 @@ } /* ═══ Buttons ═══ */ + /* @deprecated: wing-btn + wing-btn-primary/secondary 사용 권장 */ .prd-btn { width: 100%; padding: 10px; diff --git a/frontend/src/common/styles/wing.css b/frontend/src/common/styles/wing.css index 41d933a..d1407f3 100644 --- a/frontend/src/common/styles/wing.css +++ b/frontend/src/common/styles/wing.css @@ -28,6 +28,11 @@ .wing-card { @apply rounded-md p-4 border border-border; background: var(--bg3); + transition: border-color var(--transition-fast); + } + + .wing-card-hover:hover { + border-color: rgba(6, 182, 212, 0.4); } .wing-card-sm { @@ -83,7 +88,42 @@ /* ── Badge ── */ .wing-badge { - @apply inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold font-korean; + @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-bold font-korean; + } + + .wing-badge-neutral { + background: rgba(134, 144, 166, 0.15); + color: var(--t2); + } + + .wing-badge-red { + background: rgba(239, 68, 68, 0.15); + color: var(--red); + } + + .wing-badge-blue { + background: rgba(59, 130, 246, 0.15); + color: var(--blue); + } + + .wing-badge-green { + background: rgba(34, 197, 94, 0.15); + color: var(--green); + } + + .wing-badge-yellow { + background: rgba(234, 179, 8, 0.15); + color: var(--yellow); + } + + .wing-badge-purple { + background: rgba(168, 85, 247, 0.15); + color: var(--purple); + } + + .wing-badge-cyan { + background: rgba(6, 182, 212, 0.15); + color: var(--cyan); } /* ── Button ── */ @@ -151,11 +191,11 @@ /* ── Table ── */ .wing-table { - @apply w-full text-[10px] font-korean; + @apply w-full text-sm font-korean; border-collapse: collapse; } - .wing-th { + .wing-table-head { @apply text-left font-semibold; padding: 8px 10px; color: var(--t3); @@ -163,17 +203,26 @@ border-bottom: 1px solid var(--bd); } - .wing-td { + .wing-table-cell { padding: 8px 10px; color: var(--t2); border-bottom: 1px solid var(--bd); } - .wing-tr-hover:hover { + .wing-table-row { + transition: background 0.15s; + } + + .wing-table-row:hover { background: var(--bgH); cursor: pointer; } + /* 기존 alias (하위 호환) */ + .wing-th { @apply text-left font-semibold; padding: 8px 10px; color: var(--t3); background: var(--bg2); border-bottom: 1px solid var(--bd); } + .wing-td { padding: 8px 10px; color: var(--t2); border-bottom: 1px solid var(--bd); } + .wing-tr-hover:hover { background: var(--bgH); cursor: pointer; } + /* ── Tab Bar ── */ .wing-tab-bar { @apply flex gap-0.5 rounded-lg p-1 border border-border; @@ -201,7 +250,7 @@ /* ── Modal ── */ .wing-overlay { @apply fixed inset-0 flex items-center justify-center; - z-index: 10000; + z-index: var(--z-modal); background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); } @@ -219,6 +268,127 @@ padding-bottom: 14px; } + /* ── Select ── */ + .wing-select { + @apply w-full rounded-sm text-[11px] font-korean outline-none; + padding: 6px 10px; + padding-right: 30px; + background: var(--bg0); + border: 1px solid var(--bd); + color: var(--t1); + cursor: pointer; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 10px; + transition: border-color 0.2s; + } + + .wing-select:hover { + border-color: rgba(255, 255, 255, 0.2); + } + + .wing-select:focus { + border-color: var(--cyan); + } + + .wing-select option { + background: #1a1f2e; + color: var(--t1); + padding: 10px; + font-size: 11px; + font-family: 'Noto Sans KR', sans-serif; + } + + /* ── Textarea ── */ + .wing-textarea { + @apply w-full rounded-sm text-[11px] font-korean outline-none; + padding: 8px 10px; + background: var(--bg0); + border: 1px solid var(--bd); + color: var(--t1); + resize: vertical; + min-height: 80px; + } + + .wing-textarea:focus { + border-color: var(--cyan); + } + + .wing-textarea::placeholder { + color: var(--t3); + } + + /* ── Search Input ── */ + .wing-input-search { + @apply rounded-sm text-[11px] font-korean outline-none; + width: 256px; + padding: 6px 10px; + background: var(--bg0); + border: 1px solid var(--bd); + color: var(--t1); + } + + .wing-input-search:focus { + border-color: var(--cyan); + } + + .wing-input-search::placeholder { + color: var(--t3); + } + + + /* ── Side Panel ── */ + .wing-panel-right { + @apply flex flex-col h-full overflow-hidden; + width: var(--panel-default); + border-left: 1px solid var(--bd); + background: var(--bg1); + } + + .wing-panel-left { + @apply flex flex-col h-full overflow-hidden; + width: var(--panel-default); + border-right: 1px solid var(--bd); + background: var(--bg1); + } + + /* ── Modal Size Variants ── */ + .wing-modal-sm { width: 400px; max-height: 90vh; } + .wing-modal-md { width: 560px; max-height: 90vh; } + .wing-modal-lg { width: 720px; max-height: 90vh; } + + /* ── Modal Body / Footer ── */ + .wing-modal-body { + @apply flex-1 overflow-y-auto px-5 py-4; + scrollbar-width: thin; + scrollbar-color: var(--bdL) transparent; + } + + .wing-modal-footer { + @apply flex justify-end gap-2 px-5 border-t border-border; + padding-top: 12px; + padding-bottom: 12px; + } + + /* ── Info Row (wing-kv alias) ── */ + .wing-info-row { + @apply flex items-center justify-between; + padding: 6px 0; + } + + .wing-info-label { + @apply text-[10px] font-korean; + color: var(--t3); + } + + .wing-info-value { + @apply text-[11px] font-semibold font-mono; + } + /* ── Utility ── */ .wing-divider { @apply w-full; diff --git a/frontend/src/common/types/navigation.ts b/frontend/src/common/types/navigation.ts index a3c3c57..b8d5db8 100644 --- a/frontend/src/common/types/navigation.ts +++ b/frontend/src/common/types/navigation.ts @@ -1 +1 @@ -export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin'; +export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin' | 'showcase'; diff --git a/frontend/src/tabs/aerial/components/MediaManagement.tsx b/frontend/src/tabs/aerial/components/MediaManagement.tsx index 90a41f9..3f72bcd 100644 --- a/frontend/src/tabs/aerial/components/MediaManagement.tsx +++ b/frontend/src/tabs/aerial/components/MediaManagement.tsx @@ -184,7 +184,7 @@ export function MediaManagement() { + +