Merge feature/redesign-v2: 미니멀 터미널 UI 리디자인

This commit is contained in:
nabomhalang
2026-04-04 04:13:42 +00:00
50 changed files with 5850 additions and 1401 deletions

View File

@@ -0,0 +1,150 @@
# 하나랑 대시보드 — 디자인 시스템 v2
## 디자인 철학
미니멀 터미널 UI. 장식 최소화, 정보 밀도 최대화.
glassmorphism 제거 → 1px 보더 카드 + 모노스페이스 라벨.
---
## 색상 (Color Tokens)
| Token | Value | 용도 |
|-------|-------|------|
| `--bg-main` | `#151515` | 전체 배경 |
| `--bg-surface` | `#151515` | 카드/패널 배경 (배경과 동일, 보더로 구분) |
| `--bg-input` | `#1a1a1a` | 인풋/검색 배경 |
| `--bg-code` | `#0d0d0d` | 로그 테이블/코드 블록 배경 |
| `--text-primary` | `#F5F5F5` | 주 텍스트 |
| `--text-secondary` | `#8A8A8A` | 보조 텍스트, 라벨 |
| `--border-color` | `#333333` | 카드 보더, 구분선 |
| `--border-hover` | `#555555` | hover 시 보더 |
| `--accent-hover` | `#FFFFFF` | 네비 active, 강조 |
### 상태 색상 (조직도 전용)
| Token | Value | 용도 |
|-------|-------|------|
| `--level-high` | `#f43f5e` | 최상위/긴급 |
| `--level-mid` | `#3b82f6` | 중간/R&D |
| `--level-low` | `#10b981` | 하위/운영 |
| `--level-none` | `#525252` | 비활성 |
### 로그 태그 색상
| Tag | Color |
|-----|-------|
| SYSTEM | `#888` |
| SECURITY | `#ff5f5f` |
| ORG | `#5fafff` |
| BACKUP | `#5fff8a` |
### 상태 표시 색상
| Status | Color |
|--------|-------|
| PASS / 성공 | `#00FF00` |
| ACTIVE dot | `#FFF` + `box-shadow: 0 0 8px rgba(255,255,255,0.3)` |
| INACTIVE dot | `#555` |
---
## 타이포그래피
| 용도 | Font | Size | Weight |
|------|------|------|--------|
| 본문 | `--font-sans` | 14px | 400 |
| 페이지 제목 | `--font-sans` | 28px | 600 |
| 섹션 제목 | `--font-sans` | 14px | 600 |
| 라벨 (label-meta) | `--font-sans` | 11px | 700, uppercase |
| 브라켓 값 | `--font-sans` | 32px | 600 |
| 타임스탬프 | `--font-mono` | 11px | 400 |
| 로그 텍스트 | `--font-mono` | 12px | 400 |
| 브라켓 인풋 | `--font-mono` | 14px | 400 |
| 뱃지 | `--font-mono` | 9px | 400, uppercase |
**폰트 스택:**
- `--font-sans`: `-apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif`
- `--font-mono`: `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace`
---
## 스페이싱
| Token | Value |
|-------|-------|
| `--space-xs` | 4px |
| `--space-sm` | 8px |
| `--space-md` | 16px |
| `--space-lg` | 24px |
| `--space-xl` | 40px |
| `--space-xxl` | 64px |
---
## 컴포넌트
### Sidebar
- 너비: 240px (데스크톱), 64px (모바일 아이콘 모드)
- `border-right: 1px solid var(--border-color)`
- `position: sticky; top: 0; height: 100vh`
- 로고: ● + ◗ (circle-full + circle-half)
- 네비 active: `[대시]` 브라켓 표기 + 흰색 텍스트
- 네비 inactive: `#8A8A8A`
- 메뉴: 대시, 활동, 프로, 자매, 조직, 설정, 관리
### Card (상태 카드)
- `border: 1px solid var(--border-color)`
- `background: var(--bg-surface)`
- `padding: var(--space-lg)`
- hover: `border-color: #555`
- 내부: header(SYS:이름) + bracket-value([ON]) + label + tech-bar
### Tech Bar (진행 바)
- `height: 2px; background: var(--border-color)`
- fill: `background: var(--text-primary)`
### Timeline
- 왼쪽 세로선: `left: 3px; width: 1px; background: var(--border-color)`
- 도트: `width: 7px; height: 7px; border: 1px solid var(--text-primary); border-radius: 50%`
### Toggle Switch
- 34x18px, border 1px
- checked: border `var(--text-primary)`, dot 흰색
### Bracket Input
- `[ value ]` 형태, mono font
- 투명 배경, 포커스 시 흰색 텍스트
### Button
- 기본: 투명 + 보더, uppercase 12px
- primary: 흰색 배경 + 검정 텍스트, hover 반전
- toggle: fit-content, 11px, active 시 흰색 배경
### Log Table
- 배경: `#0d0d0d`, border 1px
- th: 11px uppercase, `--text-secondary`
- td: 12px mono, hover 배경 `#1a1a1a`
### Terminal Card (조직도)
- `border-left: 3px solid` (레벨별 색상)
- `box-shadow: 0 10px 30px rgba(0,0,0,0.5)`
- 뱃지: 9px mono, `#1a1a1a` 배경
---
## 반응형 (Breakpoints)
| Breakpoint | 화면 | 변경 사항 |
|------------|------|-----------|
| `≥1200px` | Desktop | 기본 레이아웃 |
| `7681199px` | Tablet | 사이드바 64px 아이콘 모드, 카드 2열, 2열 그리드→1열 |
| `≤767px` | Mobile | 사이드바 하단 탭바, 카드 1열, 노드 엔트리 수직 스택, 테이블 카드 변환 |
### Mobile 세부 규칙
1. **사이드바 → 하단 탭바**: 고정 하단, 아이콘+짧은 라벨, `height: 56px`
2. **상태 카드 그리드**: `grid-template-columns: repeat(2, 1fr)``1fr` (모바일)
3. **data-columns (대시 하단)**: `1.5fr 1fr``1fr` 수직 스택
4. **project-grid (프로젝트 상세)**: `1fr 320px``1fr` 수직 스택
5. **node-entry (자매 관리)**: 3열 가로 → 수직 스택
6. **settings-grid**: 2열 → 1열
7. **log-table**: 가로 스크롤 또는 카드 형태 변환
8. **조직도 tree**: 수평 스크롤 + 핀치 줌
9. **padding 축소**: `--space-xxl``--space-lg`, `--space-xl``--space-md`
10. **bracket-value 크기**: 32px → 24px (모바일)

View File

@@ -0,0 +1,510 @@
The code below contains a design. This design should be used to create a new app or be added to an existing one.
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
Run the dev command so the user can see the app once finished.
```
<html lang="ko" vid="0"><head vid="1">
<meta charset="UTF-8" vid="2">
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
<title vid="4">하나랑 대시보드</title>
<style vid="5">
:root {
--bg-main: #151515;
--bg-surface: #151515;
--text-primary: #F5F5F5;
--text-secondary: #8A8A8A;
--border-color: #333333;
--accent-hover: #FFFFFF;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--space-xxl: 64px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-main);
color: var(--text-primary);
line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
min-height: 100vh;
overflow-x: hidden;
}
.label-meta {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-primary);
}
.label-meta span {
color: var(--text-secondary);
margin-right: var(--space-sm);
}
.bracket-value {
font-size: 32px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
}
.app-container {
display: flex;
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.sidebar {
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;
}
.logo-container {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
}
.logo-circle-full {
width: 20px;
height: 20px;
background-color: var(--text-primary);
border-radius: 50%;
}
.logo-circle-half {
width: 10px;
height: 20px;
background-color: var(--text-primary);
border-radius: 0 20px 20px 0;
}
.nav-menu {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.nav-item {
display: flex;
align-items: center;
}
.nav-link {
color: 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%;
}
.nav-link:hover, .nav-link.active {
color: var(--accent-hover);
}
.nav-link.active::before {
content: "[";
margin-right: 4px;
color: var(--text-secondary);
}
.nav-link.active::after {
content: "]";
margin-left: 4px;
color: var(--text-secondary);
}
.main-content {
flex-grow: 1;
padding: var(--space-xl) var(--space-xxl);
display: flex;
flex-direction: column;
gap: var(--space-xl);
min-width: 0;
}
.page-header {
margin-bottom: var(--space-md);
}
.page-title {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.status-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-lg);
}
.card {
border: 1px solid var(--border-color);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
background: var(--bg-surface);
transition: border-color 0.2s;
}
.card:hover {
border-color: #555;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.card-data-row {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.tech-bar {
height: 2px;
width: 100%;
background-color: var(--border-color);
margin-top: auto;
position: relative;
}
.tech-bar-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: var(--text-primary);
}
.data-columns {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: var(--space-xxl);
align-items: start;
}
.section-title {
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-sm);
margin-bottom: var(--space-lg);
display: flex;
justify-content: space-between;
}
.project-list {
display: flex;
flex-direction: column;
}
.project-row {
display: grid;
grid-template-columns: 40px 1fr auto;
gap: var(--space-md);
padding: var(--space-md) 0;
border-bottom: 1px solid #222;
align-items: center;
}
.project-row:last-child {
border-bottom: none;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #333;
overflow: hidden;
filter: grayscale(100%) contrast(120%);
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.project-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.project-name {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
}
.project-desc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
.timeline {
display: flex;
flex-direction: column;
position: relative;
}
.timeline::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 3px;
width: 1px;
background-color: var(--border-color);
}
.timeline-item {
position: relative;
padding-left: var(--space-lg);
padding-bottom: var(--space-lg);
}
.timeline-item::before {
content: '';
position: absolute;
left: 0;
top: 6px;
width: 7px;
height: 7px;
background-color: var(--bg-main);
border: 1px solid var(--text-primary);
border-radius: 50%;
}
.time-stamp {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
margin-bottom: var(--space-xs);
display: block;
}
.timeline-content {
font-size: 13px;
color: var(--text-primary);
line-height: 1.5;
}
.timeline-content strong {
font-weight: 600;
}
</style>
</head>
<body vid="6">
<div class="app-container" vid="7">
<aside class="sidebar" vid="8">
<div class="logo-container" vid="9">
<div class="logo-circle-full" vid="10"></div>
<div class="logo-circle-half" vid="11"></div>
</div>
<nav vid="12">
<ul class="nav-menu" vid="13">
<li class="nav-item" vid="14"><a href="#" class="nav-link active" vid="15">대시</a></li>
<li class="nav-item" vid="16"><a href="#" class="nav-link" vid="17">프로</a></li>
<li class="nav-item" vid="18"><a href="#" class="nav-link" vid="19">자매</a></li>
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">조직</a></li>
<li class="nav-item" vid="22"><a href="#" class="nav-link" vid="23">설정</a></li>
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">관리</a></li>
</ul>
</nav>
</aside>
<main class="main-content" vid="26">
<header class="page-header" vid="27">
<h1 class="page-title" vid="28">하나랑 대시보드</h1>
</header>
<section class="status-grid" vid="29">
<div class="card" vid="30">
<div class="card-header" vid="31">
<div class="label-meta" vid="32"><span vid="33">SYS:</span>하랑</div>
</div>
<div class="card-data-row" vid="34">
<div class="bracket-value" vid="35">[ON]</div>
<div class="label-meta" vid="36">Active Node</div>
</div>
<div class="tech-bar" vid="37"><div class="tech-bar-fill" style="width: 100%;" vid="38"></div></div>
</div>
<div class="card" vid="39">
<div class="card-header" vid="40">
<div class="label-meta" vid="41"><span vid="42">SYS:</span>나랑</div>
</div>
<div class="card-data-row" vid="43">
<div class="bracket-value" vid="44">[84]</div>
<div class="label-meta" vid="45">Capacity %</div>
</div>
<div class="tech-bar" vid="46"><div class="tech-bar-fill" style="width: 84%;" vid="47"></div></div>
</div>
<div class="card" vid="48">
<div class="card-header" vid="49">
<div class="label-meta" vid="50"><span vid="51">SYS:</span>다랑</div>
</div>
<div class="card-data-row" vid="52">
<div class="bracket-value" style="color: var(--text-secondary);" vid="53">[--]</div>
<div class="label-meta" vid="54">Standby</div>
</div>
<div class="tech-bar" vid="55"><div class="tech-bar-fill" style="width: 0%;" vid="56"></div></div>
</div>
<div class="card" vid="57">
<div class="card-header" vid="58">
<div class="label-meta" vid="59"><span vid="60">SYS:</span>이랑</div>
</div>
<div class="card-data-row" vid="61">
<div class="bracket-value" vid="62">[12]</div>
<div class="label-meta" vid="63">Sync Queue</div>
</div>
<div class="tech-bar" vid="64"><div class="tech-bar-fill" style="width: 12%;" vid="65"></div></div>
</div>
</section>
<div class="data-columns" vid="66">
<section vid="67">
<div class="section-title" vid="68">
<span vid="69">ONGOING PROJECTS</span>
<span class="label-meta" vid="70">VOL: 04</span>
</div>
<div class="project-list" vid="71">
<div class="project-row" vid="72">
<div class="avatar" vid="73">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23444'/%3E%3Ccircle cx='50' cy='40' r='20' fill='%23666'/%3E%3Cpath d='M20 90 Q50 60 80 90' stroke='%23666' stroke-width='10' fill='none'/%3E%3C/svg%3E" alt="avatar" vid="74">
</div>
<div class="project-details" vid="75">
<div class="project-name" vid="76">데이터 파이프라인 최적화</div>
<div class="project-desc" vid="77">하랑 및 나랑 시스템 간의 실시간 동기화 지연율 개선.</div>
</div>
<div class="label-meta" vid="78">PHASE 2</div>
</div>
<div class="project-row" vid="79">
<div class="avatar" vid="80">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23555'/%3E%3Cpath d='M30 80 L50 30 L70 80' stroke='%23777' stroke-width='8' fill='none'/%3E%3C/svg%3E" alt="avatar" vid="81">
</div>
<div class="project-details" vid="82">
<div class="project-name" vid="83">새로운 자매 노드 배포 (아랑)</div>
<div class="project-desc" vid="84">인프라 구성 및 초기 보안 감사 진행 중.</div>
</div>
<div class="label-meta" vid="85">INIT</div>
</div>
<div class="project-row" vid="86">
<div class="avatar" vid="87">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23333'/%3E%3Ccircle cx='50' cy='50' r='25' fill='none' stroke='%23888' stroke-width='6'/%3E%3C/svg%3E" alt="avatar" vid="88">
</div>
<div class="project-details" vid="89">
<div class="project-name" vid="90">관리자 권한 체계 개편</div>
<div class="project-desc" vid="91">조직별 접근 제어 정책 재수립 및 적용.</div>
</div>
<div class="label-meta" vid="92">REVIEW</div>
</div>
</div>
</section>
<section vid="93">
<div class="section-title" vid="94">
<span vid="95">ACTIVITY FEED</span>
<span class="label-meta" vid="96">LIVE</span>
</div>
<div class="timeline" vid="97">
<div class="timeline-item" vid="98">
<span class="time-stamp" vid="99">14:02:45 GMT+9</span>
<div class="timeline-content" vid="100">
<strong vid="101">System (다랑)</strong> status updated to Standby mode by admin protocol.
</div>
</div>
<div class="timeline-item" vid="102">
<span class="time-stamp" vid="103">11:30:12 GMT+9</span>
<div class="timeline-content" vid="104">
<strong vid="105">Security Audit</strong> completed for project [데이터 파이프라인 최적화]. No critical vulnerabilities found.
</div>
</div>
<div class="timeline-item" vid="106">
<span class="time-stamp" vid="107">09:15:00 GMT+9</span>
<div class="timeline-content" vid="108">
New user batch imported to <strong vid="109">Organization [Beta]</strong> structure.
</div>
</div>
<div class="timeline-item" vid="110">
<span class="time-stamp" vid="111">08:00:05 GMT+9</span>
<div class="timeline-content" vid="112">
Daily automated backup initiated across all active nodes.
</div>
</div>
</div>
</section>
</div>
</main>
</div>
</body></html>
```

View File

@@ -0,0 +1,465 @@
The code below contains a design. This design should be used to create a new app or be added to an existing one.
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
Run the dev command so the user can see the app once finished.
```
<html lang="ko" vid="0"><head vid="1">
<meta charset="UTF-8" vid="2">
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
<title vid="4">하나랑 - 프로젝트 상세</title>
<style vid="5">
:root {
--bg-main: #151515;
--bg-surface: #151515;
--text-primary: #F5F5F5;
--text-secondary: #8A8A8A;
--border-color: #333333;
--accent-hover: #FFFFFF;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--space-xxl: 64px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-main);
color: var(--text-primary);
line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased;
display: flex;
min-height: 100vh;
overflow-x: hidden;
}
.label-meta {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-primary);
}
.label-meta span {
color: var(--text-secondary);
margin-right: var(--space-sm);
}
.app-container {
display: flex;
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.sidebar {
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;
}
.logo-container {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
}
.logo-circle-full {
width: 20px;
height: 20px;
background-color: var(--text-primary);
border-radius: 50%;
}
.logo-circle-half {
width: 10px;
height: 20px;
background-color: var(--text-primary);
border-radius: 0 20px 20px 0;
}
.nav-menu {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.nav-link {
color: 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%;
}
.nav-link:hover, .nav-link.active {
color: var(--accent-hover);
}
.nav-link.active::before { content: "["; margin-right: 4px; color: var(--text-secondary); }
.nav-link.active::after { content: "]"; margin-left: 4px; color: var(--text-secondary); }
.main-content {
flex-grow: 1;
padding: var(--space-xl) var(--space-xxl);
display: flex;
flex-direction: column;
gap: var(--space-xl);
min-width: 0;
}
.breadcrumb {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
margin-bottom: var(--space-sm);
text-transform: uppercase;
}
.page-header {
margin-bottom: var(--space-md);
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-lg);
}
.page-title-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
}
.project-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--space-xxl);
}
.section-title {
font-size: 14px;
font-weight: 600;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-sm);
margin-bottom: var(--space-lg);
display: flex;
justify-content: space-between;
}
.phase-timeline {
display: flex;
flex-direction: column;
gap: var(--space-lg);
margin-bottom: var(--space-xl);
}
.phase-item {
display: grid;
grid-template-columns: 120px 1fr;
gap: var(--space-lg);
}
.phase-meta {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
padding-top: 4px;
}
.phase-box {
border-left: 2px solid var(--border-color);
padding-left: var(--space-lg);
position: relative;
}
.phase-box.active {
border-left-color: var(--text-primary);
}
.phase-box.active::before {
content: '';
position: absolute;
left: -5px;
top: 0;
width: 8px;
height: 8px;
background: var(--text-primary);
border-radius: 50%;
}
.phase-name {
font-weight: 600;
margin-bottom: 4px;
}
.phase-desc {
font-size: 13px;
color: var(--text-secondary);
}
.checklist {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.check-item {
display: flex;
align-items: center;
gap: var(--space-md);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
}
.check-box {
width: 14px;
height: 14px;
border: 1px solid var(--border-color);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.check-box.checked::after {
content: '■';
font-size: 10px;
}
.check-item.done {
color: var(--text-secondary);
text-decoration: line-through;
}
.node-stack {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.node-mini-card {
border: 1px solid var(--border-color);
padding: var(--space-sm) var(--space-md);
display: flex;
justify-content: space-between;
align-items: center;
}
.node-status-dot {
width: 6px;
height: 6px;
background: #00FF00;
border-radius: 50%;
}
.audit-log {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border-color);
border: 1px solid var(--border-color);
}
.audit-row {
background: var(--bg-main);
padding: var(--space-sm) var(--space-md);
display: grid;
grid-template-columns: 100px 1fr 80px;
font-family: var(--font-mono);
font-size: 11px;
}
.audit-header {
color: var(--text-secondary);
text-transform: uppercase;
font-weight: 700;
}
.status-tag {
padding: 2px 6px;
font-size: 10px;
border: 1px solid var(--border-color);
text-align: center;
}
.status-tag.pass { border-color: #00FF00; color: #00FF00; }
</style>
</head>
<body vid="6">
<div class="app-container" vid="7">
<aside class="sidebar" vid="8">
<div class="logo-container" vid="9">
<div class="logo-circle-full" vid="10"></div>
<div class="logo-circle-half" vid="11"></div>
</div>
<nav vid="12">
<ul class="nav-menu" vid="13">
<li class="nav-item" vid="14"><a href="#" class="nav-link" vid="15">대시</a></li>
<li class="nav-item" vid="16"><a href="#" class="nav-link active" vid="17">프로</a></li>
<li class="nav-item" vid="18"><a href="#" class="nav-link" vid="19">자매</a></li>
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">조직</a></li>
<li class="nav-item" vid="22"><a href="#" class="nav-link" vid="23">설정</a></li>
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">관리</a></li>
</ul>
</nav>
</aside>
<main class="main-content" vid="26">
<div class="breadcrumb" vid="27">PROJECTS / P-402 / SUMMARY</div>
<header class="page-header" vid="28">
<div class="page-title-row" vid="29">
<h1 class="page-title" vid="30">데이터 파이프라인 최적화</h1>
<div class="label-meta" vid="31"><span vid="32">STATUS:</span>ACTIVE / PHASE 02</div>
</div>
</header>
<div class="project-grid" vid="33">
<div class="content-left" vid="34">
<section vid="35">
<div class="section-title" vid="36">
<span vid="37">PHASE TIMELINE</span>
<span class="label-meta" vid="38">CURRENT: P2</span>
</div>
<div class="phase-timeline" vid="39">
<div class="phase-item" vid="40">
<div class="phase-meta" vid="41">COMPLETED<br vid="42">2023.10.12</div>
<div class="phase-box" vid="43">
<div class="phase-name" vid="44">PHASE 01: 아키텍처 분석</div>
<div class="phase-desc" vid="45">기존 하랑-나랑 노드 간의 데이터 흐름 병목 현상 진단 및 병렬 처리 로직 설계 완료.</div>
</div>
</div>
<div class="phase-item" vid="46">
<div class="phase-meta" vid="47">IN PROGRESS<br vid="48">EST: 11.05</div>
<div class="phase-box active" vid="49">
<div class="phase-name" vid="50">PHASE 02: 실시간 동기화 구현</div>
<div class="phase-desc" vid="51">Redis 스트림을 활용한 비동기 메시지 큐 시스템 구축 및 데이터 무결성 검증 레이어 통합.</div>
</div>
</div>
<div class="phase-item" vid="52">
<div class="phase-meta" vid="53">PENDING<br vid="54">TBD</div>
<div class="phase-box" vid="55">
<div class="phase-name" vid="56">PHASE 03: 스트레스 테스트 및 배포</div>
<div class="phase-desc" vid="57">최대 트래픽 상황에서의 안정성 확보 및 프로덕션 환경 순차 배포 예정.</div>
</div>
</div>
</div>
</section>
<section vid="58">
<div class="section-title" vid="59">
<span vid="60">SECURITY AUDIT LOG</span>
<span class="label-meta" vid="61">LAST CHECK: 4H AGO</span>
</div>
<div class="audit-log" vid="62">
<div class="audit-row audit-header" vid="63">
<div vid="64">TIMESTAMP</div>
<div vid="65">ACTION / RESOURCE</div>
<div vid="66">RESULT</div>
</div>
<div class="audit-row" vid="67">
<div vid="68">14:02:45</div>
<div vid="69">CRYPTO_KEY_ROTATION / NODE_HARANG</div>
<div class="status-tag pass" vid="70">PASS</div>
</div>
<div class="audit-row" vid="71">
<div vid="72">11:30:12</div>
<div vid="73">CODE_SCAN_STATIC / PIPELINE_V2</div>
<div class="status-tag pass" vid="74">PASS</div>
</div>
<div class="audit-row" vid="75">
<div vid="76">09:15:00</div>
<div vid="77">ACCESS_LOG_REVIEW / ADMIN_ROOT</div>
<div class="status-tag" vid="78">WARN</div>
</div>
</div>
</section>
</div>
<div class="content-right" vid="79">
<section style="margin-bottom: var(--space-xl);" vid="80">
<div class="section-title" vid="81">ASSIGNED NODES</div>
<div class="node-stack" vid="82">
<div class="node-mini-card" vid="83">
<span class="label-meta" vid="84">하랑 [PRIMARY]</span>
<div class="node-status-dot" vid="85"></div>
</div>
<div class="node-mini-card" vid="86">
<span class="label-meta" vid="87">나랑 [SECONDARY]</span>
<div class="node-status-dot" vid="88"></div>
</div>
</div>
</section>
<section vid="89">
<div class="section-title" vid="90">TASK CHECKLIST</div>
<ul class="checklist" vid="91">
<li class="check-item done" vid="92">
<div class="check-box checked" vid="93"></div>
<span vid="94">인프라 요구사항 명세 작성</span>
</li>
<li class="check-item done" vid="95">
<div class="check-box checked" vid="96"></div>
<span vid="97">보안 프로토콜 V3 적용</span>
</li>
<li class="check-item" vid="98">
<div class="check-box" vid="99"></div>
<span vid="100">캐시 무효화 로직 테스트</span>
</li>
<li class="check-item" vid="101">
<div class="check-box" vid="102"></div>
<span vid="103">부하 분산 알고리즘 튜닝</span>
</li>
<li class="check-item" vid="104">
<div class="check-box" vid="105"></div>
<span vid="106">최종 문서화 및 코드 리뷰</span>
</li>
</ul>
</section>
</div>
</div>
</main>
</div>
</body></html>
```

View File

@@ -0,0 +1,472 @@
The code below contains a design. This design should be used to create a new app or be added to an existing one.
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
Run the dev command so the user can see the app once finished.
```
<html lang="ko" vid="0"><head vid="1">
<meta charset="UTF-8" vid="2">
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
<title vid="4">하나랑 - 자매 노드 관리</title>
<style vid="5">
:root {
--bg-main: #151515;
--bg-surface: #151515;
--text-primary: #F5F5F5;
--text-secondary: #8A8A8A;
--border-color: #333333;
--accent-hover: #FFFFFF;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--space-xxl: 64px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-main);
color: var(--text-primary);
line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased;
display: flex;
min-height: 100vh;
overflow-x: hidden;
}
.label-meta {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-primary);
}
.label-meta span {
color: var(--text-secondary);
margin-right: var(--space-sm);
}
.app-container {
display: flex;
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.sidebar {
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;
}
.logo-container {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
}
.logo-circle-full {
width: 20px;
height: 20px;
background-color: var(--text-primary);
border-radius: 50%;
}
.logo-circle-half {
width: 10px;
height: 20px;
background-color: var(--text-primary);
border-radius: 0 20px 20px 0;
}
.nav-menu {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.nav-link {
color: 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%;
}
.nav-link:hover, .nav-link.active {
color: var(--accent-hover);
}
.nav-link.active::before {
content: "[";
margin-right: 4px;
color: var(--text-secondary);
}
.nav-link.active::after {
content: "]";
margin-left: 4px;
color: var(--text-secondary);
}
.main-content {
flex-grow: 1;
padding: var(--space-xl) var(--space-xxl);
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-md);
}
.page-title {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
}
.node-grid {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.node-entry {
display: grid;
grid-template-columns: 240px 1fr 300px;
gap: var(--space-xxl);
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-xl);
}
.node-info {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.node-name {
font-size: 20px;
font-weight: 600;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #555;
}
.status-indicator.active {
background-color: #FFF;
box-shadow: 0 0 8px rgba(255,255,255,0.3);
}
.node-controls {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.btn-toggle {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
cursor: pointer;
text-align: left;
width: fit-content;
transition: all 0.2s;
}
.btn-toggle:hover {
border-color: #666;
background: #1a1a1a;
}
.btn-toggle.active {
background: var(--text-primary);
color: var(--bg-main);
}
.capacity-section {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.graph-container {
height: 60px;
display: flex;
align-items: flex-end;
gap: 2px;
border-left: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
padding-left: 4px;
}
.graph-bar {
flex: 1;
background-color: #333;
transition: height 0.3s;
}
.graph-bar.highlight {
background-color: var(--text-primary);
}
.sync-history {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.sync-row {
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
border-bottom: 1px solid #222;
padding-bottom: 4px;
}
.sync-row span:last-child {
color: var(--text-primary);
}
.meta-pair {
display: flex;
flex-direction: column;
gap: 2px;
}
.meta-pair .val {
font-size: 16px;
font-family: var(--font-mono);
}
</style>
</head>
<body vid="6">
<div class="app-container" vid="7">
<aside class="sidebar" vid="8">
<div class="logo-container" vid="9">
<div class="logo-circle-full" vid="10"></div>
<div class="logo-circle-half" vid="11"></div>
</div>
<nav vid="12">
<ul class="nav-menu" vid="13">
<li class="nav-item" vid="14"><a href="#" class="nav-link" vid="15">대시</a></li>
<li class="nav-item" vid="16"><a href="#" class="nav-link" vid="17">프로</a></li>
<li class="nav-item" vid="18"><a href="#" class="nav-link active" vid="19">자매</a></li>
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">조직</a></li>
<li class="nav-item" vid="22"><a href="#" class="nav-link" vid="23">설정</a></li>
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">관리</a></li>
</ul>
</nav>
</aside>
<main class="main-content" vid="26">
<header class="page-header" vid="27">
<h1 class="page-title" vid="28">자매 노드 관리</h1>
<div class="label-meta" vid="29"><span vid="30">TOTAL NODES:</span> 04</div>
</header>
<section class="node-grid" vid="31">
<div class="node-entry" vid="32">
<div class="node-info" vid="33">
<div class="node-name" vid="34">
<div class="status-indicator active" vid="35"></div>
하랑 (Primary)
</div>
<div class="meta-pair" vid="36">
<div class="label-meta" vid="37">Uptime</div>
<div class="val" vid="38">342:12:05</div>
</div>
<div class="node-controls" vid="39">
<button class="btn-toggle active" vid="40">ACTIVE</button>
<button class="btn-toggle" vid="41">REBOOT</button>
</div>
</div>
<div class="capacity-section" vid="42">
<div class="label-meta" vid="43">CAPACITY HISTORY (24H)</div>
<div class="graph-container" vid="44">
<div class="graph-bar" style="height: 40%" vid="45"></div>
<div class="graph-bar" style="height: 45%" vid="46"></div>
<div class="graph-bar" style="height: 60%" vid="47"></div>
<div class="graph-bar" style="height: 55%" vid="48"></div>
<div class="graph-bar" style="height: 70%" vid="49"></div>
<div class="graph-bar" style="height: 85%" vid="50"></div>
<div class="graph-bar highlight" style="height: 92%" vid="51"></div>
<div class="graph-bar highlight" style="height: 84%" vid="52"></div>
</div>
<div class="sync-row" vid="53"><span vid="54">LOAD INDEX</span><span vid="55">84.2%</span></div>
</div>
<div class="sync-history" vid="56">
<div class="label-meta" vid="57">SYNC LOG</div>
<div class="sync-row" vid="58"><span vid="59">14:05:22</span><span vid="60">SUCCESS</span></div>
<div class="sync-row" vid="61"><span vid="62">13:55:01</span><span vid="63">SUCCESS</span></div>
<div class="sync-row" vid="64"><span vid="65">13:44:10</span><span vid="66">SUCCESS</span></div>
</div>
</div>
<div class="node-entry" vid="67">
<div class="node-info" vid="68">
<div class="node-name" vid="69">
<div class="status-indicator active" vid="70"></div>
나랑 (Secondary)
</div>
<div class="meta-pair" vid="71">
<div class="label-meta" vid="72">Uptime</div>
<div class="val" vid="73">112:44:21</div>
</div>
<div class="node-controls" vid="74">
<button class="btn-toggle active" vid="75">ACTIVE</button>
<button class="btn-toggle" vid="76">REBOOT</button>
</div>
</div>
<div class="capacity-section" vid="77">
<div class="label-meta" vid="78">CAPACITY HISTORY (24H)</div>
<div class="graph-container" vid="79">
<div class="graph-bar" style="height: 30%" vid="80"></div>
<div class="graph-bar" style="height: 32%" vid="81"></div>
<div class="graph-bar" style="height: 35%" vid="82"></div>
<div class="graph-bar" style="height: 40%" vid="83"></div>
<div class="graph-bar" style="height: 38%" vid="84"></div>
<div class="graph-bar" style="height: 45%" vid="85"></div>
<div class="graph-bar highlight" style="height: 50%" vid="86"></div>
<div class="graph-bar highlight" style="height: 48%" vid="87"></div>
</div>
<div class="sync-row" vid="88"><span vid="89">LOAD INDEX</span><span vid="90">48.7%</span></div>
</div>
<div class="sync-history" vid="91">
<div class="label-meta" vid="92">SYNC LOG</div>
<div class="sync-row" vid="93"><span vid="94">14:02:11</span><span vid="95">SUCCESS</span></div>
<div class="sync-row" vid="96"><span vid="97">13:51:44</span><span vid="98">SUCCESS</span></div>
<div class="sync-row" vid="99"><span vid="100">13:40:02</span><span vid="101">SUCCESS</span></div>
</div>
</div>
<div class="node-entry" vid="102">
<div class="node-info" vid="103">
<div class="node-name" vid="104">
<div class="status-indicator" vid="105"></div>
다랑 (Standby)
</div>
<div class="meta-pair" vid="106">
<div class="label-meta" vid="107">Last Active</div>
<div class="val" vid="108">02:15:00 ago</div>
</div>
<div class="node-controls" vid="109">
<button class="btn-toggle" vid="110">STANDBY</button>
<button class="btn-toggle" vid="111">ACTIVATE</button>
</div>
</div>
<div class="capacity-section" vid="112">
<div class="label-meta" vid="113">CAPACITY HISTORY (24H)</div>
<div class="graph-container" vid="114">
<div class="graph-bar" style="height: 10%" vid="115"></div>
<div class="graph-bar" style="height: 10%" vid="116"></div>
<div class="graph-bar" style="height: 5%" vid="117"></div>
<div class="graph-bar" style="height: 5%" vid="118"></div>
<div class="graph-bar" style="height: 5%" vid="119"></div>
<div class="graph-bar" style="height: 5%" vid="120"></div>
<div class="graph-bar" style="height: 2%" vid="121"></div>
<div class="graph-bar" style="height: 2%" vid="122"></div>
</div>
<div class="sync-row" vid="123"><span vid="124">LOAD INDEX</span><span vid="125">0.0%</span></div>
</div>
<div class="sync-history" vid="126">
<div class="label-meta" vid="127">SYNC LOG</div>
<div class="sync-row" vid="128"><span vid="129">14:00:00</span><span vid="130">IDLE</span></div>
<div class="sync-row" vid="131"><span vid="132">13:45:00</span><span vid="133">IDLE</span></div>
<div class="sync-row" vid="134"><span vid="135">13:30:00</span><span vid="136">IDLE</span></div>
</div>
</div>
<div class="node-entry" vid="137">
<div class="node-info" vid="138">
<div class="node-name" vid="139">
<div class="status-indicator active" vid="140"></div>
이랑 (Sync)
</div>
<div class="meta-pair" vid="141">
<div class="label-meta" vid="142">Queue Depth</div>
<div class="val" vid="143">12 PKTS</div>
</div>
<div class="node-controls" vid="144">
<button class="btn-toggle active" vid="145">SYNCING</button>
<button class="btn-toggle" vid="146">FLUSH</button>
</div>
</div>
<div class="capacity-section" vid="147">
<div class="label-meta" vid="148">CAPACITY HISTORY (24H)</div>
<div class="graph-container" vid="149">
<div class="graph-bar" style="height: 20%" vid="150"></div>
<div class="graph-bar" style="height: 25%" vid="151"></div>
<div class="graph-bar" style="height: 15%" vid="152"></div>
<div class="graph-bar" style="height: 30%" vid="153"></div>
<div class="graph-bar" style="height: 40%" vid="154"></div>
<div class="graph-bar" style="height: 35%" vid="155"></div>
<div class="graph-bar highlight" style="height: 20%" vid="156"></div>
<div class="graph-bar highlight" style="height: 15%" vid="157"></div>
</div>
<div class="sync-row" vid="158"><span vid="159">LOAD INDEX</span><span vid="160">15.2%</span></div>
</div>
<div class="sync-history" vid="161">
<div class="label-meta" vid="162">SYNC LOG</div>
<div class="sync-row" vid="163"><span vid="164">14:06:01</span><span vid="165">SYNCING</span></div>
<div class="sync-row" vid="166"><span vid="167">14:05:45</span><span vid="168">SYNCING</span></div>
<div class="sync-row" vid="169"><span vid="170">14:05:30</span><span vid="171">SYNCING</span></div>
</div>
</div>
</section>
</main>
</div>
</body></html>
```

View File

@@ -0,0 +1,502 @@
The code below contains a design. This design should be used to create a new app or be added to an existing one.
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
Run the dev command so the user can see the app once finished.
```
<html lang="ko" vid="0"><head vid="1">
<meta charset="UTF-8" vid="2">
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
<title vid="4">하나랑 대시보드 - 설정</title>
<style vid="5">
:root {
--bg-main: #151515;
--bg-surface: #151515;
--text-primary: #F5F5F5;
--text-secondary: #8A8A8A;
--border-color: #333333;
--accent-hover: #FFFFFF;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--space-xxl: 64px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-main);
color: var(--text-primary);
line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
min-height: 100vh;
overflow-x: hidden;
}
.label-meta {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-primary);
}
.label-meta span {
color: var(--text-secondary);
margin-right: var(--space-sm);
}
.app-container {
display: flex;
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.sidebar {
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;
}
.logo-container {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
}
.logo-circle-full {
width: 20px;
height: 20px;
background-color: var(--text-primary);
border-radius: 50%;
}
.logo-circle-half {
width: 10px;
height: 20px;
background-color: var(--text-primary);
border-radius: 0 20px 20px 0;
}
.nav-menu {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.nav-item {
display: flex;
align-items: center;
}
.nav-link {
color: 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%;
}
.nav-link:hover, .nav-link.active {
color: var(--accent-hover);
}
.nav-link.active::before {
content: "[";
margin-right: 4px;
color: var(--text-secondary);
}
.nav-link.active::after {
content: "]";
margin-left: 4px;
color: var(--text-secondary);
}
.main-content {
flex-grow: 1;
padding: var(--space-xl) var(--space-xxl);
display: flex;
flex-direction: column;
gap: var(--space-xl);
min-width: 0;
}
.page-header {
margin-bottom: var(--space-md);
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.section-title {
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-sm);
margin-bottom: var(--space-lg);
display: flex;
justify-content: space-between;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-xxl);
}
.settings-section {
display: flex;
flex-direction: column;
}
.settings-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md) 0;
border-bottom: 1px solid #222;
}
.settings-row:last-child {
border-bottom: none;
}
.setting-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.setting-label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.setting-desc {
font-size: 12px;
color: var(--text-secondary);
}
.switch {
position: relative;
display: inline-block;
width: 34px;
height: 18px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #2a2a2a;
transition: .2s;
border: 1px solid var(--border-color);
}
.slider:before {
position: absolute;
content: "";
height: 10px;
width: 10px;
left: 3px;
bottom: 3px;
background-color: var(--text-secondary);
transition: .2s;
}
input:checked + .slider {
background-color: #151515;
border-color: var(--text-primary);
}
input:checked + .slider:before {
transform: translateX(16px);
background-color: var(--text-primary);
}
.bracket-input-group {
display: flex;
align-items: center;
color: var(--text-secondary);
font-family: var(--font-mono);
}
.bracket-input {
background: transparent;
border: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 14px;
width: 60px;
text-align: center;
outline: none;
padding: 0 4px;
}
.bracket-input:focus {
color: var(--accent-hover);
}
.action-bar {
margin-top: auto;
padding-top: var(--space-xl);
display: flex;
justify-content: flex-end;
gap: var(--space-md);
}
.btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: var(--space-sm) var(--space-lg);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn:hover {
border-color: var(--text-primary);
color: var(--text-primary);
}
.btn-primary {
background: var(--text-primary);
color: var(--bg-main);
border-color: var(--text-primary);
}
.btn-primary:hover {
background: transparent;
color: var(--text-primary);
}
</style>
</head>
<body vid="6">
<div class="app-container" vid="7">
<aside class="sidebar" vid="8">
<div class="logo-container" vid="9">
<div class="logo-circle-full" vid="10"></div>
<div class="logo-circle-half" vid="11"></div>
</div>
<nav vid="12">
<ul class="nav-menu" vid="13">
<li class="nav-item" vid="14"><a href="#" class="nav-link" vid="15">대시</a></li>
<li class="nav-item" vid="16"><a href="#" class="nav-link" vid="17">프로</a></li>
<li class="nav-item" vid="18"><a href="#" class="nav-link" vid="19">자매</a></li>
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">조직</a></li>
<li class="nav-item" vid="22"><a href="#" class="nav-link active" vid="23">설정</a></li>
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">관리</a></li>
</ul>
</nav>
</aside>
<main class="main-content" vid="26">
<header class="page-header" vid="27">
<h1 class="page-title" vid="28">시스템 설정</h1>
<div class="label-meta" vid="29"><span vid="30">CONFIG:</span>v2.0.4-STABLE</div>
</header>
<div class="settings-grid" vid="31">
<section class="settings-section" vid="32">
<div class="section-title" vid="33">
<span vid="34">ADMIN PROTOCOL RULES</span>
<span class="label-meta" vid="35">SEC: 01</span>
</div>
<div class="settings-row" vid="36">
<div class="setting-info" vid="37">
<div class="setting-label" vid="38">강제 2단계 인증 (2FA)</div>
<div class="setting-desc" vid="39">모든 관리자 계정에 대해 생체 인식 또는 하드웨어 키 요구.</div>
</div>
<label class="switch" vid="40">
<input type="checkbox" checked="" vid="41">
<span class="slider" vid="42"></span>
</label>
</div>
<div class="settings-row" vid="43">
<div class="setting-info" vid="44">
<div class="setting-label" vid="45">세션 유효 시간</div>
<div class="setting-desc" vid="46">비활동 시 관리 콘솔 자동 로그아웃 시간(분).</div>
</div>
<div class="bracket-input-group" vid="47">
[ <input type="text" class="bracket-input" value="15" vid="48"> ]
</div>
</div>
<div class="settings-row" vid="49">
<div class="setting-info" vid="50">
<div class="setting-label" vid="51">IP 화이트리스트</div>
<div class="setting-desc" vid="52">지정된 대역에서만 관리자 권한 접근 허용.</div>
</div>
<label class="switch" vid="53">
<input type="checkbox" vid="54">
<span class="slider" vid="55"></span>
</label>
</div>
</section>
<section class="settings-section" vid="56">
<div class="section-title" vid="57">
<span vid="58">BACKUP &amp; REDUNDANCY</span>
<span class="label-meta" vid="59">SEC: 02</span>
</div>
<div class="settings-row" vid="60">
<div class="setting-info" vid="61">
<div class="setting-label" vid="62">자동 백업 활성화</div>
<div class="setting-desc" vid="63">전체 시스템 상태의 주기적인 스냅샷 생성.</div>
</div>
<label class="switch" vid="64">
<input type="checkbox" checked="" vid="65">
<span class="slider" vid="66"></span>
</label>
</div>
<div class="settings-row" vid="67">
<div class="setting-info" vid="68">
<div class="setting-label" vid="69">백업 주기 (시간)</div>
<div class="setting-desc" vid="70">스냅샷 생성 간격 설정.</div>
</div>
<div class="bracket-input-group" vid="71">
[ <input type="text" class="bracket-input" value="08:00" vid="72"> ]
</div>
</div>
<div class="settings-row" vid="73">
<div class="setting-info" vid="74">
<div class="setting-label" vid="75">보관 주기 (일)</div>
<div class="setting-desc" vid="76">백업 데이터 자동 삭제 전 유지 기간.</div>
</div>
<div class="bracket-input-group" vid="77">
[ <input type="text" class="bracket-input" value="30" vid="78"> ]
</div>
</div>
</section>
<section class="settings-section" vid="79">
<div class="section-title" vid="80">
<span vid="81">SYNC QUEUE THRESHOLDS</span>
<span class="label-meta" vid="82">SEC: 03</span>
</div>
<div class="settings-row" vid="83">
<div class="setting-info" vid="84">
<div class="setting-label" vid="85">대기열 경고 임계값</div>
<div class="setting-desc" vid="86">동기화 대기 항목이 설정치를 초과할 시 경보 발생.</div>
</div>
<div class="bracket-input-group" vid="87">
[ <input type="text" class="bracket-input" value="500" vid="88"> ]
</div>
</div>
<div class="settings-row" vid="89">
<div class="setting-info" vid="90">
<div class="setting-label" vid="91">자동 스케일링</div>
<div class="setting-desc" vid="92">부하 증가 시 임시 노드 리소스 할당.</div>
</div>
<label class="switch" vid="93">
<input type="checkbox" checked="" vid="94">
<span class="slider" vid="95"></span>
</label>
</div>
</section>
<section class="settings-section" vid="96">
<div class="section-title" vid="97">
<span vid="98">NODE ALERT TRIGGERS</span>
<span class="label-meta" vid="99">SEC: 04</span>
</div>
<div class="settings-row" vid="100">
<div class="setting-info" vid="101">
<div class="setting-label" vid="102">CPU 사용량 임계값 (%)</div>
<div class="setting-desc" vid="103">시스템 부하 경고 기준점.</div>
</div>
<div class="bracket-input-group" vid="104">
[ <input type="text" class="bracket-input" value="90" vid="105"> ]
</div>
</div>
<div class="settings-row" vid="106">
<div class="setting-info" vid="107">
<div class="setting-label" vid="108">레이턴시 경보 (ms)</div>
<div class="setting-desc" vid="109">응답 속도 지연에 대한 임계값 설정.</div>
</div>
<div class="bracket-input-group" vid="110">
[ <input type="text" class="bracket-input" value="250" vid="111"> ]
</div>
</div>
<div class="settings-row" vid="112">
<div class="setting-info" vid="113">
<div class="setting-label" vid="114">노드 오프라인 알림</div>
<div class="setting-desc" vid="115">활성 노드 연결 해제 시 즉시 알림.</div>
</div>
<label class="switch" vid="116">
<input type="checkbox" checked="" vid="117">
<span class="slider" vid="118"></span>
</label>
</div>
</section>
</div>
<div class="action-bar" vid="119">
<button class="btn" vid="120">초기화</button>
<button class="btn btn-primary" vid="121">설정 저장</button>
</div>
</main>
</div>
</body></html>
```

View File

@@ -0,0 +1,408 @@
The code below contains a design. This design should be used to create a new app or be added to an existing one.
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
Run the dev command so the user can see the app once finished.
```
<html lang="ko" vid="0"><head vid="1">
<meta charset="UTF-8" vid="2">
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
<title vid="4">하나랑 대시보드 - ACTIVITY LOG</title>
<style vid="5">
:root {
--bg-main: #151515;
--bg-surface: #151515;
--text-primary: #F5F5F5;
--text-secondary: #8A8A8A;
--border-color: #333333;
--accent-hover: #FFFFFF;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--space-xxl: 64px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-main);
color: var(--text-primary);
line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
min-height: 100vh;
overflow-x: hidden;
}
.app-container {
display: flex;
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.sidebar {
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;
}
.logo-container {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
}
.logo-circle-full {
width: 20px;
height: 20px;
background-color: var(--text-primary);
border-radius: 50%;
}
.logo-circle-half {
width: 10px;
height: 20px;
background-color: var(--text-primary);
border-radius: 0 20px 20px 0;
}
.nav-menu {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.nav-link {
color: 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%;
}
.nav-link:hover, .nav-link.active {
color: var(--accent-hover);
}
.nav-link.active::before { content: "["; margin-right: 4px; color: var(--text-secondary); }
.nav-link.active::after { content: "]"; margin-left: 4px; color: var(--text-secondary); }
.main-content {
flex-grow: 1;
padding: var(--space-xl) var(--space-xxl);
display: flex;
flex-direction: column;
gap: var(--space-lg);
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-md);
margin-bottom: var(--space-md);
}
.page-title {
font-size: 24px;
font-weight: 600;
letter-spacing: -0.02em;
}
.header-meta {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
.log-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-lg);
margin-bottom: var(--space-md);
}
.filter-group {
display: flex;
gap: var(--space-md);
}
.filter-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 6px 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover {
border-color: #555;
color: var(--text-primary);
}
.filter-btn.active {
background: var(--text-primary);
color: var(--bg-main);
border-color: var(--text-primary);
}
.search-box {
position: relative;
flex-grow: 1;
max-width: 300px;
}
.search-input {
width: 100%;
background: #1a1a1a;
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 12px;
outline: none;
}
.search-input:focus {
border-color: #555;
}
.log-container {
border: 1px solid var(--border-color);
background: #0d0d0d;
font-family: var(--font-mono);
}
.log-table {
width: 100%;
border-collapse: collapse;
}
.log-table th {
text-align: left;
font-size: 11px;
color: var(--text-secondary);
padding: var(--space-md);
border-bottom: 1px solid var(--border-color);
text-transform: uppercase;
}
.log-table td {
padding: 12px 16px;
font-size: 12px;
border-bottom: 1px solid #1a1a1a;
vertical-align: top;
}
.log-row:hover {
background-color: #1a1a1a;
}
.col-time { width: 180px; color: var(--text-secondary); }
.col-type { width: 100px; font-weight: bold; }
.col-event { color: var(--text-primary); }
.col-status { width: 100px; text-align: right; }
.tag {
font-size: 10px;
padding: 2px 6px;
border: 1px solid currentColor;
}
.tag-sys { color: #888; }
.tag-sec { color: #ff5f5f; }
.tag-org { color: #5fafff; }
.tag-bak { color: #5fff8a; }
.pagination {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-md);
margin-top: var(--space-md);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
.page-nav {
cursor: pointer;
color: var(--text-primary);
text-decoration: none;
}
.page-nav:hover {
text-decoration: underline;
}
.bracket-label {
font-weight: bold;
color: var(--text-primary);
}
</style>
</head>
<body vid="6">
<div class="app-container" vid="7">
<aside class="sidebar" vid="8">
<div class="logo-container" vid="9">
<div class="logo-circle-full" vid="10"></div>
<div class="logo-circle-half" vid="11"></div>
</div>
<nav vid="12">
<ul class="nav-menu" vid="13">
<li class="nav-item" vid="14"><a href="#" class="nav-link" vid="15">대시</a></li>
<li class="nav-item" vid="16"><a href="#" class="nav-link active" vid="17">활동</a></li>
<li class="nav-item" vid="18"><a href="#" class="nav-link" vid="19">프로</a></li>
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">자매</a></li>
<li class="nav-item" vid="22"><a href="#" class="nav-link" vid="23">조직</a></li>
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">설정</a></li>
<li class="nav-item" vid="26"><a href="#" class="nav-link" vid="27">관리</a></li>
</ul>
</nav>
</aside>
<main class="main-content" vid="28">
<header class="page-header" vid="29">
<h1 class="page-title" vid="30">활동 로그 <span style="color:var(--text-secondary); font-size: 18px;" vid="31">/ ACTIVITY LOG</span></h1>
<div class="header-meta" vid="32">SYS_LOG_V.4.2 // RUNTIME: 428:12:05</div>
</header>
<section class="log-controls" vid="33">
<div class="filter-group" vid="34">
<button class="filter-btn active" vid="35">ALL</button>
<button class="filter-btn" vid="36">SYSTEM</button>
<button class="filter-btn" vid="37">SECURITY</button>
<button class="filter-btn" vid="38">ORG</button>
<button class="filter-btn" vid="39">BACKUP</button>
</div>
<div class="search-box" vid="40">
<input type="text" class="search-input" placeholder="SEARCH LOGS..." vid="41">
</div>
</section>
<div class="log-container" vid="42">
<table class="log-table" vid="43">
<thead vid="44">
<tr vid="45">
<th vid="46">TIMESTAMP</th>
<th vid="47">TYPE</th>
<th vid="48">EVENT DESCRIPTION</th>
<th style="text-align: right;" vid="49">CODE</th>
</tr>
</thead>
<tbody vid="50">
<tr class="log-row" vid="51">
<td class="col-time" vid="52">2023-10-27 14:02:45</td>
<td class="col-type" vid="53"><span class="tag tag-sys" vid="54">SYSTEM</span></td>
<td class="col-event" vid="55">Node <span class="bracket-label" vid="56">[다랑]</span> transitioned to status: <span class="bracket-label" vid="57">STANDBY</span>. Protocol initiated by admin_auth_01.</td>
<td class="col-status" vid="58">0x402</td>
</tr>
<tr class="log-row" vid="59">
<td class="col-time" vid="60">2023-10-27 13:45:12</td>
<td class="col-type" vid="61"><span class="tag tag-org" vid="62">ORG</span></td>
<td class="col-event" vid="63">New sub-directory created under <span class="bracket-label" vid="64">[Organization Beta]</span>. Path mapping complete.</td>
<td class="col-status" vid="65">0x112</td>
</tr>
<tr class="log-row" vid="66">
<td class="col-time" vid="67">2023-10-27 11:30:12</td>
<td class="col-type" vid="68"><span class="tag tag-sec" vid="69">SECURITY</span></td>
<td class="col-event" vid="70">Full security sweep completed for project <span class="bracket-label" vid="71">[DATA_PIPE_OPT]</span>. Zero vulnerabilities identified.</td>
<td class="col-status" vid="72">0x000</td>
</tr>
<tr class="log-row" vid="73">
<td class="col-time" vid="74">2023-10-27 10:15:33</td>
<td class="col-type" vid="75"><span class="tag tag-sys" vid="76">SYSTEM</span></td>
<td class="col-event" vid="77">Memory allocation threshold reached on <span class="bracket-label" vid="78">[나랑]</span>. Auto-scaling provisioned +4GB swap.</td>
<td class="col-status" vid="79">0x284</td>
</tr>
<tr class="log-row" vid="80">
<td class="col-time" vid="81">2023-10-27 09:15:00</td>
<td class="col-type" vid="82"><span class="tag tag-org" vid="83">ORG</span></td>
<td class="col-event" vid="84">Batch import: 42 new identities integrated into <span class="bracket-label" vid="85">[Beta]</span> structure. </td>
<td class="col-status" vid="86">0x105</td>
</tr>
<tr class="log-row" vid="87">
<td class="col-time" vid="88">2023-10-27 08:00:05</td>
<td class="col-type" vid="89"><span class="tag tag-bak" vid="90">BACKUP</span></td>
<td class="col-event" vid="91">Scheduled snapshot <span class="bracket-label" vid="92">[DAILY_BKP_2710]</span> finalized. Target: S3_COLD_STORAGE.</td>
<td class="col-status" vid="93">0x900</td>
</tr>
<tr class="log-row" vid="94">
<td class="col-time" vid="95">2023-10-27 07:42:11</td>
<td class="col-type" vid="96"><span class="tag tag-sec" vid="97">SECURITY</span></td>
<td class="col-event" vid="98">SSH login attempt blocked. IP: 192.168.1.104. Failed attempts: 3. Protocol: BAN_IP.</td>
<td class="col-status" vid="99">0x666</td>
</tr>
<tr class="log-row" vid="100">
<td class="col-time" vid="101">2023-10-27 04:20:00</td>
<td class="col-type" vid="102"><span class="tag tag-sys" vid="103">SYSTEM</span></td>
<td class="col-event" vid="104">Garbage collection cycle completed on all active nodes. Freed 1.2GB.</td>
<td class="col-status" vid="105">0x401</td>
</tr>
<tr class="log-row" vid="106">
<td class="col-time" vid="107">2023-10-27 01:12:45</td>
<td class="col-type" vid="108"><span class="tag tag-sys" vid="109">SYSTEM</span></td>
<td class="col-event" vid="110">Kernel patch 1.2.4 deployed to <span class="bracket-label" vid="111">[하랑]</span>. Restart bypassed via hotpatching.</td>
<td class="col-status" vid="112">0x400</td>
</tr>
</tbody>
</table>
</div>
<footer class="pagination" vid="113">
<span class="page-info" vid="114">SHOWING 1-9 OF 2,410 ENTRIES</span>
<div style="display: flex; gap: var(--space-md);" vid="115">
<span class="page-nav" vid="116">[PREV]</span>
<span style="color: var(--text-primary);" vid="117">PAGE 01</span>
<span class="page-nav" vid="118">[NEXT]</span>
</div>
</footer>
</main>
</div>
</body></html>
```

View File

@@ -0,0 +1,363 @@
The code below contains a design. This design should be used to create a new app or be added to an existing one.
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
Run the dev command so the user can see the app once finished.
```
<html lang="ko" vid="0"><head vid="1">
<meta charset="UTF-8" vid="2">
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
<title vid="4">하나랑 - 시스템 아키텍처</title>
<style vid="5">
:root {
--bg-main: #0a0a0a;
--bg-card: #141414;
--text-primary: #e0e0e0;
--text-secondary: #707070;
--border-color: #262626;
--level-high: #f43f5e;
--level-mid: #3b82f6;
--level-low: #10b981;
--level-none: #525252;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-main);
color: var(--text-primary);
line-height: 1.5;
font-size: 13px;
-webkit-font-smoothing: antialiased;
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
height: 100vh;
}
.sidebar {
width: 64px;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 24px;
background: #0d0d0d;
}
.logo-container {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 48px;
}
.logo-circle-full { width: 16px; height: 16px; background: #fff; border-radius: 50%; }
.logo-circle-half { width: 8px; height: 16px; background: #fff; border-radius: 0 16px 16px 0; margin-left: 8px; }
.nav-item {
color: var(--text-secondary);
font-family: var(--font-mono);
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180px);
margin: 16px 0;
text-decoration: none;
font-size: 11px;
letter-spacing: 1px;
transition: color 0.2s;
}
.nav-item.active { color: #fff; }
.main-viewport {
flex-grow: 1;
overflow: auto;
padding: 40px;
background-image: radial-gradient(circle, #1a1a1a 1px, transparent 1px);
background-size: 30px 30px;
display: flex;
flex-direction: column;
align-items: center;
}
.header-info {
width: 100%;
max-width: 1000px;
display: flex;
justify-content: space-between;
margin-bottom: 60px;
font-family: var(--font-mono);
border-bottom: 1px solid var(--border-color);
padding-bottom: 12px;
}
.tree-container {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.tree-branch {
display: flex;
justify-content: center;
position: relative;
padding-top: 40px;
}
.tree-branch::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: 1px;
height: 40px;
background: var(--border-color);
}
.tree-children {
display: flex;
gap: 32px;
position: relative;
}
.tree-children::before {
content: "";
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 100px);
height: 1px;
background: var(--border-color);
}
.terminal-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-left: 3px solid var(--level-none);
width: 200px;
padding: 12px;
position: relative;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.terminal-card.high { border-left-color: var(--level-high); }
.terminal-card.mid { border-left-color: var(--level-mid); }
.terminal-card.low { border-left-color: var(--level-low); }
.node-label {
font-family: var(--font-mono);
font-weight: 600;
font-size: 13px;
display: block;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-label span {
color: var(--text-secondary);
font-size: 11px;
margin-right: 4px;
}
.badge-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.badge {
font-family: var(--font-mono);
font-size: 9px;
background: #1a1a1a;
color: var(--text-secondary);
padding: 2px 4px;
border: 1px solid #262626;
text-transform: uppercase;
}
.badge b { color: var(--text-primary); font-weight: normal; }
.node-group {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.toggle {
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 16px;
height: 16px;
background: var(--bg-card);
border: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
z-index: 2;
}
</style>
</head>
<body vid="6">
<div class="app-container" vid="7">
<aside class="sidebar" vid="8">
<div class="logo-container" vid="9">
<div class="logo-circle-full" vid="10"></div>
<div class="logo-circle-half" vid="11"></div>
</div>
<a href="#" class="nav-item" vid="12">DASH</a>
<a href="#" class="nav-item" vid="13">PROJ</a>
<a href="#" class="nav-item active" vid="14">ORG_TREE</a>
<a href="#" class="nav-item" vid="15">CONF</a>
</aside>
<main class="main-viewport" vid="16">
<header class="header-info" vid="17">
<div vid="18">SYS_TYPE: <span style="color: #fff;" vid="19">CORE_ROOT_01</span></div>
<div vid="20">TIMESTAMP: <span style="color: var(--text-secondary);" vid="21">2023.10.27_14:42</span></div>
</header>
<div class="tree-container" vid="22">
<div class="node-group" vid="23">
<div class="terminal-card high" vid="24">
<div class="node-label" vid="25"><span vid="26">[HQ]</span> 하나랑 글로벌</div>
<div class="badge-row" vid="27">
<div class="badge" vid="28">MEM: <b vid="29">142</b></div>
<div class="badge" vid="30">S_ADMIN</div>
<div class="badge" vid="31">LV_09</div>
</div>
<div class="toggle" vid="32"></div>
</div>
<div class="tree-branch" vid="33">
<div class="tree-children" vid="34">
<div class="node-group" vid="35">
<div class="terminal-card mid" vid="36">
<div class="node-label" vid="37"><span vid="38">[R&amp;D]</span> 기술연구소</div>
<div class="badge-row" vid="39">
<div class="badge" vid="40">MEM: <b vid="41">48</b></div>
<div class="badge" vid="42">LEAD</div>
<div class="badge" vid="43">LV_07</div>
</div>
<div class="toggle" vid="44"></div>
</div>
<div class="tree-branch" vid="45">
<div class="tree-children" vid="46">
<div class="terminal-card mid" vid="47">
<div class="node-label" vid="48">Core Engine</div>
<div class="badge-row" vid="49">
<div class="badge" vid="50">MEM: <b vid="51">12</b></div>
<div class="badge" vid="52">WRITE</div>
<div class="badge" vid="53">LV_05</div>
</div>
</div>
<div class="terminal-card mid" vid="54">
<div class="node-label" vid="55">Infrastructure</div>
<div class="badge-row" vid="56">
<div class="badge" vid="57">MEM: <b vid="58">36</b></div>
<div class="badge" vid="59">DEPLOY</div>
<div class="badge" vid="60">LV_06</div>
</div>
</div>
</div>
</div>
</div>
<div class="node-group" vid="61">
<div class="terminal-card low" vid="62">
<div class="node-label" vid="63"><span vid="64">[OPS]</span> 운영지원팀</div>
<div class="badge-row" vid="65">
<div class="badge" vid="66">MEM: <b vid="67">94</b></div>
<div class="badge" vid="68">MGR</div>
<div class="badge" vid="69">LV_04</div>
</div>
<div class="toggle" vid="70"></div>
</div>
<div class="tree-branch" vid="71">
<div class="terminal-card low" vid="72">
<div class="node-label" vid="73">Monitoring</div>
<div class="badge-row" vid="74">
<div class="badge" vid="75">MEM: <b vid="76">24</b></div>
<div class="badge" vid="77">READ</div>
<div class="badge" vid="78">LV_02</div>
</div>
</div>
</div>
</div>
<div class="node-group" vid="79">
<div class="terminal-card low" vid="80">
<div class="node-label" vid="81"><span vid="82">[EXT]</span> 외부 협력사</div>
<div class="badge-row" vid="83">
<div class="badge" vid="84">MEM: <b vid="85">12</b></div>
<div class="badge" vid="86">GUEST</div>
<div class="badge" vid="87">LV_01</div>
</div>
<div class="toggle" vid="88"></div>
</div>
<div class="tree-branch" vid="89">
<div class="terminal-card low" vid="90">
<div class="node-label" vid="91">Security Audit</div>
<div class="badge-row" vid="92">
<div class="badge" vid="93">MEM: <b vid="94">04</b></div>
<div class="badge" vid="95">LIMIT</div>
<div class="badge" vid="96">LV_01</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<footer style="margin-top: auto; width: 100%; max-width: 1000px; padding: 24px 0; display: flex; justify-content: space-between; border-top: 1px solid var(--border-color); font-family: var(--font-mono); color: var(--text-secondary); font-size: 10px;" vid="97">
<div vid="98">TOTAL_NODES: 07</div>
<div vid="99">STATUS: SYNC_COMPLETE</div>
</footer>
</main>
</div>
</body></html>
```

View File

@@ -0,0 +1,81 @@
# 활동 로그 (`/activities`) — 디자인 v2
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #5
## 레이아웃
```
┌──────────┬──────────────────────────────────────────────────┐
│ Sidebar │ 활동 로그 / ACTIVITY LOG SYS_LOG_V.4.2 //... │
│ │ ────────────────────────────────────────────── │
│ │ │
│ │ [ALL] [SYSTEM] [SECURITY] [ORG] [BACKUP] 🔍... │
│ │ │
│ │ ┌──────────────────────────────────────────────┐│
│ │ │ TIMESTAMP TYPE EVENT CODE││
│ │ │ 2023-10-27 14:02 SYSTEM Node [다랑] 0x402││
│ │ │ 2023-10-27 13:45 ORG New sub-dir 0x112││
│ │ │ 2023-10-27 11:30 SECURITY Full sweep 0x000││
│ │ │ 2023-10-27 10:15 SYSTEM Memory alloc 0x284││
│ │ │ 2023-10-27 09:15 ORG Batch import 0x105││
│ │ │ 2023-10-27 08:00 BACKUP Snapshot 0x900││
│ │ │ 2023-10-27 07:42 SECURITY SSH blocked 0x666││
│ │ │ 2023-10-27 04:20 SYSTEM GC cycle 0x401││
│ │ │ 2023-10-27 01:12 SYSTEM Kernel patch 0x400││
│ │ └──────────────────────────────────────────────┘│
│ │ SHOWING 1-9 OF 2,410 [PREV] 01 [NEXT]│
└──────────┴──────────────────────────────────────────────────┘
```
## 네비게이션 변경
- 사이드바에 **"활동"** 메뉴 추가 (대시 다음)
## 페이지 헤더
- page-title: "활동 로그" (24px 600) + ` / ACTIVITY LOG` (18px secondary)
- header-meta: mono 11px, "SYS_LOG_V.4.2 // RUNTIME: 428:12:05"
- border-bottom 1px
## 필터 + 검색 (Log Controls)
- `justify-content: space-between; align-items: center`
- **filter-group**: 가로 flex, gap 16px
- filter-btn: 11px 700 uppercase, border 1px
- active: 흰색 배경 + 검정 텍스트
- 옵션: ALL / SYSTEM / SECURITY / ORG / BACKUP
- **search-box**: max-width 300px, flex-grow
- search-input: `#1a1a1a` 배경, border 1px, mono 12px
- placeholder: "SEARCH LOGS..."
## 로그 테이블 (Log Container)
- 외곽: border 1px, `background: #0d0d0d`
- table: 전체 너비, border-collapse
- **th**: 11px uppercase secondary, padding 16px, border-bottom 1px
- **td**: 12px mono, padding 12px 16px, border-bottom `#1a1a1a`
- **hover**: 배경 `#1a1a1a`
### 컬럼
| 컬럼 | 너비 | 스타일 |
|------|------|--------|
| TIMESTAMP | 180px | secondary |
| TYPE | 100px | bold + tag |
| EVENT | auto | primary, `[이름]` bold 강조 |
| CODE | 100px | right align |
### 태그 (Tag)
- 10px, padding 2px 6px, border 1px currentColor
- SYSTEM: `#888`
- SECURITY: `#ff5f5f`
- ORG: `#5fafff`
- BACKUP: `#5fff8a`
## 페이지네이션
- `justify-content: flex-end` / mono 11px
- "SHOWING 1-9 OF 2,410 ENTRIES"
- `[PREV]` `PAGE 01` `[NEXT]`
- PREV/NEXT: primary 색, hover underline
## 모바일 반응형
- filter-group: 가로 스크롤 또는 드롭다운 변환
- search-box: 풀 너비
- log-table: 가로 스크롤 wrapper 또는 카드 형태 변환
- 카드: 타임스탬프 + 태그 상단, 이벤트 본문, 코드 하단
- 페이지네이션: 간소화 (< 01 >)

View File

@@ -1,47 +1,47 @@
# 관리자 페이지 — UI 디자인 (`/admin/*`) # 관리자 페이지 (`/admin/*`) — 디자인 v2
## 자매 관리 (`/admin/sisters`) > 참조: DESIGN-SYSTEM.md
> 관리자 페이지는 기존 Sprint 004에서 구현된 기능을 v2 디자인 시스템에 맞춰 리스타일링.
> 별도 디자인 파일이 없으므로, 다른 페이지의 패턴을 조합.
``` ## 페이지 목록
┌──────────────────────────────────────┐ - `/admin` — 자매 관리 (재시작/리셋)
│ 자매 관리 │ - `/admin/harness` — 하네스 파일 편집
├──────────────────────────────────────┤ - `/admin/logs` — 로그 뷰어
│ ┌─────────────────────────────────┐ │ - `/admin/costs` — 비용 대시보드
│ │ 하랑이 [온라인] [재시작] [리셋] │ │ - `/admin/repos` — 저장소 관리
│ │ 나랑이 [온라인] [재시작] [리셋] │ │
│ │ 다랑이 [오프라인] [재시작] [리셋] │ │
│ │ 이랑이 [온라인] [재시작] [리셋] │ │
│ └─────────────────────────────────┘ │
└──────────────────────────────────────┘
```
- 각 자매 행: 이름 + 상태 + 액션 버튼 ## 공통 스타일
- 재시작/리셋 버튼은 확인 모달 필수 - page-header: 제목 28px + label-meta (ADMIN PANEL)
- 실시간 상태 업데이트 - section-title 패턴 동일
- 모든 입력/버튼: DESIGN-SYSTEM.md의 btn, bracket-input, switch 사용
- 인증 필요 표시: 상단에 `label-meta``AUTH: BEARER TOKEN REQUIRED`
## 하네스 편집 (`/admin/harness`) ## /admin — 자매 관리
- node-mini-card 스타일 (프로젝트 상세의 ASSIGNED NODES와 동일)
- 각 자매: 이름 + 상태 도트 + btn-toggle (RESTART / RESET)
- 결과 표시: audit-row 스타일 (타임스탬프 + 액션 + 결과)
- 좌측: 파일 트리 (자매 선택 → 파일 선택) ## /admin/harness — 하네스 편집
- 우측: 코드 에디터 (Monaco Editor 또는 CodeMirror) - 드롭다운: 자매 선택 + 파일 선택
- 저장 버튼 → SSH로 파일 쓰기 + SSOT push - 텍스트 에디터: `#0d0d0d` 배경, mono 12px, border 1px
- 변경 사항 diff 표시 - 하단: btn (취소) + btn-primary (저장)
## 로그 뷰어 (`/admin/logs`) ## /admin/logs — 로그 뷰어
- activity-log-design.md의 로그 테이블 패턴 활용
- 자매 필터 + lines 제한
- 실시간 스트림: mono 텍스트, 자동 스크롤
- 자매 선택 탭 ## /admin/costs — 비용 대시보드
- 실시간 로그 스트림 (terminal 스타일) - 상단: 요약 카드 3개 (card 스타일)
- 검색 + 에러 필터 - 총 토큰 / 총 비용 / 평균 일일 비용
- 다크 터미널 배경 - bracket-value로 표시
- 중앙: BarChart (graph-container 스타일 확장)
- 일별 바, highlight 최근
- border-left + border-bottom
- 하단: 기간 필터 (filter-btn 스타일)
## 비용 모니터 (`/admin/costs`) ## 모바일 반응형
- 카드/행: 1열 수직 스택
- 모델별 토큰 사용량 차트 (bar chart) - 텍스트 에디터: 풀 너비, 높이 축소
- 자매별 사용량 비교 - 비용 차트: 가로 스크롤
- 기간 필터 (일/주/월)
- 예상 비용 계산
## 공통 컴포넌트
- `<ConfirmModal />` — 확인 모달
- `<CodeEditor />` — 코드 에디터
- `<LogTerminal />` — 터미널 스타일 로그 뷰어
- `<CostChart />` — 비용 차트

View File

@@ -1,67 +1,58 @@
# 메인 대시보드 — UI 디자인 (`/`) # 대시보드 메인 (`/`) — 디자인 v2
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #1
## 레이아웃 ## 레이아웃
``` ```
┌─────────────────────────────────────────────────────┐ ┌──────────┬─────────────────────────────────────────────┐
[사이드바] [메인 콘텐츠] │ 하나랑 대시보드
┌──────┐ ┌────────────────────────────────────┐ ●◗ │
│ 로고 │ 헤더: "하나랑 대시보드" │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ │ ├────────────────────────────────────┤ [대시] │ │SYS: │ │SYS: │ │SYS: │ │SYS: │
│ 메뉴 │ [자매 상태 카드 4개 - 가로 배치] │ 활동 │ │하랑 │나랑 │ │다랑 │ │이랑 │
│ - 대시 │ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ 프로 │ │[ON] │ │[84] │ │[--] │ │[12] │
│ - 프로 │ │ │하랑 │ │나랑 │ │다랑 │ │이랑 자매 │ │▬▬▬▬▬ │ │▬▬▬▬░ │ │░░░░░ │ │▬░░░░ │ │
│ - 자매 │ │ └────┘ └────┘ └────┘ └────┘ 조직 └──────┘ └────┘ └────┘ └──────┘ │
│ - 조직 │ ├────────────────────────────────────┤ 설정 │
│ - 설정 │ │ [진행 중 프로젝트] [최근 활동 피드] 관리 │ ONGOING PROJECTS ACTIVITY FEED
│ - 관리 │ │ 카드 리스트 타임라인 ───────────────── ──────────────
└──────┘ └────────────────────────────────────┘ │ ● 데이터 파이프라인 PHASE 2 │ 14:02:45
└─────────────────────────────────────────────────────┘ │ │ ● 새로운 자매 노드 INIT │ System... │
│ │ ● 관리자 권한 체계 REVIEW │ 11:30:12 │
│ │ │ Security... │
└──────────┴─────────────────────────────────────────────┘
``` ```
## 자매 상태 카드 ## 상태 카드 (Status Grid)
- **크기:** 가로 4등분 (반응형: 태블릿 2열, 모바일 1열) - `grid-template-columns: repeat(4, 1fr)` / gap: 24px
- **내용:** - 각 카드: `card` 컴포넌트 (DESIGN-SYSTEM.md 참조)
- 이름 + 역할 (예: "하랑이 · Orchestrator") - 구조:
- 상태 뱃지 (온라인/오프라인/작업중) + 상태 색상 dot - header: `label-meta``SYS:` (secondary) + 이름 (primary)
- 마지막 활동 시간 - body: `bracket-value``[ON]`, `[84]`, `[--]`, `[12]`
- 현재 작업 요약 (있으면) - sub-label: Active Node / Capacity % / Standby / Sync Queue
- **스타일:** - tech-bar: 2px 진행 바 (100% / 84% / 0% / 12%)
- glassmorphism 카드 (반투명 배경 + blur) - standby 카드: bracket-value `color: var(--text-secondary)`
- 호버 시 살짝 확대 + 그림자 강화
- 상태별 좌측 보더 색상: 온라인=#00E676, 오프라인=#FF1744, 작업중=#2979FF
## 진행 중 프로젝트 섹션 ## 하단 2열 (Data Columns)
- 카드 리스트: 프로젝트명 + 현재 Sprint + 진행률 바 - `grid-template-columns: 1.5fr 1fr` / gap: 64px
- 클릭 시 `/projects/:id`로 이동
## 최근 활동 피드 ### 좌: ONGOING PROJECTS
- 타임라인 형태 - section-title: `ONGOING PROJECTS` + `VOL: 04`
- 각 항목: 시간 + 자매 아이콘 + 활동 내용 - project-row: `grid-template-columns: 40px 1fr auto`
- 예: "10:47 🦊 하랑이 — Sprint 001 핸드오프 전달" - 아바타: 32px 원형, grayscale, SVG placeholder
- 프로젝트명: 15px 500
- 설명: 13px secondary
- 상태 라벨: label-meta (PHASE 2 / INIT / REVIEW)
## 사이드바 ### 우: ACTIVITY FEED
- 고정 사이드바 (접기 가능) - section-title: `ACTIVITY FEED` + `LIVE`
- 로고 + 메뉴 아이콘 - timeline 컴포넌트 (세로선 + 도트 + 타임스탬프 + 내용)
- 현재 페이지 하이라이트 - 타임스탬프: mono 11px, `14:02:45 GMT+9`
- 하단: 관리자 메뉴 (접이식) - 내용: 13px, bold로 시스템명 강조
## 색상 (다크 테마) ## 모바일 반응형
| 용도 | 색상 | - 상태 카드: `repeat(2, 1fr)``1fr`
|------|------| - data-columns: 1열 수직 스택 (프로젝트 먼저, 활동 아래)
| 배경 | #0D1117 | - bracket-value: 24px
| 카드 배경 | rgba(22, 27, 34, 0.8) | - 패딩 축소
| 텍스트 (주) | #E6EDF3 |
| 텍스트 (부) | #8B949E |
| 액센트 | #58A6FF |
| 보더 | rgba(240, 246, 252, 0.1) |
| 온라인 | #00E676 |
| 오프라인 | #FF1744 |
| 작업중 | #2979FF |
## 컴포넌트
- `<SisterCard />` — 자매 상태 카드
- `<ProjectCard />` — 프로젝트 카드
- `<ActivityFeed />` — 활동 피드 타임라인
- `<Sidebar />` — 사이드바 네비게이션
- `<StatusBadge />` — 상태 뱃지 (색상 dot + 텍스트)

View File

@@ -1,32 +1,97 @@
# 조직도 — UI 디자인 (`/org`) # 조직도 (`/org`) — 디자인 v2
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #6
## 레이아웃 ## 레이아웃
``` ```
┌──────────────────────────────────────────────────┐ ┌─────┬──────────────────────────────────────────────────────┐
자기야 64px│ SYS_TYPE: CORE_ROOT_01 2023.10.27_14:42
│ │ ────────────────────────────────────────────────── │
│ │ │ │ │ │
│ ┌────────────┐ │ DASH│ ┌─────────────────┐ │
│ │ 하랑이 │ │ PROJ│[HQ] 하나랑 글로벌 │ │
Orchestrator │ │ ◗ORG │MEM:142 S_ADMIN │ │
│ └────────────┘ │ CONF│ └────────┬────────┘ │
│ ┌───────────┼───────────┐ │ ┌───────────┼───────────┐
│ ┌─────┴─────┐ ┌───┴────┐ ┌───┴───┐ │ │ ┌─────┴───── ┌─┴────┐ ┌───┴───┐
│ │ 나랑이 │ │ 다랑이 │ │ 이랑이 │ │ │[R&D] 기술연구│ │[OPS] │ │[EXT]
│ │ Generator │ │Evaluator│ │ Infra │ │ MEM:48 LEAD │ │운영팀 │ │외부협력│
│ └─────┬───── └───┬────┘ └──────┘ │ │ └─────┬──────┘ └─────┘ └──────┘
서브에이전트 서브에이전트 │ ┌─────┼─────┐ │ │
└────────────────────────────────────────────────── │ │ ┌────┐ ┌┴─────┐ ┌─────┐ ┌───────┐ │
│ │ │Core │ │Infra │ │Monitor│ │Security│ │
│ │ │Eng. │ │struct│ │ing │ │Audit │ │
│ │ └─────┘ └──────┘ └──────┘ └────────┘ │
│ │ │
│ │ TOTAL_NODES: 07 STATUS: SYNC_COMPLETE │
└─────┴──────────────────────────────────────────────────────┘
``` ```
## 시각화 ## 사이드바 (조직도 전용)
- 트리 구조 (위→아래) - **64px 너비** (다른 페이지의 240px과 다름)
- 각 노드: glassmorphism 카드 - 세로 텍스트: `writing-mode: vertical-rl`
- 상태 색상 적용 - 아이콘 모드: DASH / PROJ / ORG_TREE / CONF
- 파이프라인 흐름 화살표: 하랑이 → 나랑이 ↔ 다랑이 → 이랑이 - 로고: 세로 배치 (circle-full 위, circle-half 아래)
- 호버 시 해당 자매 정보 팝업
## 컴포넌 ## 메인 뷰포
- `<OrgTree />` — 조직도 트리 - 배경: dot grid pattern — `radial-gradient(circle, #1a1a1a 1px, transparent 1px)` / 30px 30px
- `<OrgNode />` — 개별 노드 카드 - `overflow: auto` (스크롤 가능)
- `<PipelineArrow />` — 파이프라인 화살표 - padding: 40px, 중앙 정렬
## 헤더
- 좌: `SYS_TYPE: CORE_ROOT_01` (mono, 값은 흰색)
- 우: `TIMESTAMP: 2023.10.27_14:42` (mono secondary)
- border-bottom 1px
## 트리 구조
### Terminal Card
- 200px 너비, padding 12px
- `border: 1px solid var(--border-color)`
- **border-left: 3px solid** (레벨별 색상)
- high (`#f43f5e`): HQ
- mid (`#3b82f6`): R&D, Core Engine, Infrastructure
- low (`#10b981`): OPS, EXT, Monitoring, Security Audit
- `box-shadow: 0 10px 30px rgba(0,0,0,0.5)`
- node-label: mono 13px 600
- 접두사 span: `[HQ]` `[R&D]` `[OPS]` `[EXT]` — secondary 11px
- badge-row: flex wrap, gap 6px
- badge: mono 9px, `#1a1a1a` bg, border 1px `#262626`
- `MEM: 142` (값은 primary)
- `S_ADMIN`, `LEAD`, `MGR`, `GUEST`
- `LV_09`, `LV_07`
### 접기/펼치기 토글
- `` / `+` 아이콘, 16x16px
- 카드 하단 중앙 absolute 배치
- bg: card bg, border 1px
### 연결선
- 세로선: `width: 1px; background: var(--border-color)`
- 가로선 (형제 연결): children `::before` — 상단 -40px
### 트리 구조
```
하나랑 글로벌 (HQ, high)
├── 기술연구소 (R&D, mid)
│ ├── Core Engine (mid)
│ └── Infrastructure (mid)
├── 운영지원팀 (OPS, low)
│ └── Monitoring (low)
└── 외부 협력사 (EXT, low)
└── Security Audit (low)
```
## 푸터
- `margin-top: auto; border-top: 1px solid`
- 좌: `TOTAL_NODES: 07`
- 우: `STATUS: SYNC_COMPLETE`
- mono 10px secondary
## 모바일 반응형
- 사이드바: 하단 탭바로 전환
- 트리: 수평 스크롤 컨테이너 + 핀치 줌
- terminal-card: 너비 160px으로 축소
- badge 폰트: 8px
- dot grid: 20px 간격으로 축소

View File

@@ -1,47 +1,69 @@
# 프로젝트 상세 — UI 디자인 (`/projects/:id`) # 프로젝트 상세 (`/projects/[id]`) — 디자인 v2
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #2
## 레이아웃 ## 레이아웃
``` ```
┌──────────────────────────────────────────────┐ ┌──────────┬────────────────────────────────────────────┐
[사이드바] [메인 콘텐츠] Sidebar │ PROJECTS / P-402 / SUMMARY
┌──────────────────────────────┐ 데이터 파이프라인 최적화 STATUS: ACTIVE/P2
│ 헤더: 프로젝트명 + Gitea 링크 │ │ ─────────────────────────────────────────
├──────────────────────────────┤
│ [탭: Sprint | Tasks | Plans] │ │ PHASE TIMELINE │ ASSIGNED NODES
────────────────────────────── ─────────────────────────
│ Sprint 목록 (아코디언) │ │ P1: 아키텍처 분석 ✓ │ 하랑 [PRIMARY] ●
│ ┌─ Sprint 001 ✅ ──────────┐ │ ●P2: 실시간 동기화 │ 나랑 [SECONDARY]●
TASK-001 ✅ done │ │ │ P3: 스트레스 테스트
│ │ TASK-002 ✅ done │ │ │ │ TASK CHECKLIST
│ │ TASK-003 ✅ done │ │ │ SECURITY AUDIT LOG │ ─────────────
│ └──────────────────────────┘ │ ─────────────── │ ■ 인프라 요구사항
│ ┌─ Sprint 002 🔄 진행중 ───┐ │ │ 14:02 CRYPTO_KEY PASS │ ■ 보안 프로토콜
│ │ TASK-004 🔵 in_progress │ │ 11:30 CODE_SCAN PASS │ □ 캐시 무효화
│ │ TASK-005 ⏳ pending │ │ 09:15 ACCESS_LOG WARN │ □ 부하 분산
│ │ └──────────────────────────┘ │ │ └──────────┴────────────────────────────────────────────┘
│ └──────────────────────────────┘ │
└──────────────────────────────────────────────┘
``` ```
## Sprint 섹션 ## 헤더
- 아코디언: Sprint 번호 + 이름 + 상태 아이콘 - breadcrumb: mono 11px uppercase, secondary
- 펼치면 Task 목록 - page-title-row: 제목(28px) + STATUS label-meta
- 진행률 바 (done/total)
- Sprint 상태: pending(⏳), in_progress(🔄), review(🔍), done(✅), failed(❌)
## Task Ledger 탭 ## 본문 그리드
- 테이블: Task ID | 제목 | 담당 | 상태 | 반복 횟수 - `grid-template-columns: 1fr 320px` / gap: 64px
- 상태 필터링
- 상태별 색상 뱃지
## .plans/ 뷰어 탭 ### 좌: Phase Timeline + Audit Log
- 파일 트리 + 마크다운 렌더러
- Gitea raw URL에서 실시간 로드
## 컴포넌트 #### Phase Timeline
- `<SprintAccordion />` — Sprint 아코디언 - phase-item: `grid-template-columns: 120px 1fr`
- `<TaskTable />` — Task 테이블 - 왼쪽: phase-meta — mono 11px, "COMPLETED / 2023.10.12"
- `<PlansViewer />` — .plans/ 마크다운 뷰어 - 오른쪽: phase-box — `border-left: 2px solid`
- `<ProgressBar />` — 진행률 바 - active: border 흰색 + 8px 원형 도트
- `<TabNav />` — 탭 네비게이션 - 완료/대기: border `--border-color`
- phase-name: 600 weight
- phase-desc: 13px secondary
#### Security Audit Log
- section-title: `SECURITY AUDIT LOG` + `LAST CHECK: 4H AGO`
- audit-log: 1px gap 줄무늬 (배경 #333)
- audit-row: `grid-template-columns: 100px 1fr 80px`
- header row: secondary uppercase 700
- status-tag: 10px, border 1px
- `.pass`: border #00FF00, color #00FF00
- 기본: border #333
### 우: Assigned Nodes + Task Checklist
#### Assigned Nodes
- node-mini-card: border 1px, 가로 정렬 (이름 + 녹색 도트)
- 녹색 도트: 6x6px, `#00FF00`
#### Task Checklist
- check-item: mono 12px, flex 가로
- check-box: 14x14px, border 1px
- checked: `■` 10px 텍스트
- done 항목: secondary + line-through
## 모바일 반응형
- project-grid: 1열 수직 스택 (좌측 먼저, 우측 아래)
- phase-item: 1열 (meta 위, box 아래)
- audit-row: 가로 스크롤 또는 카드 변환

View File

@@ -0,0 +1,65 @@
# 설정 (`/settings`) — 디자인 v2
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #4
## 레이아웃
```
┌──────────┬────────────────────────────────────────────────┐
│ Sidebar │ 시스템 설정 CONFIG: v2.0.4-STABLE │
│ │ │
│ │ ADMIN PROTOCOL RULES │ BACKUP & REDUNDANCY │
│ │ ───────────────── │ ────────────────── │
│ │ 강제 2FA [●] │ 자동 백업 [●] │
│ │ 세션 유효 시간 [ 15 ] │ 백업 주기 [08:00] │
│ │ IP 화이트리스트 [○] │ 보관 주기 [ 30 ]│
│ │ │ │
│ │ SYNC QUEUE THRESHOLDS │ NODE ALERT TRIGGERS │
│ │ ───────────────── │ ────────────────── │
│ │ 대기열 경고 [ 500 ] │ CPU 임계값 [ 90 ]│
│ │ 자동 스케일링 [●] │ 레이턴시 경보 [ 250 ] │
│ │ │ 노드 오프라인 [●] │
│ │ │
│ │ [초기화] [설정 저장] │
└──────────┴────────────────────────────────────────────────┘
```
## 페이지 헤더
- page-title: "시스템 설정" (28px 600)
- label-meta: `CONFIG: v2.0.4-STABLE`
## 설정 그리드
- `grid-template-columns: repeat(2, 1fr)` / gap: 64px
### 섹션 구조 (4개)
1. **ADMIN PROTOCOL RULES** (SEC: 01)
2. **BACKUP & REDUNDANCY** (SEC: 02)
3. **SYNC QUEUE THRESHOLDS** (SEC: 03)
4. **NODE ALERT TRIGGERS** (SEC: 04)
### 설정 행 (Settings Row)
- `justify-content: space-between; align-items: center`
- `border-bottom: 1px solid #222`
- 좌: setting-label (14px 500) + setting-desc (12px secondary)
- 우: Toggle Switch 또는 Bracket Input
### Toggle Switch
- 34x18px
- unchecked: `#2a2a2a` 배경, border `#333`, dot `#8A8A8A`
- checked: `#151515` 배경, border `#F5F5F5`, dot `#F5F5F5`, translate 16px
### Bracket Input
- `[ value ]` 형태
- `--font-mono` 14px, transparent bg, center text
- 포커스: 흰색 텍스트
- 너비: 60px
## Action Bar
- `margin-top: auto; justify-content: flex-end`
- 초기화: 기본 btn (투명 + 보더)
- 설정 저장: btn-primary (흰색 배경, hover 반전)
- 12px 600 uppercase
## 모바일 반응형
- settings-grid: 2열 → 1열
- action-bar: 풀 너비 버튼 또는 sticky bottom

View File

@@ -1,45 +1,69 @@
# 자매 상세 — UI 디자인 (`/sisters/:name`) # 자매 노드 관리 (`/sisters`) — 디자인 v2
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #3
## 레이아웃 ## 레이아웃
``` ```
┌──────────────────────────────────────────────┐ ┌──────────┬──────────────────────────────────────────────┐
[사이드바] [메인 콘텐츠] Sidebar │ 자매 노드 관리 TOTAL NODES: 04
────────────────────────────── ─────────────────────────────────────────── │
│ 헤더: 자매 이름 + 상태 뱃지 │
│ 역할 + IP + LXC 정보 │ ● 하랑 (Primary) │ CAPACITY 24H │ SYNC LOG
├──────────────────────────────┤ Uptime: 342:12 │ ▁▂▃▂▄▆█▇ │ 14:05 ✓
[탭: 개요 | 설정 | 세션 | 서브] │ [ACTIVE][REBOOT]│ LOAD: 84.2% │ 13:55 ✓
────────────────────────────── ─────────────────────────────────────────── │
│ 개요 탭: │ │ ● 나랑 (Secondary)│ CAPACITY 24H │ SYNC LOG
│ - 상태 카드 (큰 버전) │ Uptime: 112:44 │ ▁▁▁▂▂▃▃▃14:02 ✓
│ - 최근 활동 타임라인 │ │ [ACTIVE][REBOOT]│ LOAD: 48.7% │ 13:51 ✓
│ - 현재 작업 │ │ ───────────────────────────────────────────
└──────────────────────────────┘ ○ 다랑 (Standby) │ CAPACITY 24H │ SYNC LOG
└──────────────────────────────────────────────┘ │ │ Last: 02:15 ago │ ░░░░░░░░ │ IDLE │
│ │ [STANDBY][ACTIV]│ LOAD: 0.0% │ IDLE │
│ │ ─────────────────────────────────────────── │
│ │ ● 이랑 (Sync) │ CAPACITY 24H │ SYNC LOG│
│ │ Queue: 12 PKTS │ ▁▂▁▂▃▂▁▁ │ SYNCING │
│ │ [SYNCING][FLUSH]│ LOAD: 15.2% │ SYNCING │
└──────────┴──────────────────────────────────────────────┘
``` ```
## 탭별 내용 ## 페이지 헤더
- page-title: "자매 노드 관리" (28px 600)
- label-meta: `TOTAL NODES: 04`
- border-bottom 1px
### 개요 탭 ## 노드 엔트리 (Node Entry)
- 큰 상태 카드 (상세 정보 포함) - `grid-template-columns: 240px 1fr 300px` / gap: 64px
- 최근 활동 5개 - `border-bottom: 1px solid var(--border-color)` / padding-bottom: 40px
- 현재 진행 중인 Task
### 설정 탭 ### 좌: Node Info (240px)
- openclaw.json 내용 (코드 뷰어, read-only) - node-name: 20px 600 + status-indicator
- 모델, 채널, 플러그인 정보 - active: 8px 흰색 원 + glow shadow
- inactive: 8px `#555`
- meta-pair: label-meta + val (16px mono)
- 하랑: Uptime 342:12:05
- 다랑: Last Active 02:15:00 ago
- 이랑: Queue Depth 12 PKTS
- node-controls: btn-toggle 2개
- active 상태: 흰색 배경 + 검정 텍스트
- 비active: 투명 + 보더
### 세션 탭 ### 중: Capacity Section
- 최근 세션 목록 (시간, 메시지 수, 토큰 사용량) - label-meta: "CAPACITY HISTORY (24H)"
- 세션 클릭 시 로그 뷰어 - graph-container: 60px 높이, flex-end
- graph-bar: 등분, `#333` 기본
- highlight: 최근 2개 바 `var(--text-primary)`
- border-left + border-bottom 1px
- sync-row: "LOAD INDEX" + 값 (mono)
### 서브에이전트 탭 ### 우: Sync History (300px)
- 서브에이전트 목록 + 실행 이력 - label-meta: "SYNC LOG"
- 성공/실패 비율 - sync-row: 타임스탬프 + 상태 (mono 11px)
- 성공: `var(--text-primary)` 색 "SUCCESS"
- IDLE: secondary
- SYNCING: primary
## 컴포넌트 ## 모바일 반응형
- `<SisterHeader />` — 자매 헤더 (이름 + 상태 + 정보) - node-entry: 3열 → 수직 스택 (info → capacity → sync)
- `<ConfigViewer />` — JSON 코드 뷰어 - graph-container: 높이 유지, 너비 100%
- `<SessionList />` — 세션 목록 - btn-toggle: 가로 나란히
- `<SubagentList />` — 서브에이전트 목록

View File

@@ -0,0 +1,60 @@
# SPRINT-REDESIGN QA Review — Iteration 1
- **검증일시:** 2026-04-04 04:13 UTC
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install | ✅ |
| npm run build | ✅ 14 routes |
| 백엔드 변경 | 없음 (FE only) |
## DESIGN-SYSTEM.md 충실도
| 디자인 토큰 | 코드 일치 |
|-------------|-----------|
| 색상 (--bg-main, --text-primary, --border-color 등) | ✅ |
| 폰트 (--font-sans, --font-mono) | ✅ |
| 스페이싱 (--space-xs ~ --space-xxl) | ✅ |
| Sidebar 240px / 64px / 하단 탭바 | ✅ |
| 로고 ● + ◗ | ✅ |
| 네비 [브라켓] active 표기 | ✅ |
| Card 1px border + hover | ✅ |
| TechBar 2px | ✅ |
| Timeline 세로선 + 7px dot | ✅ |
| BracketValue 32px → 24px 모바일 | ✅ |
| LabelMeta 11px 700 uppercase | ✅ |
## 반응형 3단계
| Breakpoint | 확인 |
|------------|------|
| ≥1200px Desktop | ✅ 기본 레이아웃 |
| 768-1199px Tablet | ✅ Sidebar 64px 아이콘, 카드 2열 |
| ≤767px Mobile | ✅ 하단 탭바 56px, 카드 1열(2→1), padding-bottom 72px |
## 새 페이지/컴포넌트
| 항목 | 확인 |
|------|------|
| /activities 페이지 | ✅ |
| /projects 목록 페이지 | ✅ |
| components/ui/base.tsx 공통 컴포넌트 | ✅ |
| CSS vars 전면 적용 (theme.ts 하드코딩 대체) | ✅ |
## Non-blocking 이슈
| ID | 이슈 |
|----|------|
| N1 | 일부 admin 컴포넌트(BarChart, CodeEditor 등)에서 old theme import 잔존 가능 — 빌드 통과했으므로 미사용 import만 정리하면 됨 |
| N2 | SidebarContext가 아직 존재하나 LayoutShell에서 Provider 감싸지 않음 — 사용 안 되면 삭제 권장 |
## 판정 근거
- FE 빌드 14 routes 전부 통과
- DESIGN-SYSTEM.md 디자인 토큰 전부 충실히 구현
- 반응형 3단계 breakpoint 코드 확인
- glassmorphism → 미니멀 터미널 UI 전환 완료
- 백엔드 변경 없어 기능 회귀 위험 없음

View File

@@ -0,0 +1,303 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
import { API_URL } from '@/lib/config';
// ─── Styled ───
const Header = styled.div`
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-md);
margin-bottom: var(--space-md);
`;
const HeaderMeta = styled.div`
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
`;
const LogControls = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-lg);
margin-bottom: var(--space-md);
flex-wrap: wrap;
`;
const FilterGroup = styled.div`
display: flex;
gap: var(--space-md);
flex-wrap: wrap;
`;
const SearchBox = styled.div`
position: relative;
flex-grow: 1;
max-width: 300px;
`;
const SearchInput = styled.input`
width: 100%;
background: #1a1a1a;
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 12px;
outline: none;
&:focus { border-color: var(--border-hover); }
&::placeholder { color: var(--text-secondary); }
`;
const LogContainer = styled.div`
border: 1px solid var(--border-color);
background: #0d0d0d;
font-family: var(--font-mono);
overflow-x: auto;
`;
const LogTable = styled.table`
width: 100%;
border-collapse: collapse;
`;
const Th = styled.th`
text-align: left;
padding: 10px 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
white-space: nowrap;
`;
const Tr = styled.tr`
&:hover td { background: #1a1a1a; }
border-bottom: 1px solid #1a1a1a;
`;
const Td = styled.td`
padding: 10px 12px;
font-size: 12px;
color: var(--text-secondary);
vertical-align: top;
`;
const TdTime = styled(Td)`
white-space: nowrap;
font-size: 11px;
`;
const TdEvent = styled(Td)`
color: var(--text-primary);
`;
const Tag = styled.span<{ $type: string }>`
display: inline-block;
padding: 2px 6px;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
background: #1a1a1a;
border: 1px solid;
white-space: nowrap;
${({ $type }) => {
switch ($type) {
case 'SECURITY': return 'color: #ff5f5f; border-color: #ff5f5f44;';
case 'ORG': return 'color: #5fafff; border-color: #5fafff44;';
case 'BACKUP': return 'color: #5fff8a; border-color: #5fff8a44;';
default: return 'color: #888; border-color: #44444444;';
}
}}
`;
const BracketLabel = styled.span`
color: var(--text-primary);
font-weight: 600;
`;
const Pagination = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md);
border-top: 1px solid var(--border-color);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
`;
const PaginationBtns = styled.div`
display: flex;
gap: var(--space-sm);
`;
const PageBtn = styled.button<{ $active?: boolean }>`
background: ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
border: 1px solid var(--border-color);
color: ${({ $active }) => $active ? '#000' : 'var(--text-secondary)'};
padding: 4px 10px;
font-size: 11px;
cursor: pointer;
font-family: var(--font-mono);
transition: all 0.15s;
&:hover {
border-color: var(--border-hover);
color: ${({ $active }) => $active ? '#000' : 'var(--text-primary)'};
}
`;
// ─── Tag helpers ───
function getTagType(action: string): string {
if (/security|auth|key|guard|block/i.test(action)) return 'SECURITY';
if (/org|sister|node|role/i.test(action)) return 'ORG';
if (/backup|snapshot|save/i.test(action)) return 'BACKUP';
return 'SYSTEM';
}
function formatDetail(detail: string | null, action: string): React.ReactNode {
if (!detail) return action;
// bracket [텍스트] 하이라이트
const parts = detail.split(/(\[.*?\])/g);
return parts.map((part, i) =>
part.startsWith('[') ? <BracketLabel key={i}>{part}</BracketLabel> : part,
);
}
const PAGE_SIZE = 20;
const ALL_TYPES = ['ALL', 'SYSTEM', 'SECURITY', 'ORG', 'BACKUP'];
export default function ActivitiesPage() {
const [items, setItems] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [filter, setFilter] = useState('ALL');
const [search, setSearch] = useState('');
useEffect(() => {
const load = async () => {
try {
const res = await fetch(`${API_URL}/api/activity?limit=200&offset=0`);
if (res.ok) {
const d = await res.json();
setItems(d.items ?? []);
setTotal(d.total ?? 0);
}
} catch { /* silent */ }
};
load();
const iv = setInterval(load, 30000);
return () => clearInterval(iv);
}, []);
const filtered = items.filter((item) => {
const tagType = getTagType(item.action);
if (filter !== 'ALL' && tagType !== filter) return false;
if (search) {
const q = search.toLowerCase();
const text = `${item.action} ${item.detail ?? ''} ${item.sister?.name ?? ''}`.toLowerCase();
if (!text.includes(q)) return false;
}
return true;
});
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paged = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const runtimeStr = (() => {
const h = Math.floor(total * 0.5);
return `SYS_LOG_V.4.2 // ENTRIES: ${total}`;
})();
return (
<>
<Header>
<PageTitle>
<span style={{ color: 'var(--text-secondary)', fontSize: '18px' }}>/ ACTIVITY LOG</span>
</PageTitle>
<HeaderMeta>{runtimeStr}</HeaderMeta>
</Header>
<LogControls>
<FilterGroup>
{ALL_TYPES.map((t) => (
<BtnToggle key={t} $active={filter === t} onClick={() => { setFilter(t); setPage(0); }}>
{t}
</BtnToggle>
))}
</FilterGroup>
<SearchBox>
<SearchInput
placeholder="SEARCH LOGS..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
</SearchBox>
</LogControls>
<LogContainer>
<LogTable>
<thead>
<tr>
<Th>TIME</Th>
<Th>TYPE</Th>
<Th>EVENT</Th>
<Th>CODE</Th>
</tr>
</thead>
<tbody>
{paged.length === 0 ? (
<Tr>
<Td colSpan={4} style={{ textAlign: 'center', padding: 'var(--space-xl)', color: 'var(--text-secondary)' }}>
</Td>
</Tr>
) : (
paged.map((item) => {
const tagType = getTagType(item.action);
const code = `0x${item.id.toString(16).toUpperCase().padStart(3, '0')}`;
return (
<Tr key={item.id}>
<TdTime>
{new Date(item.createdAt).toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
})}
</TdTime>
<Td><Tag $type={tagType}>{tagType}</Tag></Td>
<TdEvent>{formatDetail(item.detail, item.action)}</TdEvent>
<Td style={{ fontFamily: 'var(--font-mono)', fontSize: '11px' }}>{code}</Td>
</Tr>
);
})
)}
</tbody>
</LogTable>
<Pagination>
<span>SHOWING {paged.length} / {filtered.length} ENTRIES</span>
<PaginationBtns>
<PageBtn onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}>PREV</PageBtn>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => (
<PageBtn key={i} $active={page === i} onClick={() => setPage(i)}>{i + 1}</PageBtn>
))}
{totalPages > 5 && <span>...</span>}
<PageBtn onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}>NEXT</PageBtn>
</PaginationBtns>
</Pagination>
</LogContainer>
</>
);
}

View File

@@ -3,7 +3,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import BarChart from '@/components/admin/BarChart'; import BarChart from '@/components/admin/BarChart';
import { theme } from '@/styles/theme';
import { adminFetch } from '@/lib/adminFetch'; import { adminFetch } from '@/lib/adminFetch';
type Period = 'day' | 'week' | 'month'; type Period = 'day' | 'week' | 'month';
@@ -35,11 +34,11 @@ const PeriodBtn = styled.button<{ $active: boolean }>`
font-size: 13px; font-size: 13px;
font-weight: ${({ $active }) => $active ? '600' : '400'}; font-weight: ${({ $active }) => $active ? '600' : '400'};
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.15)' : 'transparent'}; background: ${({ $active }) => $active ? 'rgba(88,166,255,0.15)' : 'transparent'};
border: 1px solid ${({ $active }) => $active ? 'rgba(88,166,255,0.4)' : theme.colors.border}; border: 1px solid ${({ $active }) => $active ? 'rgba(88,166,255,0.4)' : 'var(--border-color)'};
color: ${({ $active }) => $active ? theme.colors.accent : theme.colors.textSecondary}; color: ${({ $active }) => $active ? '#58A6FF' : 'var(--text-secondary)'};
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
&:hover { color: ${theme.colors.accent}; border-color: rgba(88,166,255,0.3); } &:hover { color: #58A6FF; border-color: rgba(88,166,255,0.3); }
`; `;
const SummaryGrid = styled.div` const SummaryGrid = styled.div`
@@ -50,15 +49,15 @@ const SummaryGrid = styled.div`
`; `;
const SummaryCard = styled.div` const SummaryCard = styled.div`
background: ${theme.colors.cardBg}; background: var(--bg-surface);
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
border-radius: 10px; border-radius: 10px;
padding: 16px 18px; padding: 16px 18px;
`; `;
const SummaryLabel = styled.div` const SummaryLabel = styled.div`
font-size: 11px; font-size: 11px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
margin-bottom: 6px; margin-bottom: 6px;
@@ -67,12 +66,12 @@ const SummaryLabel = styled.div`
const SummaryValue = styled.div` const SummaryValue = styled.div`
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
`; `;
const SummaryUnit = styled.span` const SummaryUnit = styled.span`
font-size: 12px; font-size: 12px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
margin-left: 4px; margin-left: 4px;
`; `;
@@ -88,8 +87,8 @@ const ChartsGrid = styled.div`
`; `;
const Card = styled.div` const Card = styled.div`
background: ${theme.colors.cardBg}; background: var(--bg-surface);
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
padding: 20px 22px; padding: 20px 22px;
`; `;
@@ -101,7 +100,7 @@ const RecordBtn = styled.button`
font-weight: 600; font-weight: 600;
background: rgba(88,166,255,0.08); background: rgba(88,166,255,0.08);
border: 1px solid rgba(88,166,255,0.25); border: 1px solid rgba(88,166,255,0.25);
color: ${theme.colors.accent}; color: #58A6FF;
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
&:hover { background: rgba(88,166,255,0.18); } &:hover { background: rgba(88,166,255,0.18); }
@@ -111,7 +110,7 @@ const RecordBtn = styled.button`
const EmptyNote = styled.div` const EmptyNote = styled.div`
padding: 32px; padding: 32px;
text-align: center; text-align: center;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.6;
`; `;
@@ -183,7 +182,7 @@ export default function CostsPage() {
</Controls> </Controls>
{loading ? ( {loading ? (
<div style={{ color: theme.colors.textSecondary, fontSize: '14px' }}> ...</div> <div style={{ color: 'var(--text-secondary)', fontSize: '14px' }}> ...</div>
) : !data || data.summary.recordCount === 0 ? ( ) : !data || data.summary.recordCount === 0 ? (
<EmptyNote> <EmptyNote>
.<br /> .<br />

View File

@@ -3,7 +3,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import CodeEditor from '@/components/admin/CodeEditor'; import CodeEditor from '@/components/admin/CodeEditor';
import { theme } from '@/styles/theme';
import { adminFetch } from '@/lib/adminFetch'; import { adminFetch } from '@/lib/adminFetch';
const SISTERS = ['harang', 'narang', 'darang', 'erang']; const SISTERS = ['harang', 'narang', 'darang', 'erang'];
@@ -20,14 +19,14 @@ const Layout = styled.div`
`; `;
const FileTree = styled.div` const FileTree = styled.div`
background: ${theme.colors.cardBg}; background: var(--bg-surface);
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
`; `;
const TreeSection = styled.div` const TreeSection = styled.div`
border-bottom: 1px solid ${theme.colors.border}; border-bottom: 1px solid var(--border-color);
padding: 6px; padding: 6px;
&:last-child { border-bottom: none; } &:last-child { border-bottom: none; }
`; `;
@@ -35,7 +34,7 @@ const TreeSection = styled.div`
const TreeLabel = styled.div` const TreeLabel = styled.div`
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
padding: 6px 8px 4px; padding: 6px 8px 4px;
@@ -50,10 +49,10 @@ const TreeItem = styled.button<{ $active: boolean }>`
border-radius: 5px; border-radius: 5px;
cursor: pointer; cursor: pointer;
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'}; background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'};
color: ${({ $active }) => $active ? theme.colors.accent : theme.colors.textSecondary}; color: ${({ $active }) => $active ? '#58A6FF' : 'var(--text-secondary)'};
transition: background 0.15s; transition: background 0.15s;
&:hover { background: rgba(240,246,252,0.06); color: ${theme.colors.textPrimary}; } &:hover { background: rgba(240,246,252,0.06); color: var(--text-primary); }
`; `;
const EditorPane = styled.div` const EditorPane = styled.div`
@@ -70,7 +69,7 @@ const Toolbar = styled.div`
const PathLabel = styled.span` const PathLabel = styled.span`
font-size: 13px; font-size: 13px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
font-family: monospace; font-family: monospace;
flex: 1; flex: 1;
`; `;
@@ -105,7 +104,7 @@ const ResultMsg = styled.div<{ $success: boolean }>`
font-size: 12px; font-size: 12px;
font-family: monospace; font-family: monospace;
background: ${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'}; background: ${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};
color: ${({ $success }) => $success ? theme.colors.online : theme.colors.offline}; color: ${({ $success }) => $success ? '#00FF00' : '#FF1744'};
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'}; border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
`; `;
@@ -189,7 +188,7 @@ export default function HarnessPage() {
{result && <ResultMsg $success={result.success}>{result.msg}</ResultMsg>} {result && <ResultMsg $success={result.success}>{result.msg}</ResultMsg>}
{loading ? ( {loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: theme.colors.textSecondary, fontSize: '13px' }}> <div style={{ padding: '32px', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '13px' }}>
... ...
</div> </div>
) : ( ) : (

View File

@@ -4,85 +4,67 @@ import React, { useState, useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { theme } from '@/styles/theme'; import { LabelMeta, SectionTitle } from '@/components/ui/base';
const ADMIN_TABS = [ const ADMIN_TABS = [
{ href: '/admin', label: '자매 관리', icon: '⚙️' }, { href: '/admin', label: '자매 관리' },
{ href: '/admin/harness', label: '하네스 편집', icon: '✏️' }, { href: '/admin/harness', label: '하네스 편집' },
{ href: '/admin/logs', label: '로그 뷰어', icon: '📋' }, { href: '/admin/logs', label: '로그 뷰어' },
{ href: '/admin/costs', label: '비용 모니터', icon: '💰' }, { href: '/admin/costs', label: '비용 모니터' },
{ href: '/admin/repos', label: '저장소', icon: '📁' }, { href: '/admin/repos', label: '저장소' },
]; ];
const Wrapper = styled.div` const Wrapper = styled.div`
padding: 32px;
min-height: 100vh;
background: ${theme.colors.bg};
`;
const Header = styled.div`
margin-bottom: 24px;
`;
const Title = styled.h1`
font-size: 22px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 4px;
`;
const Subtitle = styled.p`
font-size: 13px;
color: ${theme.colors.textSecondary};
`;
const TabRow = styled.div`
display: flex; display: flex;
gap: 4px; flex-direction: column;
border-bottom: 1px solid ${theme.colors.border}; gap: var(--space-lg);
margin-bottom: 24px;
`; `;
const Tab = styled(Link)<{ $active: boolean }>` const AdminHeader = styled.div`
padding: 10px 16px; border-bottom: 1px solid var(--border-color);
font-size: 13px; padding-bottom: var(--space-md);
font-weight: ${({ $active }) => $active ? '600' : '400'}; `;
color: ${({ $active }) => $active ? theme.colors.textPrimary : theme.colors.textSecondary};
border-bottom: 2px solid ${({ $active }) => $active ? theme.colors.accent : 'transparent'}; const TabNav = styled.div`
transition: color 0.15s, border-color 0.15s;
display: flex; display: flex;
align-items: center; gap: var(--space-lg);
gap: 6px; flex-wrap: wrap;
`;
const TabLink = styled(Link)<{ $active: boolean }>`
font-size: 13px;
font-weight: 500;
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
text-decoration: none; text-decoration: none;
padding: var(--space-sm) 0;
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
transition: color 0.15s, border-color 0.15s;
&:hover { color: ${theme.colors.textPrimary}; } &:hover { color: var(--text-primary); }
`; `;
const ApiKeyRow = styled.div` const ApiKeyRow = styled.div`
display: flex; display: flex;
gap: 8px;
align-items: center; align-items: center;
margin-bottom: 20px; gap: var(--space-sm);
padding: 10px 14px; font-family: var(--font-mono);
background: rgba(88,166,255,0.06); font-size: 12px;
border: 1px solid rgba(88,166,255,0.15); color: var(--text-secondary);
border-radius: 8px;
`; `;
const ApiKeyInput = styled.input` const ApiKeyInput = styled.input`
background: transparent; background: transparent;
border: none; border: none;
color: ${theme.colors.textPrimary}; border-bottom: 1px solid var(--border-color);
font-size: 13px; color: var(--text-primary);
font-family: monospace; font-family: var(--font-mono);
font-size: 12px;
outline: none; outline: none;
flex: 1; flex: 1;
`; max-width: 300px;
padding: 2px 0;
const ApiKeyLabel = styled.span` &:focus { border-bottom-color: var(--text-primary); }
font-size: 12px;
color: ${theme.colors.textSecondary};
white-space: nowrap;
`; `;
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
@@ -100,29 +82,30 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return ( return (
<Wrapper> <Wrapper>
<Header> <AdminHeader>
<Title> </Title> <div style={{ fontSize: '28px', fontWeight: 600, letterSpacing: '-0.02em', marginBottom: 'var(--space-md)' }}>
<Subtitle> , , </Subtitle>
</Header> </div>
<TabNav>
{ADMIN_TABS.map((tab) => (
<TabLink key={tab.href} href={tab.href} $active={pathname === tab.href}>
{tab.label}
</TabLink>
))}
</TabNav>
</AdminHeader>
<ApiKeyRow> <ApiKeyRow>
<ApiKeyLabel>🔑 Admin Key:</ApiKeyLabel> <span>KEY [</span>
<ApiKeyInput <ApiKeyInput
type="password" type="password"
placeholder="ADMIN_API_KEYS 값 입력" placeholder="ADMIN_API_KEY"
value={apiKey} value={apiKey}
onChange={(e) => saveKey(e.target.value)} onChange={(e) => saveKey(e.target.value)}
/> />
<span>]</span>
</ApiKeyRow> </ApiKeyRow>
<TabRow>
{ADMIN_TABS.map((tab) => (
<Tab key={tab.href} href={tab.href} $active={pathname === tab.href}>
{tab.icon} {tab.label}
</Tab>
))}
</TabRow>
{children} {children}
</Wrapper> </Wrapper>
); );

View File

@@ -3,7 +3,6 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import LogTerminal from '@/components/admin/LogTerminal'; import LogTerminal from '@/components/admin/LogTerminal';
import { theme } from '@/styles/theme';
import { adminFetch } from '@/lib/adminFetch'; import { adminFetch } from '@/lib/adminFetch';
const SISTERS = [ const SISTERS = [
@@ -21,28 +20,28 @@ const Controls = styled.div`
`; `;
const Select = styled.select` const Select = styled.select`
background: ${theme.colors.cardBg}; background: var(--bg-surface);
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
border-radius: 7px; border-radius: 7px;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
padding: 8px 12px; padding: 8px 12px;
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
outline: none; outline: none;
&:focus { border-color: ${theme.colors.accent}; } &:focus { border-color: #58A6FF; }
option { background: #1a1f2a; } option { background: #1a1f2a; }
`; `;
const LinesInput = styled.input` const LinesInput = styled.input`
background: ${theme.colors.cardBg}; background: var(--bg-surface);
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
border-radius: 7px; border-radius: 7px;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
padding: 8px 12px; padding: 8px 12px;
font-size: 13px; font-size: 13px;
width: 80px; width: 80px;
outline: none; outline: none;
&:focus { border-color: ${theme.colors.accent}; } &:focus { border-color: #58A6FF; }
`; `;
const FetchBtn = styled.button` const FetchBtn = styled.button`
@@ -52,7 +51,7 @@ const FetchBtn = styled.button`
font-weight: 600; font-weight: 600;
background: rgba(88,166,255,0.1); background: rgba(88,166,255,0.1);
border: 1px solid rgba(88,166,255,0.35); border: 1px solid rgba(88,166,255,0.35);
color: ${theme.colors.accent}; color: #58A6FF;
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
&:hover { background: rgba(88,166,255,0.2); } &:hover { background: rgba(88,166,255,0.2); }
@@ -61,7 +60,7 @@ const FetchBtn = styled.button`
const Label = styled.span` const Label = styled.span`
font-size: 12px; font-size: 12px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
`; `;
export default function LogsPage() { export default function LogsPage() {

View File

@@ -4,7 +4,6 @@ import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import StatusBadge from '@/components/common/StatusBadge'; import StatusBadge from '@/components/common/StatusBadge';
import ConfirmModal from '@/components/admin/ConfirmModal'; import ConfirmModal from '@/components/admin/ConfirmModal';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
import { adminFetch } from '@/lib/adminFetch'; import { adminFetch } from '@/lib/adminFetch';
import { SISTER_EMOJIS, SISTER_DISPLAY_NAMES } from '@/lib/sisters'; import { SISTER_EMOJIS, SISTER_DISPLAY_NAMES } from '@/lib/sisters';
@@ -24,8 +23,8 @@ const Table = styled.div`
`; `;
const Row = styled.div` const Row = styled.div`
background: ${theme.colors.cardBg}; background: var(--bg-surface);
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
border-radius: 10px; border-radius: 10px;
padding: 16px 20px; padding: 16px 20px;
display: flex; display: flex;
@@ -45,7 +44,7 @@ const Emoji = styled.span`font-size: 22px;`;
const Name = styled.span` const Name = styled.span`
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
min-width: 70px; min-width: 70px;
`; `;
@@ -78,7 +77,7 @@ const ResultBanner = styled.div<{ $success: boolean }>`
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'}; border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
border-radius: 6px; border-radius: 6px;
font-size: 12px; font-size: 12px;
color: ${({ $success }) => $success ? theme.colors.online : theme.colors.offline}; color: ${({ $success }) => $success ? '#00FF00' : '#FF1744'};
font-family: monospace; font-family: monospace;
white-space: pre-wrap; white-space: pre-wrap;
`; `;

View File

@@ -2,7 +2,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
const Grid = styled.div` const Grid = styled.div`
@@ -13,8 +12,8 @@ const Grid = styled.div`
const Card = styled.a` const Card = styled.a`
display: block; display: block;
background: ${theme.colors.cardBg}; background: var(--bg-surface);
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
border-radius: 10px; border-radius: 10px;
padding: 18px 20px; padding: 18px 20px;
text-decoration: none; text-decoration: none;
@@ -29,13 +28,13 @@ const Card = styled.a`
const RepoName = styled.div` const RepoName = styled.div`
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: ${theme.colors.accent}; color: #58A6FF;
margin-bottom: 6px; margin-bottom: 6px;
`; `;
const RepoDesc = styled.div` const RepoDesc = styled.div`
font-size: 12px; font-size: 12px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
margin-bottom: 12px; margin-bottom: 12px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -49,7 +48,7 @@ const Stats = styled.div`
const Stat = styled.span` const Stat = styled.span`
font-size: 12px; font-size: 12px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
`; `;
const PRBadge = styled.span<{ $count: number }>` const PRBadge = styled.span<{ $count: number }>`
@@ -58,13 +57,13 @@ const PRBadge = styled.span<{ $count: number }>`
border-radius: 4px; border-radius: 4px;
font-weight: 600; font-weight: 600;
background: ${({ $count }) => $count > 0 ? 'rgba(88,166,255,0.12)' : 'rgba(139,148,158,0.08)'}; background: ${({ $count }) => $count > 0 ? 'rgba(88,166,255,0.12)' : 'rgba(139,148,158,0.08)'};
color: ${({ $count }) => $count > 0 ? theme.colors.accent : theme.colors.textSecondary}; color: ${({ $count }) => $count > 0 ? '#58A6FF' : 'var(--text-secondary)'};
`; `;
const NoProjects = styled.div` const NoProjects = styled.div`
padding: 32px; padding: 32px;
text-align: center; text-align: center;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
font-size: 13px; font-size: 13px;
`; `;
@@ -80,7 +79,7 @@ export default function ReposPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
if (loading) return <div style={{ color: theme.colors.textSecondary, fontSize: '14px' }}> ...</div>; if (loading) return <div style={{ color: 'var(--text-secondary)', fontSize: '14px' }}> ...</div>;
if (!projects.length) return <NoProjects> </NoProjects>; if (!projects.length) return <NoProjects> </NoProjects>;

View File

@@ -2,102 +2,279 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import OrgTree from '@/components/org/OrgTree';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
const Page = styled.div` // ─── Styled ───
padding: 32px; const MainViewport = styled.div`
min-height: 100vh;
background: ${theme.colors.bg};
`;
const PageTitle = styled.h1`
font-size: 22px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 6px;
`;
const PageSubtitle = styled.p`
font-size: 14px;
color: ${theme.colors.textSecondary};
margin-bottom: 28px;
`;
const Card = styled.div`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 16px;
padding: 32px;
max-width: 800px;
margin: 0 auto;
`;
const Legend = styled.div`
display: flex; display: flex;
gap: 20px; flex-direction: column;
justify-content: center; gap: var(--space-xl);
margin-top: 24px; min-height: 80vh;
padding-top: 20px; `;
border-top: 1px solid ${theme.colors.border};
const HeaderInfo = styled.div`
display: flex;
gap: var(--space-xl);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-md);
span { color: var(--text-primary); }
`;
const TreeContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
overflow-x: auto;
padding: var(--space-xl) 0;
@media (max-width: 767px) {
align-items: flex-start;
}
`;
const NodeGroup = styled.div`
display: flex;
flex-direction: column;
align-items: center;
position: relative;
`;
const TerminalCard = styled.div<{ $level: 'high' | 'mid' | 'low' | 'none' }>`
border: 1px solid ${({ $level }) => ({
high: '#f43f5e',
mid: '#3b82f6',
low: '#10b981',
none: '#525252',
})[$level]};
border-left-width: 3px;
padding: var(--space-md) var(--space-lg);
background: var(--bg-main);
min-width: 200px;
max-width: 260px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
transition: border-color 0.2s, box-shadow 0.2s;
position: relative;
cursor: default;
&:hover {
box-shadow: 0 12px 36px rgba(0,0,0,0.7);
}
`;
const NodeLabel = styled.div`
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-sm);
span {
color: var(--text-secondary);
margin-right: var(--space-xs);
font-family: var(--font-mono);
font-size: 11px;
}
`;
const BadgeRow = styled.div`
display: flex;
gap: var(--space-xs);
flex-wrap: wrap; flex-wrap: wrap;
`; `;
const LegendItem = styled.div` const Badge = styled.div`
display: flex; background: #1a1a1a;
align-items: center; border: 1px solid var(--border-color);
gap: 6px; padding: 2px 6px;
font-size: 12px; font-size: 9px;
color: ${theme.colors.textSecondary}; font-family: var(--font-mono);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
b { color: var(--text-primary); font-weight: normal; }
`; `;
const Dot = styled.div<{ $color: string }>` const TreeConnector = styled.div`
width: 8px; width: 1px;
height: 8px; height: 24px;
border-radius: 50%; background: var(--border-color);
background: ${({ $color }) => $color}; margin: 0 auto;
`; `;
const TreeBranch = styled.div`
display: flex;
flex-direction: column;
align-items: center;
`;
const TreeChildren = styled.div`
display: flex;
gap: var(--space-xl);
align-items: flex-start;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: calc(var(--space-xl) / 2 + 100px);
right: calc(var(--space-xl) / 2 + 100px);
height: 1px;
background: var(--border-color);
}
@media (max-width: 767px) {
flex-direction: column;
gap: var(--space-lg);
&::before { display: none; }
}
`;
const PageFooter = styled.footer`
margin-top: auto;
width: 100%;
padding: var(--space-lg) 0;
display: flex;
justify-content: space-between;
border-top: 1px solid var(--border-color);
font-family: var(--font-mono);
color: var(--text-secondary);
font-size: 10px;
`;
type Level = 'high' | 'mid' | 'low' | 'none';
interface OrgData {
owner: { name: string; role: string };
sisters: Array<{
name: string;
role: string;
description?: string;
status?: string;
lxcId?: number;
}>;
pipeline: Array<{ from: string; to: string; label: string }>;
}
const LEVEL_MAP: Record<string, Level> = {
Orchestrator: 'high',
Generator: 'mid',
Evaluator: 'mid',
'Infra Manager': 'low',
};
const ROLE_TAG: Record<string, string> = {
Orchestrator: 'ORCH',
Generator: 'GEN',
Evaluator: 'EVAL',
'Infra Manager': 'INFRA',
};
const LXC_ID: Record<string, number> = {
harang: 104,
narang: 105,
darang: 106,
erang: 107,
};
export default function OrgPage() { export default function OrgPage() {
const [orgData, setOrgData] = useState<any>(null); const [orgData, setOrgData] = useState<OrgData | null>(null);
const [loading, setLoading] = useState(true); const ts = new Date().toLocaleDateString('ko-KR').replace(/\. /g, '.').replace('.', '') + '_' +
new Date().toLocaleTimeString('ko-KR', { hour12: false, hour: '2-digit', minute: '2-digit' });
useEffect(() => { useEffect(() => {
const load = async () => { fetch(`${API_URL}/api/org`)
try { .then((r) => r.json())
const res = await fetch(`${API_URL}/api/org`); .then(setOrgData)
if (res.ok) setOrgData(await res.json()); .catch(() => {});
} catch {
// silent
} finally {
setLoading(false);
}
};
load();
}, []); }, []);
const sisters = orgData?.sisters ?? [];
return ( return (
<Page> <MainViewport>
<PageTitle></PageTitle> <HeaderInfo>
<PageSubtitle> 4 </PageSubtitle> <div>SYS_TYPE: <span>CORE_ROOT_01</span></div>
<div>TIMESTAMP: <span style={{ color: 'var(--text-secondary)' }}>{ts}</span></div>
</HeaderInfo>
<Card> <TreeContainer>
{loading ? ( <NodeGroup>
<div style={{ textAlign: 'center', color: theme.colors.textSecondary, padding: '40px' }}> {/* HQ */}
... <TerminalCard $level="high">
</div> <NodeLabel><span>[HQ]</span> </NodeLabel>
) : ( <BadgeRow>
<OrgTree sisters={orgData?.sisters ?? []} /> <Badge>MEM: <b>{sisters.length + 1}</b></Badge>
<Badge>S_ADMIN</Badge>
<Badge>LV_09</Badge>
</BadgeRow>
</TerminalCard>
<TreeConnector />
{/* 자매 레벨 */}
<TreeBranch>
<TreeChildren>
{/* 하랑이 (Orchestrator) */}
{sisters.filter((s) => s.role === 'Orchestrator').map((s) => (
<NodeGroup key={s.name}>
<TerminalCard $level={LEVEL_MAP[s.role] ?? 'none'}>
<NodeLabel>
<span>[{ROLE_TAG[s.role] ?? s.role}]</span>
{s.name === 'harang' ? '하랑이' : s.name}
</NodeLabel>
<BadgeRow>
<Badge>LXC: <b>{LXC_ID[s.name] ?? '---'}</b></Badge>
<Badge>{ROLE_TAG[s.role] ?? s.role}</Badge>
<Badge>{s.status === 'online' ? 'ACTIVE' : s.status === 'offline' ? 'STBY' : 'RUN'}</Badge>
</BadgeRow>
</TerminalCard>
<TreeConnector />
<TreeBranch>
<TreeChildren>
{/* 나랑/다랑/이랑 */}
{sisters.filter((s2) => s2.role !== 'Orchestrator').map((s2) => (
<NodeGroup key={s2.name}>
<TerminalCard $level={LEVEL_MAP[s2.role] ?? 'none'}>
<NodeLabel>
{s2.name === 'narang' ? '나랑이' : s2.name === 'darang' ? '다랑이' : '이랑이'}
</NodeLabel>
<BadgeRow>
<Badge>LXC: <b>{LXC_ID[s2.name] ?? '---'}</b></Badge>
<Badge>{ROLE_TAG[s2.role] ?? s2.role}</Badge>
<Badge>{s2.status === 'online' ? 'ON' : s2.status === 'offline' ? '--' : 'RUN'}</Badge>
</BadgeRow>
</TerminalCard>
</NodeGroup>
))}
</TreeChildren>
</TreeBranch>
</NodeGroup>
))}
{/* Orchestrator 없을 때 fallback */}
{sisters.filter((s) => s.role === 'Orchestrator').length === 0 && (
<NodeGroup>
<TerminalCard $level="none">
<NodeLabel> </NodeLabel>
<BadgeRow><Badge>N/A</Badge></BadgeRow>
</TerminalCard>
</NodeGroup>
)} )}
</TreeChildren>
</TreeBranch>
</NodeGroup>
</TreeContainer>
<Legend> <PageFooter>
<LegendItem><Dot $color={theme.colors.online} /> </LegendItem> <div>TOTAL_NODES: {String(sisters.length + 1).padStart(2, '0')}</div>
<LegendItem><Dot $color={theme.colors.working} /> </LegendItem> <div>STATUS: SYNC_COMPLETE</div>
<LegendItem><Dot $color={theme.colors.offline} /> </LegendItem> </PageFooter>
<LegendItem><Dot $color={theme.colors.textSecondary} /> </LegendItem> </MainViewport>
</Legend>
</Card>
</Page>
); );
} }

View File

@@ -2,237 +2,260 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import SisterCard from '@/components/dashboard/SisterCard'; import Link from 'next/link';
import ProjectCard from '@/components/projects/ProjectCard'; import {
import ActivityFeed from '@/components/dashboard/ActivityFeed'; PageHeader, PageTitle, Card, BracketValue, LabelMeta,
import { theme } from '@/styles/theme'; TechBar, TechBarFill, SectionTitle, Timeline, TimelineItem,
TimeStamp, TimelineContent,
} from '@/components/ui/base';
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
import { useSocket } from '@/lib/useSocket'; import { useSocket } from '@/lib/useSocket';
type Status = 'online' | 'offline' | 'working' | 'unknown'; // ─── Styled ───
const StatusGrid = styled.section`
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-lg);
interface SisterStatus { @media (min-width: 768px) and (max-width: 1199px) {
id: number; grid-template-columns: repeat(2, 1fr);
name: string; }
user: string;
lxcId: number; @media (max-width: 767px) {
role: string; grid-template-columns: repeat(2, 1fr);
status: Status; gap: var(--space-md);
lastSeen: string | null; }
currentTask: string | null; `;
const DataColumns = styled.div`
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: var(--space-xxl);
align-items: start;
@media (max-width: 1199px) {
grid-template-columns: 1fr;
gap: var(--space-xl);
}
`;
const ProjectList = styled.div`
display: flex;
flex-direction: column;
`;
const ProjectRow = styled(Link)`
display: grid;
grid-template-columns: 40px 1fr auto;
gap: var(--space-md);
padding: var(--space-md) 0;
border-bottom: 1px solid #222;
align-items: center;
text-decoration: none;
transition: opacity 0.15s;
&:last-child { border-bottom: none; }
&:hover { opacity: 0.8; }
`;
const Avatar = styled.div`
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #333;
overflow: hidden;
filter: grayscale(100%) contrast(120%);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
`;
const ProjectDetails = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
`;
const ProjectName = styled.div`
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const ProjectDesc = styled.div`
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const WsIndicator = styled.span<{ $on: boolean }>`
font-size: 11px;
font-family: var(--font-mono);
color: ${({ $on }) => $on ? 'var(--text-secondary)' : '#333'};
margin-left: var(--space-md);
`;
// ─── Helpers ───
const SISTER_SHORT: Record<string, string> = {
harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑',
};
const SISTER_SUB_LABEL: Record<string, string> = {
harang: 'Active Node',
narang: 'Capacity %',
darang: 'Standby',
erang: 'Sync Queue',
};
function sisterBracketValue(s: any): string {
if (s.status === 'online') return '[ON]';
if (s.status === 'working') return '[RUN]';
if (s.status === 'offline') return '[--]';
return '[??]';
} }
const MOCK_SISTERS: SisterStatus[] = [ function sisterBarWidth(s: any): number {
{ id: 1, name: 'harang', user: 'harang', lxcId: 104, role: 'Orchestrator', status: 'online', lastSeen: new Date().toISOString(), currentTask: 'Sprint 002 기획 중' }, if (s.status === 'online') return 100;
{ id: 2, name: 'narang', user: 'narang', lxcId: 105, role: 'Generator', status: 'working', lastSeen: new Date().toISOString(), currentTask: 'SPRINT-002 구현 중' }, if (s.status === 'working') return 84;
{ id: 3, name: 'darang', user: 'darang', lxcId: 106, role: 'Evaluator', status: 'online', lastSeen: new Date().toISOString(), currentTask: null }, if (s.status === 'offline') return 0;
{ id: 4, name: 'erang', user: 'erang', lxcId: 107, role: 'Infra Manager', status: 'online', lastSeen: new Date().toISOString(), currentTask: null }, return 12;
}
function formatTimestamp(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString('ko-KR', { hour12: false }) + ' GMT+9';
}
const MOCK_SISTERS = [
{ id: 1, name: 'harang', status: 'online', lastSeen: null, currentTask: null },
{ id: 2, name: 'narang', status: 'working', lastSeen: null, currentTask: null },
{ id: 3, name: 'darang', status: 'offline', lastSeen: null, currentTask: null },
{ id: 4, name: 'erang', status: 'online', lastSeen: null, currentTask: null },
]; ];
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
const PageWrapper = styled.div`
padding: 32px;
min-height: 100vh;
background: ${theme.colors.bg};
`;
const PageHeader = styled.div`
margin-bottom: 32px;
`;
const PageTitle = styled.h1`
font-size: 24px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 6px;
`;
const PageSubtitle = styled.p`
font-size: 14px;
color: ${theme.colors.textSecondary};
`;
const SectionTitle = styled.h2`
font-size: 13px;
font-weight: 600;
color: ${theme.colors.textSecondary};
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 16px;
`;
const SistersGrid = styled.div`
display: flex;
gap: 16px;
margin-bottom: 40px;
@media (max-width: 1024px) {
flex-wrap: wrap;
> * { flex: 1 1 calc(50% - 8px); }
}
@media (max-width: 640px) {
flex-direction: column;
}
`;
const TwoColumnRow = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 40px;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
`;
const ProjectsGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 40px;
`;
const Card = styled.div`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 12px;
padding: 20px;
`;
const ErrorBanner = styled.div`
padding: 10px 14px;
background: rgba(255,23,68,0.08);
border: 1px solid rgba(255,23,68,0.3);
border-radius: 8px;
font-size: 13px;
color: ${theme.colors.offline};
margin-bottom: 20px;
`;
const PlaceholderText = styled.div`
padding: 32px;
text-align: center;
color: ${theme.colors.textSecondary};
font-size: 13px;
`;
export default function DashboardPage() { export default function DashboardPage() {
const [sisters, setSisters] = useState<SisterStatus[]>(MOCK_SISTERS); const [sisters, setSisters] = useState<any[]>(MOCK_SISTERS);
const [projects, setProjects] = useState<any[]>([]); const [projects, setProjects] = useState<any[]>([]);
const [activityItems, setActivityItems] = useState<any[]>([]); const [activityItems, setActivityItems] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const handleSistersUpdate = useCallback((updatedSisters: any[]) => {
setSisters(updatedSisters);
}, []);
const handleSistersUpdate = useCallback((s: any[]) => setSisters(s), []);
const handleActivityNew = useCallback((item: any) => { const handleActivityNew = useCallback((item: any) => {
setActivityItems((prev) => [item, ...prev].slice(0, 20)); setActivityItems((prev) => [item, ...prev].slice(0, 10));
}, []); }, []);
const { connected } = useSocket({ onSistersUpdate: handleSistersUpdate, onActivityNew: handleActivityNew });
const { connected } = useSocket({
onSistersUpdate: handleSistersUpdate,
onActivityNew: handleActivityNew,
});
useEffect(() => { useEffect(() => {
const fetchAll = async () => { const fetch_ = async () => {
try { try {
const [sRes, pRes, aRes] = await Promise.allSettled([ const [sRes, pRes, aRes] = await Promise.allSettled([
fetch(`${API_URL}/api/sisters`), fetch(`${API_URL}/api/sisters`),
fetch(`${API_URL}/api/projects`), fetch(`${API_URL}/api/projects`),
fetch(`${API_URL}/api/activity?limit=20`), fetch(`${API_URL}/api/activity?limit=8`),
]); ]);
if (sRes.status === 'fulfilled' && sRes.value.ok) setSisters(await sRes.value.json());
if (sRes.status === 'fulfilled' && sRes.value.ok) { if (pRes.status === 'fulfilled' && pRes.value.ok) setProjects(await pRes.value.json());
setSisters(await sRes.value.json());
}
if (pRes.status === 'fulfilled' && pRes.value.ok) {
setProjects(await pRes.value.json());
}
if (aRes.status === 'fulfilled' && aRes.value.ok) { if (aRes.status === 'fulfilled' && aRes.value.ok) {
const d = await aRes.value.json(); const d = await aRes.value.json();
setActivityItems(d.items ?? []); setActivityItems(d.items ?? []);
} }
setError(null); } catch { /* silent */ }
} catch {
setError('API 연결 실패 — 일부 데이터는 mock으로 표시 중');
}
}; };
fetch_();
fetchAll(); const iv = setInterval(fetch_, POLL_INTERVAL_MS);
const interval = setInterval(fetchAll, POLL_INTERVAL_MS); return () => clearInterval(iv);
return () => clearInterval(interval);
}, []); }, []);
return ( return (
<PageWrapper> <>
<PageHeader> <PageHeader>
<PageTitle> </PageTitle> <PageTitle>
<PageSubtitle>
4 <WsIndicator $on={connected}> {connected ? 'LIVE' : 'POLL'}</WsIndicator>
{connected && <span style={{ marginLeft: '10px', fontSize: '11px', color: theme.colors.online }}> </span>} </PageTitle>
{!connected && <span style={{ marginLeft: '10px', fontSize: '11px', color: theme.colors.textSecondary }}> </span>}
</PageSubtitle>
</PageHeader> </PageHeader>
{error && <ErrorBanner> {error}</ErrorBanner>} {/* 상태 카드 */}
<StatusGrid>
<SectionTitle> </SectionTitle> {sisters.map((s) => (
<SistersGrid> <Card key={s.id ?? s.name} as={Link} href={`/sisters/${s.name}`} style={{ textDecoration: 'none' }}>
{sisters.map((sister) => ( <div style={{ display: 'flex', justifyContent: 'space-between' }}>
<SisterCard <LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta>
key={sister.id} </div>
name={sister.name} <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}>
role={sister.role} <BracketValue $dimmed={s.status === 'offline'}>
status={sister.status} {sisterBracketValue(s)}
lastSeen={sister.lastSeen} </BracketValue>
currentTask={sister.currentTask} <LabelMeta>{SISTER_SUB_LABEL[s.name] ?? s.status}</LabelMeta>
/> </div>
<TechBar>
<TechBarFill $width={sisterBarWidth(s)} />
</TechBar>
</Card>
))} ))}
</SistersGrid> </StatusGrid>
<SectionTitle> </SectionTitle> {/* 하단 2열 */}
{projects.length > 0 ? ( <DataColumns>
<ProjectsGrid> {/* ONGOING PROJECTS */}
{projects.map((p) => ( <section>
<ProjectCard <SectionTitle>
key={p.id} <span>ONGOING PROJECTS</span>
id={p.id} <LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
name={p.name} </SectionTitle>
description={p.description} <ProjectList>
status={p.status} {projects.length === 0 ? (
progress={p.progress} <div style={{ color: 'var(--text-secondary)', fontSize: '13px', padding: 'var(--space-md) 0' }}>
doneTasks={p.doneTasks}
totalTasks={p.totalTasks} </div>
openPRs={p.openPRs}
currentSprint={p.currentSprint}
/>
))}
</ProjectsGrid>
) : ( ) : (
<TwoColumnRow> projects.map((p) => (
<Card> <ProjectRow key={p.id} href={`/projects/${p.id}`}>
<PlaceholderText> </PlaceholderText> <Avatar>📁</Avatar>
</Card> <ProjectDetails>
<Card> <ProjectName>{p.name}</ProjectName>
<SectionTitle> </SectionTitle> <ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc>
<ActivityFeed items={activityItems} /> </ProjectDetails>
</Card> <LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta>
</TwoColumnRow> </ProjectRow>
))
)} )}
</ProjectList>
</section>
{projects.length > 0 && ( {/* ACTIVITY FEED */}
<TwoColumnRow> <section>
<div /> <SectionTitle>
<Card> <span>ACTIVITY FEED</span>
<SectionTitle> </SectionTitle> <LabelMeta>{connected ? 'LIVE' : 'FEED'}</LabelMeta>
<ActivityFeed items={activityItems} /> </SectionTitle>
</Card> <Timeline>
</TwoColumnRow> {activityItems.length === 0 ? (
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', paddingLeft: 'var(--space-lg)' }}>
</div>
) : (
activityItems.map((item) => (
<TimelineItem key={item.id}>
<TimeStamp>{formatTimestamp(item.createdAt)}</TimeStamp>
<TimelineContent>
{item.sister && <strong>{item.sister.name} </strong>}
{item.detail ?? item.action}
</TimelineContent>
</TimelineItem>
))
)} )}
</PageWrapper> </Timeline>
</section>
</DataColumns>
</>
); );
} }

View File

@@ -4,194 +4,309 @@ import React, { useEffect, useState } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import TabNav from '@/components/common/TabNav'; import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill } from '@/components/ui/base';
import SprintAccordion from '@/components/projects/SprintAccordion';
import TaskTable from '@/components/projects/TaskTable';
import ActivityFeed from '@/components/dashboard/ActivityFeed';
import ProgressBar from '@/components/common/ProgressBar';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
const TABS = [ const Breadcrumb = styled.div`
{ id: 'sprints', label: 'Sprints', icon: '📁' }, font-family: var(--font-mono);
{ id: 'tasks', label: 'Task Ledger', icon: '📋' }, font-size: 11px;
{ id: 'activity', label: '활동 로그', icon: '📝' }, color: var(--text-secondary);
]; margin-bottom: var(--space-md);
const Page = styled.div` a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
padding: 32px;
min-height: 100vh;
background: ${theme.colors.bg};
`; `;
const BackLink = styled(Link)` const PageTitleRow = styled.div`
font-size: 13px;
color: ${theme.colors.textSecondary};
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 20px;
transition: color 0.15s;
&:hover { color: ${theme.colors.textPrimary}; }
`;
const Header = styled.div`
margin-bottom: 28px;
`;
const Title = styled.h1`
font-size: 22px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 6px;
display: flex; display: flex;
align-items: center; justify-content: space-between;
gap: 12px; align-items: flex-start;
gap: var(--space-lg);
margin-bottom: var(--space-xl);
flex-wrap: wrap;
`; `;
const GitLink = styled.a` const ProjectGrid = styled.div`
font-size: 12px; display: grid;
color: ${theme.colors.accent}; grid-template-columns: 1fr 320px;
padding: 3px 8px; gap: var(--space-xxl);
border: 1px solid rgba(88,166,255,0.3); align-items: start;
border-radius: 5px;
transition: background 0.15s; @media (max-width: 1199px) {
&:hover { background: rgba(88,166,255,0.1); } grid-template-columns: 1fr;
gap: var(--space-xl);
}
`; `;
const Desc = styled.p` // Phase Timeline
const PhaseTimeline = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-lg);
margin-bottom: var(--space-xl);
`;
const PhaseItem = styled.div`
display: grid;
grid-template-columns: 100px 1fr;
gap: var(--space-lg);
align-items: flex-start;
`;
const PhaseMeta = styled.div`
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
line-height: 1.6;
padding-top: var(--space-sm);
`;
const PhaseBox = styled.div<{ $active?: boolean }>`
border: 1px solid ${({ $active }) => $active ? 'var(--border-hover)' : 'var(--border-color)'};
padding: var(--space-md) var(--space-lg);
background: ${({ $active }) => $active ? '#1a1a1a' : 'transparent'};
`;
const PhaseName = styled.div`
font-size: 14px; font-size: 14px;
color: ${theme.colors.textSecondary}; font-weight: 600;
margin-bottom: 12px; color: var(--text-primary);
margin-bottom: var(--space-xs);
`; `;
const ProgressRow = styled.div` const PhaseDesc = styled.div`
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
`;
// Audit Log
const AuditLog = styled.div`
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border-color);
border: 1px solid var(--border-color);
font-family: var(--font-mono);
`;
const AuditRow = styled.div<{ $header?: boolean }>`
background: var(--bg-main);
padding: var(--space-sm) var(--space-md);
display: grid;
grid-template-columns: 100px 1fr 80px;
font-size: 11px;
color: ${({ $header }) => $header ? 'var(--text-secondary)' : 'var(--text-primary)'};
font-weight: ${({ $header }) => $header ? '700' : '400'};
text-transform: ${({ $header }) => $header ? 'uppercase' : 'none'};
`;
const StatusTag = styled.span<{ $pass?: boolean }>`
padding: 2px 6px;
font-size: 10px;
border: 1px solid ${({ $pass }) => $pass ? '#00FF00' : 'var(--border-color)'};
color: ${({ $pass }) => $pass ? '#00FF00' : 'var(--text-secondary)'};
text-align: center;
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
`;
// Right Panel
const NodeStack = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-bottom: var(--space-xl);
`;
const NodeMiniCard = styled.div`
border: 1px solid var(--border-color);
padding: var(--space-sm) var(--space-md);
display: flex;
justify-content: space-between;
align-items: center;
transition: border-color 0.2s;
&:hover { border-color: var(--border-hover); }
`;
const NodeStatusDot = styled.div`
width: 6px;
height: 6px;
background: #00FF00;
border-radius: 50%;
`;
const Checklist = styled.ul`
list-style: none;
display: flex;
flex-direction: column;
gap: 0;
`;
const CheckItem = styled.li<{ $done?: boolean }>`
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: var(--space-md);
max-width: 400px; padding: var(--space-sm) 0;
margin-bottom: 8px; border-bottom: 1px solid #1a1a1a;
`;
const ProgressLabel = styled.span`
font-size: 13px; font-size: 13px;
color: ${theme.colors.textSecondary}; color: ${({ $done }) => $done ? 'var(--text-secondary)' : 'var(--text-primary)'};
white-space: nowrap; text-decoration: ${({ $done }) => $done ? 'line-through' : 'none'};
&:last-child { border-bottom: none; }
`; `;
const PRCount = styled.span` const CheckBox = styled.div<{ $checked?: boolean }>`
font-size: 12px; width: 14px;
color: ${theme.colors.accent}; height: 14px;
padding: 2px 8px; border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
background: rgba(88,166,255,0.1); flex-shrink: 0;
border-radius: 4px; background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'transparent'};
position: relative;
${({ $checked }) => $checked && `
&::after {
content: '✓';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: #000;
}
`}
`; `;
const Card = styled.div` function getSprintMeta(sprint: any): { label: string; isActive: boolean } {
background: ${theme.colors.cardBg}; if (sprint.status === 'done') return { label: `COMPLETED\n${sprint.completedAt ? new Date(sprint.completedAt).toLocaleDateString() : ''}`, isActive: false };
border: 1px solid ${theme.colors.border}; if (sprint.status === 'in_progress') return { label: 'IN PROGRESS\nEST: TBD', isActive: true };
border-radius: 12px; return { label: 'PENDING\nTBD', isActive: false };
padding: 24px; }
`;
const ErrorBanner = styled.div`
padding: 10px 14px;
background: rgba(255,23,68,0.08);
border: 1px solid rgba(255,23,68,0.3);
border-radius: 8px;
font-size: 13px;
color: ${theme.colors.offline};
margin-bottom: 20px;
`;
export default function ProjectDetailPage() { export default function ProjectDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [project, setProject] = useState<any>(null); const [project, setProject] = useState<any>(null);
const [tasks, setTasks] = useState<any[]>([]); const [tasks, setTasks] = useState<any[]>([]);
const [activity, setActivity] = useState<any[]>([]);
const [tab, setTab] = useState('sprints');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const load = async () => { Promise.allSettled([
try {
const [projRes, tasksRes, actRes] = await Promise.all([
fetch(`${API_URL}/api/projects/${id}`), fetch(`${API_URL}/api/projects/${id}`),
fetch(`${API_URL}/api/projects/${id}/tasks`), fetch(`${API_URL}/api/projects/${id}/tasks`),
fetch(`${API_URL}/api/projects/${id}/activity`), ]).then(([pRes, tRes]) => {
]); if (pRes.status === 'fulfilled' && pRes.value.ok) pRes.value.json().then(setProject);
if (!projRes.ok) throw new Error(`Project API ${projRes.status}`); if (tRes.status === 'fulfilled' && tRes.value.ok) tRes.value.json().then(setTasks);
setProject(await projRes.json()); }).finally(() => setLoading(false));
if (tasksRes.ok) setTasks(await tasksRes.json());
if (actRes.ok) {
const d = await actRes.json();
setActivity(d.items ?? []);
}
setError(null);
} catch (e) {
setError('API 연결 실패');
} finally {
setLoading(false);
}
};
load();
}, [id]); }, [id]);
const allTasks = project?.sprints?.flatMap((s: any) => s.tasks) ?? []; if (loading) return <div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}> ...</div>;
const totalTasks = allTasks.length; if (!project) return <div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}> </div>;
const doneTasks = allTasks.filter((t: any) => t.status === 'done').length;
const progress = totalTasks > 0 ? Math.round(doneTasks / totalTasks * 100) : 0; const allTasks = tasks.flatMap((s: any) => s.tasks ?? []);
const doneTasks = allTasks.filter((t: any) => t.status === 'done');
const activeSprint = tasks.find((s: any) => s.status === 'in_progress');
return ( return (
<Page>
<BackLink href="/"> </BackLink>
{error && <ErrorBanner> {error}</ErrorBanner>}
{loading ? (
<Desc> ...</Desc>
) : project ? (
<> <>
<Header> <Breadcrumb>
<Title> <Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / SUMMARY
{project.name} </Breadcrumb>
{project.repoUrl && ( <PageTitleRow>
<GitLink href={project.repoUrl} target="_blank" rel="noopener"> <PageTitle>{project.name}</PageTitle>
Gitea <LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / {activeSprint?.name ?? 'PLANNING'}</LabelMeta>
</GitLink> </PageTitleRow>
)}
{(project.openPRs?.length ?? 0) > 0 && (
<PRCount>PR {project.openPRs.length}</PRCount>
)}
</Title>
{project.description && <Desc>{project.description}</Desc>}
<ProgressRow>
<ProgressBar value={progress} />
<ProgressLabel>{progress}% ({doneTasks}/{totalTasks})</ProgressLabel>
</ProgressRow>
</Header>
<Card> <ProjectGrid>
<TabNav tabs={TABS} active={tab} onChange={setTab} /> <div>
{/* Phase Timeline */}
{tab === 'sprints' && ( <SectionTitle>
<SprintAccordion sprints={tasks.length > 0 ? tasks : (project.sprints ?? [])} /> <span>PHASE TIMELINE</span>
<LabelMeta>CURRENT: {activeSprint ? `S${activeSprint.number}` : 'N/A'}</LabelMeta>
</SectionTitle>
<PhaseTimeline>
{tasks.map((sprint: any) => {
const { label, isActive } = getSprintMeta(sprint);
return (
<PhaseItem key={sprint.id}>
<PhaseMeta style={{ whiteSpace: 'pre-line' }}>{label}</PhaseMeta>
<PhaseBox $active={isActive}>
<PhaseName>SPRINT {String(sprint.number).padStart(2, '0')}: {sprint.name}</PhaseName>
<PhaseDesc>
{sprint.tasks?.length ?? 0} · {sprint.tasks?.filter((t: any) => t.status === 'done').length ?? 0}
</PhaseDesc>
<TechBar style={{ marginTop: 'var(--space-sm)', background: 'var(--border-color)' }}>
<TechBarFill $width={sprint.progress ?? 0} />
</TechBar>
</PhaseBox>
</PhaseItem>
);
})}
{tasks.length === 0 && (
<PhaseItem>
<PhaseMeta>PENDING{'\n'}TBD</PhaseMeta>
<PhaseBox><PhaseName>Sprint </PhaseName></PhaseBox>
</PhaseItem>
)} )}
</PhaseTimeline>
{tab === 'tasks' && ( {/* Audit Log (활동 로그) */}
<TaskTable tasks={allTasks} /> <SectionTitle>
)} <span>SECURITY AUDIT LOG</span>
<LabelMeta>LAST CHECK: LIVE</LabelMeta>
</SectionTitle>
<AuditLog>
<AuditRow $header>
<div>TIMESTAMP</div>
<div>ACTION / RESOURCE</div>
<div>RESULT</div>
</AuditRow>
{allTasks.slice(0, 5).map((task: any) => (
<AuditRow key={task.id}>
<div>{new Date(task.createdAt).toLocaleTimeString('ko-KR', { hour12: false })}</div>
<div>{task.taskId} / {task.assignee?.toUpperCase()}</div>
<StatusTag $pass={task.status === 'done'}>
{task.status === 'done' ? 'PASS' : task.status === 'failed' ? 'FAIL' : 'PEND'}
</StatusTag>
</AuditRow>
))}
</AuditLog>
</div>
{tab === 'activity' && ( <div>
<ActivityFeed items={activity} /> {/* Assigned Nodes */}
<SectionTitle style={{ marginBottom: 'var(--space-md)' }}>ASSIGNED NODES</SectionTitle>
<NodeStack>
{['harang', 'narang', 'darang', 'erang'].map((name) => (
<NodeMiniCard key={name}>
<LabelMeta>
{name === 'harang' ? '하랑' : name === 'narang' ? '나랑' : name === 'darang' ? '다랑' : '이랑'}{' '}
[{name === 'harang' ? 'PRIMARY' : name === 'narang' ? 'SECONDARY' : name === 'darang' ? 'EVALUATOR' : 'INFRA'}]
</LabelMeta>
<NodeStatusDot />
</NodeMiniCard>
))}
</NodeStack>
{/* Task Checklist */}
<SectionTitle>TASK CHECKLIST</SectionTitle>
<Checklist>
{allTasks.slice(0, 8).map((task: any) => (
<CheckItem key={task.id} $done={task.status === 'done'}>
<CheckBox $checked={task.status === 'done'} />
<span>[{task.taskId}] {task.title}</span>
</CheckItem>
))}
{allTasks.length === 0 && (
<CheckItem>
<CheckBox />
<span style={{ color: 'var(--text-secondary)' }}> </span>
</CheckItem>
)} )}
</Card> </Checklist>
</div>
</ProjectGrid>
</> </>
) : (
<Desc> </Desc>
)}
</Page>
); );
} }

View File

@@ -0,0 +1,130 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import { PageTitle, LabelMeta, TechBar, TechBarFill } from '@/components/ui/base';
import { API_URL } from '@/lib/config';
const ProjectList = styled.div`
display: flex;
flex-direction: column;
`;
const ProjectRow = styled(Link)`
display: grid;
grid-template-columns: 40px 1fr auto;
gap: var(--space-md);
padding: var(--space-md) 0;
border-bottom: 1px solid #222;
align-items: center;
text-decoration: none;
transition: opacity 0.15s;
&:last-child { border-bottom: none; }
&:hover { opacity: 0.75; }
@media (max-width: 767px) {
grid-template-columns: 32px 1fr auto;
gap: var(--space-sm);
}
`;
const Avatar = styled.div`
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #333;
filter: grayscale(100%) contrast(120%);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
border: 1px solid var(--border-color);
`;
const ProjectDetails = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
`;
const ProjectName = styled.div`
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const ProjectMeta = styled.div`
display: flex;
align-items: center;
gap: var(--space-sm);
`;
const ProjectDesc = styled.div`
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 400px;
`;
const EmptyState = styled.div`
padding: var(--space-xl) 0;
color: var(--text-secondary);
font-size: 13px;
font-family: var(--font-mono);
`;
export default function ProjectsPage() {
const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_URL}/api/projects`)
.then((r) => r.json())
.then(setProjects)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}>
<PageTitle></PageTitle>
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
</div>
{loading ? (
<EmptyState>LOADING...</EmptyState>
) : projects.length === 0 ? (
<EmptyState>NO PROJECTS REGISTERED</EmptyState>
) : (
<ProjectList>
{projects.map((p) => (
<ProjectRow key={p.id} href={`/projects/${p.id}`}>
<Avatar>📁</Avatar>
<ProjectDetails>
<ProjectName>{p.name}</ProjectName>
<ProjectMeta>
<ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc>
<TechBar style={{ width: '80px', flexShrink: 0 }}>
<TechBarFill $width={p.progress ?? 0} />
</TechBar>
<LabelMeta style={{ flexShrink: 0, fontSize: '10px' }}>{p.progress ?? 0}%</LabelMeta>
</ProjectMeta>
</ProjectDetails>
<LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta>
</ProjectRow>
))}
</ProjectList>
)}
</>
);
}

View File

@@ -1,160 +1,281 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import ConfigViewer from '@/components/sisters/ConfigViewer'; import { PageTitle, LabelMeta, SectionTitle, Btn, BtnPrimary } from '@/components/ui/base';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config';
const SISTERS = [ // ─── Styled ───
{ value: 'harang', label: '🦊 하랑이 (Orchestrator)' }, const SettingsGrid = styled.div`
{ value: 'narang', label: '🦊 나랑이 (Generator)' }, display: grid;
{ value: 'darang', label: '🐱 다랑이 (Evaluator)' }, grid-template-columns: 1fr 1fr;
{ value: 'erang', label: '🐺 이랑이 (Infra Manager)' }, gap: var(--space-xl);
];
const Page = styled.div` @media (max-width: 767px) {
padding: 32px; grid-template-columns: 1fr;
min-height: 100vh; gap: var(--space-lg);
background: ${theme.colors.bg}; }
`; `;
const PageTitle = styled.h1` const SettingsSection = styled.section``;
font-size: 22px;
font-weight: 700; const SettingsRow = styled.div`
color: ${theme.colors.textPrimary}; display: flex;
margin-bottom: 6px; justify-content: space-between;
align-items: center;
gap: var(--space-lg);
padding: var(--space-md) 0;
border-bottom: 1px solid #1f1f1f;
&:last-child { border-bottom: none; }
`; `;
const PageSubtitle = styled.p` const SettingInfo = styled.div`
flex: 1;
min-width: 0;
`;
const SettingLabel = styled.div`
font-size: 14px; font-size: 14px;
color: ${theme.colors.textSecondary}; font-weight: 500;
margin-bottom: 28px; color: var(--text-primary);
margin-bottom: 2px;
`; `;
const Controls = styled.div` const SettingDesc = styled.div`
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
`;
// Toggle Switch
const SwitchWrapper = styled.label`
position: relative;
display: inline-block;
width: 34px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
`;
const SwitchInput = styled.input`
opacity: 0;
width: 0;
height: 0;
position: absolute;
`;
const Slider = styled.span<{ $checked: boolean }>`
position: absolute;
inset: 0;
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
background: transparent;
transition: border-color 0.2s;
cursor: pointer;
&::after {
content: '';
position: absolute;
left: ${({ $checked }) => $checked ? '16px' : '2px'};
top: 2px;
width: 12px;
height: 12px;
background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--text-secondary)'};
transition: left 0.2s, background 0.2s;
}
`;
const BracketInputGroup = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 4px;
margin-bottom: 20px; font-family: var(--font-mono);
font-size: 14px;
color: var(--text-secondary);
flex-shrink: 0;
`; `;
const Select = styled.select` const BracketInput = styled.input`
background: ${theme.colors.cardBg}; background: transparent;
border: 1px solid ${theme.colors.border}; border: none;
border-radius: 8px; color: var(--text-primary);
color: ${theme.colors.textPrimary}; font-family: var(--font-mono);
padding: 8px 12px;
font-size: 14px; font-size: 14px;
cursor: pointer; text-align: center;
width: 60px;
outline: none; outline: none;
transition: border-color 0.15s; padding: 2px 0;
border-bottom: 1px solid var(--border-color);
&:focus { border-color: ${theme.colors.accent}; } &:focus { border-bottom-color: var(--text-primary); }
option {
background: #1a1f2a;
}
`; `;
const Card = styled.div` const ActionBar = styled.div`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 12px;
padding: 24px;
`;
const InfoRow = styled.div`
display: flex; display: flex;
gap: 16px; justify-content: flex-end;
margin-bottom: 16px; gap: var(--space-md);
padding-bottom: 16px; padding-top: var(--space-xl);
border-bottom: 1px solid ${theme.colors.border}; border-top: 1px solid var(--border-color);
`; `;
const InfoItem = styled.div` function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
font-size: 13px;
color: ${theme.colors.textSecondary};
strong {
color: ${theme.colors.textPrimary};
display: block;
font-size: 14px;
margin-bottom: 2px;
}
`;
const SectionTitle = styled.h3`
font-size: 12px;
font-weight: 600;
color: ${theme.colors.textSecondary};
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 12px;
`;
export default function SettingsPage() {
const [selected, setSelected] = useState('narang');
const [configData, setConfigData] = useState<any>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const load = async () => {
setLoading(true);
setConfigData(null);
try {
const res = await fetch(`${API_URL}/api/sisters/${selected}/config`);
if (res.ok) setConfigData(await res.json());
} catch {
// silent
} finally {
setLoading(false);
}
};
load();
}, [selected]);
return ( return (
<Page> <SwitchWrapper onClick={onChange}>
<PageTitle> </PageTitle> <SwitchInput type="checkbox" checked={checked} onChange={() => {}} />
<PageSubtitle> SOUL.md / AGENTS.md </PageSubtitle> <Slider $checked={checked} />
</SwitchWrapper>
<Controls> );
<Select value={selected} onChange={(e) => setSelected(e.target.value)}> }
{SISTERS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option> function BInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
))} return (
</Select> <BracketInputGroup>
</Controls> [<BracketInput value={value} onChange={(e) => onChange(e.target.value)} />]
</BracketInputGroup>
<Card> );
{configData && ( }
<InfoRow>
<InfoItem> export default function SettingsPage() {
<strong>{configData.name}</strong> const [settings, setSettings] = useState({
twofa: true,
</InfoItem> sessionTimeout: '15',
<InfoItem> ipWhitelist: false,
<strong>{configData.role}</strong> autoBackup: true,
backupInterval: '08:00',
</InfoItem> backupRetention: '30',
<InfoItem style={{ flex: 1 }}> queueThreshold: '500',
<strong>{configData.description}</strong> autoScaling: true,
cpuThreshold: '90',
</InfoItem> latencyThreshold: '250',
</InfoRow> nodeOfflineAlert: true,
)} });
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle> const toggle = (key: keyof typeof settings) =>
setSettings((p) => ({ ...p, [key]: !p[key] }));
{loading ? ( const setVal = (key: keyof typeof settings, val: string) =>
<div style={{ padding: '32px', textAlign: 'center', color: theme.colors.textSecondary, fontSize: '13px' }}> setSettings((p) => ({ ...p, [key]: val }));
...
</div> return (
) : ( <>
<ConfigViewer content={configData?.raw ?? ''} /> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 'var(--space-lg)' }}>
)} <PageTitle> </PageTitle>
</Card> <LabelMeta><span>CONFIG:</span>v2.0.4-STABLE</LabelMeta>
</Page> </div>
<SettingsGrid>
{/* SEC 01 */}
<SettingsSection>
<SectionTitle>
<span>ADMIN PROTOCOL RULES</span>
<LabelMeta>SEC: 01</LabelMeta>
</SectionTitle>
<SettingsRow>
<SettingInfo>
<SettingLabel> 2 (2FA)</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.twofa} onChange={() => toggle('twofa')} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> ().</SettingDesc>
</SettingInfo>
<BInput value={settings.sessionTimeout} onChange={(v) => setVal('sessionTimeout', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel>IP </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.ipWhitelist} onChange={() => toggle('ipWhitelist')} />
</SettingsRow>
</SettingsSection>
{/* SEC 02 */}
<SettingsSection>
<SectionTitle>
<span>BACKUP &amp; REDUNDANCY</span>
<LabelMeta>SEC: 02</LabelMeta>
</SectionTitle>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.autoBackup} onChange={() => toggle('autoBackup')} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> ()</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.backupInterval} onChange={(v) => setVal('backupInterval', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> ()</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.backupRetention} onChange={(v) => setVal('backupRetention', v)} />
</SettingsRow>
</SettingsSection>
{/* SEC 03 */}
<SettingsSection>
<SectionTitle>
<span>SYNC QUEUE THRESHOLDS</span>
<LabelMeta>SEC: 03</LabelMeta>
</SectionTitle>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.queueThreshold} onChange={(v) => setVal('queueThreshold', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.autoScaling} onChange={() => toggle('autoScaling')} />
</SettingsRow>
</SettingsSection>
{/* SEC 04 */}
<SettingsSection>
<SectionTitle>
<span>NODE ALERT TRIGGERS</span>
<LabelMeta>SEC: 04</LabelMeta>
</SectionTitle>
<SettingsRow>
<SettingInfo>
<SettingLabel>CPU (%)</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.cpuThreshold} onChange={(v) => setVal('cpuThreshold', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> (ms)</SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<BInput value={settings.latencyThreshold} onChange={(v) => setVal('latencyThreshold', v)} />
</SettingsRow>
<SettingsRow>
<SettingInfo>
<SettingLabel> </SettingLabel>
<SettingDesc> .</SettingDesc>
</SettingInfo>
<Toggle checked={settings.nodeOfflineAlert} onChange={() => toggle('nodeOfflineAlert')} />
</SettingsRow>
</SettingsSection>
</SettingsGrid>
<ActionBar>
<Btn onClick={() => setSettings((p) => ({ ...p }))}></Btn>
<BtnPrimary> </BtnPrimary>
</ActionBar>
</>
); );
} }

View File

@@ -4,192 +4,208 @@ import React, { useEffect, useState } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import SisterHeader from '@/components/sisters/SisterHeader'; import { PageTitle, LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent } from '@/components/ui/base';
import ConfigViewer from '@/components/sisters/ConfigViewer';
import SessionList from '@/components/sisters/SessionList';
import SubagentList from '@/components/sisters/SubagentList';
import ActivityFeed from '@/components/dashboard/ActivityFeed';
import TabNav from '@/components/common/TabNav';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
import { SISTER_ROLES } from '@/lib/sisters';
type Status = 'online' | 'offline' | 'working' | 'unknown'; const Breadcrumb = styled.div`
font-family: var(--font-mono);
const TABS = [ font-size: 11px;
{ id: 'overview', label: '개요', icon: '👤' }, color: var(--text-secondary);
{ id: 'config', label: '설정', icon: '⚙️' }, margin-bottom: var(--space-md);
{ id: 'sessions', label: '세션', icon: '💬' }, a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
{ id: 'subagents', label: '서브에이전트', icon: '🤖' },
];
const Page = styled.div`
padding: 32px;
min-height: 100vh;
background: ${theme.colors.bg};
`; `;
const BackLink = styled(Link)` const DetailGrid = styled.div`
font-size: 13px;
color: ${theme.colors.textSecondary};
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 20px;
transition: color 0.15s;
&:hover { color: ${theme.colors.textPrimary}; }
`;
const Card = styled.div`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 12px;
padding: 24px;
`;
const OverviewGrid = styled.div`
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 280px 1fr;
gap: 20px; gap: var(--space-xxl);
align-items: start;
@media (max-width: 768px) { @media (max-width: 1199px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: var(--space-xl);
} }
`; `;
const SectionTitle = styled.h3` const MetaPanel = styled.div`
font-size: 13px; display: flex;
font-weight: 600; flex-direction: column;
color: ${theme.colors.textSecondary}; gap: var(--space-lg);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 14px;
`; `;
const CurrentTaskBox = styled.div` const MetaCard = styled.div`
padding: 12px 16px; border: 1px solid var(--border-color);
background: rgba(88,166,255,0.06); padding: var(--space-lg);
border: 1px solid rgba(88,166,255,0.2);
border-radius: 8px;
font-size: 13px;
color: ${theme.colors.accent};
margin-bottom: 20px;
`; `;
const ErrorBanner = styled.div` const StatusRow = styled.div`
padding: 10px 14px; display: flex;
background: rgba(255,23,68,0.08); align-items: center;
border: 1px solid rgba(255,23,68,0.3); gap: var(--space-sm);
border-radius: 8px;
font-size: 13px;
color: ${theme.colors.offline};
margin-bottom: 20px;
`; `;
const StatusDot = styled.div<{ $on: boolean }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $on }) => $on ? '#FFF' : '#555'};
${({ $on }) => $on && `box-shadow: 0 0 8px rgba(255,255,255,0.3);`}
`;
const ConfigPre = styled.pre`
background: #0d0d0d;
border: 1px solid var(--border-color);
padding: var(--space-md);
font-family: var(--font-mono);
font-size: 11px;
color: #8888aa;
overflow: auto;
max-height: 400px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
`;
const TabBar = styled.div`
display: flex;
gap: var(--space-lg);
border-bottom: 1px solid var(--border-color);
margin-bottom: var(--space-lg);
`;
const TabBtn = styled.button<{ $active: boolean }>`
font-size: 13px;
font-weight: 500;
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
background: transparent;
border: none;
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
padding: var(--space-sm) 0;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
&:hover { color: var(--text-primary); }
`;
const SISTER_DISPLAY: Record<string, string> = {
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
};
export default function SisterDetailPage() { export default function SisterDetailPage() {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
const [sisters, setSisters] = useState<any[]>([]); const [sisterInfo, setSisterInfo] = useState<any>(null);
const [configData, setConfigData] = useState<any>(null); const [configData, setConfigData] = useState<any>(null);
const [sessions, setSessions] = useState<any[]>([]);
const [subagents, setSubagents] = useState<any[]>([]);
const [activity, setActivity] = useState<any[]>([]); const [activity, setActivity] = useState<any[]>([]);
const [tab, setTab] = useState('overview'); const [tab, setTab] = useState('overview');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const load = async () => { Promise.allSettled([
try {
const [sistersRes, configRes, sessionsRes, subagentsRes, activityRes] = await Promise.allSettled([
fetch(`${API_URL}/api/sisters`), fetch(`${API_URL}/api/sisters`),
fetch(`${API_URL}/api/sisters/${name}/config`), fetch(`${API_URL}/api/sisters/${name}/config`),
fetch(`${API_URL}/api/sisters/${name}/sessions`),
fetch(`${API_URL}/api/sisters/${name}/subagents`),
fetch(`${API_URL}/api/sisters/${name}/activity`), fetch(`${API_URL}/api/sisters/${name}/activity`),
]); ]).then(([sRes, cRes, aRes]) => {
if (sRes.status === 'fulfilled' && sRes.value.ok) {
if (sistersRes.status === 'fulfilled' && sistersRes.value.ok) { sRes.value.json().then((d: any[]) => setSisterInfo(d.find((s) => s.name === name)));
setSisters(await sistersRes.value.json());
} }
if (configRes.status === 'fulfilled' && configRes.value.ok) { if (cRes.status === 'fulfilled' && cRes.value.ok) cRes.value.json().then(setConfigData);
setConfigData(await configRes.value.json()); if (aRes.status === 'fulfilled' && aRes.value.ok) {
aRes.value.json().then((d: any) => setActivity(d.items ?? []));
} }
if (sessionsRes.status === 'fulfilled' && sessionsRes.value.ok) { }).finally(() => setLoading(false));
setSessions(await sessionsRes.value.json());
}
if (subagentsRes.status === 'fulfilled' && subagentsRes.value.ok) {
setSubagents(await subagentsRes.value.json());
}
if (activityRes.status === 'fulfilled' && activityRes.value.ok) {
const d = await activityRes.value.json();
setActivity(d.items ?? []);
}
setError(null);
} catch {
setError('API 연결 실패');
} finally {
setLoading(false);
}
};
load();
}, [name]); }, [name]);
const sisterInfo = sisters.find((s) => s.name === name); const isActive = sisterInfo?.status === 'online' || sisterInfo?.status === 'working';
return ( return (
<Page> <>
<BackLink href="/"> </BackLink> <Breadcrumb>
<Link href="/sisters"> </Link> / {SISTER_DISPLAY[name] ?? name}
{error && <ErrorBanner> {error}</ErrorBanner>} </Breadcrumb>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
<PageTitle>
{SISTER_DISPLAY[name] ?? name}
<span style={{ fontSize: '16px', color: 'var(--text-secondary)', fontWeight: 400, marginLeft: 'var(--space-sm)' }}>
/ {SISTER_ROLES[name] ?? 'UNKNOWN'}
</span>
</PageTitle>
<LabelMeta><span>STATUS:</span>{sisterInfo?.status?.toUpperCase() ?? 'UNKNOWN'}</LabelMeta>
</div>
{loading ? ( {loading ? (
<div style={{ color: theme.colors.textSecondary, fontSize: '14px' }}> ...</div> <div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div>
) : ( ) : (
<> <DetailGrid>
<SisterHeader <MetaPanel>
name={name} <MetaCard>
role={configData?.role ?? ''} <StatusRow>
description={configData?.description ?? ''} <StatusDot $on={isActive} />
status={(sisterInfo?.status ?? 'unknown') as Status} <LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
lastSeen={sisterInfo?.lastSeen ?? null} </StatusRow>
lxcId={sisterInfo?.lxcId ?? 0} <div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
/> LXC: {sisterInfo?.lxcId ?? '--'}<br />
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
ROLE: {SISTER_ROLES[name] ?? '--'}
</div>
<TechBar>
<TechBarFill $width={isActive ? 100 : 0} />
</TechBar>
</MetaCard>
<Card> {configData?.description && (
<TabNav tabs={TABS} active={tab} onChange={setTab} /> <MetaCard>
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>DESC</LabelMeta>
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
{configData.description}
</div>
</MetaCard>
)}
</MetaPanel>
<div>
<TabBar>
{[
{ id: 'overview', label: '개요' },
{ id: 'config', label: '설정' },
].map((t) => (
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
{t.label}
</TabBtn>
))}
</TabBar>
{tab === 'overview' && ( {tab === 'overview' && (
<> <>
{sisterInfo?.currentTask && ( <SectionTitle>
<CurrentTaskBox>📌 : {sisterInfo.currentTask}</CurrentTaskBox> <span>RECENT ACTIVITY</span>
<LabelMeta>{activity.length} ENTRIES</LabelMeta>
</SectionTitle>
<Timeline>
{activity.slice(0, 8).map((item) => (
<TimelineItem key={item.id}>
<TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp>
<TimelineContent>{item.detail ?? item.action}</TimelineContent>
</TimelineItem>
))}
{activity.length === 0 && (
<TimelineItem>
<TimelineContent style={{ color: 'var(--text-secondary)' }}> </TimelineContent>
</TimelineItem>
)} )}
<SectionTitle> </SectionTitle> </Timeline>
<ActivityFeed items={activity.slice(0, 5)} />
</> </>
)} )}
{tab === 'config' && ( {tab === 'config' && (
<> <>
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle> <SectionTitle>SOUL.md / AGENTS.md</SectionTitle>
<ConfigViewer content={configData?.raw ?? ''} /> <ConfigPre>{configData?.raw || '(설정 파일 없음)'}</ConfigPre>
</> </>
)} )}
</div>
{tab === 'sessions' && ( </DetailGrid>
<>
<SectionTitle> </SectionTitle>
<SessionList sessions={sessions} />
</>
)} )}
{tab === 'subagents' && (
<>
<SectionTitle> </SectionTitle>
<SubagentList agents={subagents} />
</> </>
)}
</Card>
</>
)}
</Page>
); );
} }

View File

@@ -3,170 +3,262 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import StatusBadge from '@/components/common/StatusBadge'; import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
const sisterEmojis: Record<string, string> = { const SISTER_ROLES: Record<string, string> = {
harang: '🦊', narang: '🦊', darang: '🐱', erang: '🐺', harang: 'Primary',
narang: 'Secondary',
darang: 'Standby',
erang: 'Sync',
}; };
const sisterDisplayNames: Record<string, string> = { const GRAPH_BARS: Record<string, number[]> = {
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이', harang: [40, 45, 60, 55, 70, 85, 92, 84],
narang: [30, 32, 35, 40, 38, 45, 50, 48],
darang: [10, 10, 5, 5, 5, 5, 2, 2],
erang: [20, 25, 15, 30, 40, 35, 20, 15],
}; };
const Page = styled.div` const NodeGrid = styled.section`
padding: 32px; display: flex;
min-height: 100vh; flex-direction: column;
background: ${theme.colors.bg}; gap: var(--space-lg);
`; `;
const PageTitle = styled.h1` const NodeEntry = styled.div`
font-size: 22px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 6px;
`;
const PageSubtitle = styled.p`
font-size: 14px;
color: ${theme.colors.textSecondary};
margin-bottom: 28px;
`;
const Grid = styled.div`
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: 220px 1fr 160px;
gap: 20px; gap: 0;
`; border: 1px solid var(--border-color);
transition: border-color 0.2s;
const Card = styled(Link)` &:hover { border-color: var(--border-hover); }
display: block;
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 14px;
padding: 24px;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
text-decoration: none;
&:hover { @media (max-width: 1199px) {
transform: translateY(-3px); grid-template-columns: 200px 1fr;
box-shadow: 0 8px 28px rgba(0,0,0,0.4); }
border-color: rgba(88,166,255,0.3);
@media (max-width: 767px) {
grid-template-columns: 1fr;
} }
`; `;
const CardTop = styled.div` const NodeInfo = styled.div`
border-right: 1px solid var(--border-color);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-md);
@media (max-width: 767px) {
border-right: none;
border-bottom: 1px solid var(--border-color);
}
`;
const NodeName = styled(Link)`
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: var(--space-sm);
margin-bottom: 14px; font-size: 16px;
font-weight: 600;
color: var(--text-primary);
text-decoration: none;
transition: color 0.15s;
&:hover { color: var(--accent-hover); }
`; `;
const Avatar = styled.div` const StatusIndicator = styled.div<{ $active: boolean }>`
font-size: 36px; width: 8px;
line-height: 1; height: 8px;
border-radius: 50%;
background: ${({ $active }) => $active ? '#FFF' : '#555'};
flex-shrink: 0;
${({ $active }) => $active && `box-shadow: 0 0 8px rgba(255,255,255,0.3);`}
`; `;
const NameBlock = styled.div``; const MetaPair = styled.div`
const Name = styled.div`
font-size: 18px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 4px;
`;
const Role = styled.div`
font-size: 12px;
color: ${theme.colors.textSecondary};
`;
const Divider = styled.div`
height: 1px;
background: ${theme.colors.border};
margin: 12px 0;
`;
const MetaItem = styled.div`
font-size: 12px;
color: ${theme.colors.textSecondary};
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 6px; gap: 2px;
margin-bottom: 6px;
`; `;
const TaskBox = styled.div` const MetaVal = styled.div`
margin-top: 10px; font-size: 13px;
padding: 8px 12px; color: var(--text-primary);
background: rgba(88,166,255,0.06); font-family: var(--font-mono);
border: 1px solid rgba(88,166,255,0.15);
border-radius: 6px;
font-size: 12px;
color: ${theme.colors.accent};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`; `;
function formatLastSeen(lastSeen: string | null): string { const NodeControls = styled.div`
if (!lastSeen) return '기록 없음'; display: flex;
const diff = Date.now() - new Date(lastSeen).getTime(); gap: var(--space-sm);
const min = Math.floor(diff / 60000); flex-wrap: wrap;
if (min < 1) return '방금 전'; `;
if (min < 60) return `${min}분 전`;
const hr = Math.floor(min / 60); const CapacitySection = styled.div`
return hr < 24 ? `${hr}시간 전` : `${Math.floor(hr / 24)}일 전`; border-right: 1px solid var(--border-color);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-md);
@media (max-width: 1199px) {
border-right: none;
}
@media (max-width: 767px) {
border-bottom: 1px solid var(--border-color);
}
`;
const GraphContainer = styled.div`
display: flex;
align-items: flex-end;
gap: 3px;
height: 48px;
`;
const GraphBar = styled.div<{ $height: number; $highlight?: boolean }>`
flex: 1;
height: ${({ $height }) => $height}%;
min-height: 2px;
background: ${({ $highlight }) => $highlight ? 'var(--text-primary)' : 'var(--border-hover)'};
transition: height 0.3s;
`;
const SyncRow = styled.div`
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
padding: 2px 0;
`;
const SyncHistory = styled.div`
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-sm);
@media (max-width: 1199px) {
display: none;
}
`;
function getNodeStatus(s: any): boolean {
return s.status === 'online' || s.status === 'working';
} }
export default function SistersListPage() { function getStatusBtns(s: any): [string, string] {
const [sisters, setSisters] = useState<any[]>([]); if (s.status === 'offline') return ['STANDBY', 'ACTIVATE'];
const [loading, setLoading] = useState(true); if (s.status === 'working') return ['ACTIVE', 'REBOOT'];
return ['ACTIVE', 'REBOOT'];
}
function formatLastSeen(lastSeen: string | null): string {
if (!lastSeen) return '—';
const diff = Date.now() - new Date(lastSeen).getTime();
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ago`;
}
const MOCK_SISTERS = [
{ id: 1, name: 'harang', status: 'online', lastSeen: new Date(Date.now() - 3600000).toISOString() },
{ id: 2, name: 'narang', status: 'working', lastSeen: new Date(Date.now() - 1800000).toISOString() },
{ id: 3, name: 'darang', status: 'offline', lastSeen: new Date(Date.now() - 7200000).toISOString() },
{ id: 4, name: 'erang', status: 'online', lastSeen: new Date(Date.now() - 900000).toISOString() },
];
export default function SistersPage() {
const [sisters, setSisters] = useState<any[]>(MOCK_SISTERS);
useEffect(() => { useEffect(() => {
const load = async () => { fetch(`${API_URL}/api/sisters`)
try { .then((r) => r.json())
const res = await fetch(`${API_URL}/api/sisters`); .then(setSisters)
if (res.ok) setSisters(await res.json()); .catch(() => {});
} catch { const iv = setInterval(() => {
// silent fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
} finally { }, 15000);
setLoading(false); return () => clearInterval(iv);
}
};
load();
const interval = setInterval(load, 30000);
return () => clearInterval(interval);
}, []); }, []);
return ( return (
<Page> <>
<PageTitle></PageTitle> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}>
<PageSubtitle> 4 . .</PageSubtitle> <PageTitle> </PageTitle>
<LabelMeta><span>TOTAL NODES:</span> {String(sisters.length).padStart(2, '0')}</LabelMeta>
</div>
{loading ? ( <NodeGrid>
<div style={{ color: theme.colors.textSecondary, fontSize: '14px' }}> ...</div> {sisters.map((s) => {
) : ( const isActive = getNodeStatus(s);
<Grid> const [btn1, btn2] = getStatusBtns(s);
{sisters.map((s) => ( const bars = GRAPH_BARS[s.name] ?? [5, 5, 5, 5, 5, 5, 5, 5];
<Card key={s.id} href={`/sisters/${s.name}`}> const loadIndex = isActive
<CardTop> ? `${(bars[bars.length - 1]).toFixed(1)}%`
<Avatar>{sisterEmojis[s.name] ?? '🤖'}</Avatar> : '0.0%';
<NameBlock> const metaLabel = s.status === 'offline' ? 'Last Active' : s.name === 'erang' ? 'Queue Depth' : 'Uptime';
<Name>{sisterDisplayNames[s.name] ?? s.name}</Name> const metaVal = s.status === 'offline'
<Role>{s.role}</Role> ? formatLastSeen(s.lastSeen)
</NameBlock> : s.name === 'erang' ? '12 PKTS'
</CardTop> : formatLastSeen(s.lastSeen).replace(' ago', '');
<StatusBadge status={s.status} />
<Divider /> return (
<MetaItem>🕐 : {formatLastSeen(s.lastSeen)}</MetaItem> <NodeEntry key={s.id ?? s.name}>
<MetaItem>🖥 LXC {s.lxcId}</MetaItem> <NodeInfo>
{s.currentTask && <TaskBox>📌 {s.currentTask}</TaskBox>} <NodeName href={`/sisters/${s.name}`}>
</Card> <StatusIndicator $active={isActive} />
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'}{' '}
({SISTER_ROLES[s.name] ?? s.name})
</NodeName>
<MetaPair>
<LabelMeta>{metaLabel}</LabelMeta>
<MetaVal>{metaVal}</MetaVal>
</MetaPair>
<NodeControls>
<BtnToggle $active>{btn1}</BtnToggle>
<BtnToggle>{btn2}</BtnToggle>
</NodeControls>
</NodeInfo>
<CapacitySection>
<LabelMeta>CAPACITY HISTORY (24H)</LabelMeta>
<GraphContainer>
{bars.map((h, i) => (
<GraphBar key={i} $height={h} $highlight={i >= bars.length - 2} />
))} ))}
</Grid> </GraphContainer>
)} <SyncRow>
</Page> <span>LOAD INDEX</span>
<span>{loadIndex}</span>
</SyncRow>
</CapacitySection>
<SyncHistory>
<LabelMeta>SYNC LOG</LabelMeta>
{[
{ time: '14:05:22', status: isActive ? 'SUCCESS' : 'IDLE' },
{ time: '13:55:01', status: isActive ? 'SUCCESS' : 'IDLE' },
{ time: '13:44:10', status: isActive ? 'SUCCESS' : 'IDLE' },
].map((row) => (
<SyncRow key={row.time}>
<span>{row.time}</span>
<span style={{ color: row.status === 'SUCCESS' ? '#00FF00' : 'var(--text-secondary)' }}>
{row.status}
</span>
</SyncRow>
))}
</SyncHistory>
</NodeEntry>
);
})}
</NodeGrid>
</>
); );
} }

View File

@@ -18,7 +18,7 @@ const Wrapper = styled.div`
const Title = styled.div` const Title = styled.div`
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
margin-bottom: 16px; margin-bottom: 16px;
@@ -57,18 +57,18 @@ const Bar = styled.div<{ $height: number; $color: string }>`
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
font-size: 11px; font-size: 11px;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
white-space: nowrap; white-space: nowrap;
background: rgba(22,27,34,0.95); background: rgba(22,27,34,0.95);
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
} }
`; `;
const Label = styled.div` const Label = styled.div`
font-size: 10px; font-size: 10px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -79,11 +79,11 @@ const Label = styled.div`
const Empty = styled.div` const Empty = styled.div`
padding: 32px; padding: 32px;
text-align: center; text-align: center;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
font-size: 13px; font-size: 13px;
`; `;
const COLORS = [theme.colors.accent, theme.colors.online, '#FF9800', '#E040FB', '#00BCD4']; const COLORS = ['#58A6FF', '#00FF00', '#FF9800', '#E040FB', '#00BCD4'];
export default function BarChart({ data, maxValue, unit = '', title }: BarChartProps) { export default function BarChart({ data, maxValue, unit = '', title }: BarChartProps) {
if (!data.length) return <Empty> </Empty>; if (!data.length) return <Empty> </Empty>;

View File

@@ -25,7 +25,7 @@ const Overlay = styled.div`
const Modal = styled.div` const Modal = styled.div`
background: #161b22; background: #161b22;
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
padding: 28px; padding: 28px;
min-width: 320px; min-width: 320px;
@@ -36,13 +36,13 @@ const Modal = styled.div`
const Title = styled.h2` const Title = styled.h2`
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
margin-bottom: 10px; margin-bottom: 10px;
`; `;
const Message = styled.p` const Message = styled.p`
font-size: 14px; font-size: 14px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
line-height: 1.6; line-height: 1.6;
margin-bottom: 24px; margin-bottom: 24px;
`; `;

View File

@@ -3,35 +3,39 @@
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import { SidebarProvider, useSidebar } from '@/lib/SidebarContext';
const LayoutRoot = styled.div` const LayoutRoot = styled.div`
display: flex; display: flex;
width: 100%;
max-width: 1600px;
margin: 0 auto;
min-height: 100vh; min-height: 100vh;
background: #0d1117;
`; `;
const MainContent = styled.main<{ $collapsed: boolean }>` const MainContent = styled.main`
flex: 1; flex-grow: 1;
margin-left: ${({ $collapsed }) => ($collapsed ? '60px' : '220px')}; padding: var(--space-xl) var(--space-xxl);
min-height: 100vh; display: flex;
transition: margin-left 0.2s ease; flex-direction: column;
`; gap: var(--space-xl);
min-width: 0;
function Inner({ children }: { children: React.ReactNode }) { @media (max-width: 767px) {
const { collapsed } = useSidebar(); padding: var(--space-lg) var(--space-md);
return ( padding-bottom: 72px;
<LayoutRoot> gap: var(--space-lg);
<Sidebar /> }
<MainContent $collapsed={collapsed}>{children}</MainContent>
</LayoutRoot> @media (min-width: 768px) and (max-width: 1199px) {
); padding: var(--space-xl) var(--space-xl);
} }
`;
export default function LayoutShell({ children }: { children: React.ReactNode }) { export default function LayoutShell({ children }: { children: React.ReactNode }) {
return ( return (
<SidebarProvider> <LayoutRoot>
<Inner>{children}</Inner> <Sidebar />
</SidebarProvider> <MainContent>{children}</MainContent>
</LayoutRoot>
); );
} }

View File

@@ -21,9 +21,9 @@ const Fill = styled.div<{ $value: number }>`
height: 100%; height: 100%;
width: ${({ $value }) => $value}%; width: ${({ $value }) => $value}%;
background: ${({ $value }) => background: ${({ $value }) =>
$value >= 80 ? theme.colors.online : $value >= 80 ? '#00FF00' :
$value >= 40 ? theme.colors.accent : $value >= 40 ? '#58A6FF' :
theme.colors.textSecondary}; 'var(--text-secondary)'};
border-radius: 999px; border-radius: 999px;
transition: width 0.4s ease; transition: width 0.4s ease;
`; `;

View File

@@ -6,138 +6,175 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useSidebar } from '@/lib/SidebarContext'; import { useSidebar } from '@/lib/SidebarContext';
const MENU_ITEMS = [ const NAV_ITEMS = [
{ href: '/', icon: '⬛', label: '대시보드' }, { href: '/', label: '대시' },
{ href: '/projects', icon: '📁', label: '프로젝트' }, { href: '/activities', label: '활동' },
{ href: '/sisters', icon: '🦊', label: '자매' }, { href: '/projects', label: '프로' },
{ href: '/org', icon: '🏢', label: '조직도' }, { href: '/sisters', label: '자매' },
{ href: '/settings', icon: '⚙️', label: '설정' }, { href: '/org', label: '조직' },
{ href: '/settings', label: '설정' },
{ href: '/admin', label: '관리' },
]; ];
const ADMIN_ITEMS = [ // ─── Desktop Sidebar ───
{ href: '/admin', icon: '🔧', label: '관리자' }, const SidebarWrapper = styled.aside`
]; width: 240px;
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; flex-shrink: 0;
border-right: 1px solid var(--border-color);
&:hover { color: #E6EDF3; background: rgba(240, 246, 252, 0.06); }
`;
const Nav = styled.nav`
flex: 1;
padding: 12px 8px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; padding: var(--space-xl) var(--space-lg);
`; position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
const NavItem = styled(Link)<{ $active: boolean }>` @media (min-width: 768px) and (max-width: 1199px) {
display: flex; width: 64px;
padding: var(--space-xl) var(--space-md);
align-items: center; 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 { @media (max-width: 767px) {
background: rgba(240, 246, 252, 0.06); display: none;
color: #E6EDF3;
} }
`; `;
const NavIcon = styled.span` const LogoContainer = styled.div`
font-size: 16px; 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; flex-shrink: 0;
`; `;
const Divider = styled.div` const CircleHalf = styled.div`
height: 1px; width: 10px;
background: rgba(240, 246, 252, 0.08); height: 20px;
margin: 8px 8px; background-color: var(--text-primary);
border-radius: 0 20px 20px 0;
flex-shrink: 0;
`; `;
const AdminSection = styled.div` const NavMenu = styled.ul`
padding: 8px; 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() { export default function Sidebar() {
const { collapsed, setCollapsed } = useSidebar();
const pathname = usePathname(); const pathname = usePathname();
const isActive = (href: string) =>
href === '/' ? pathname === '/' : pathname.startsWith(href);
return ( return (
<Wrapper $collapsed={collapsed}> <>
<LogoArea> <SidebarWrapper>
<span style={{ fontSize: '22px', flexShrink: 0 }}>🦊</span> <LogoContainer>
{!collapsed && <LogoText></LogoText>} <CircleFull />
<CollapseBtn onClick={() => setCollapsed(!collapsed)}> <CircleHalf />
{collapsed ? '' : ''} </LogoContainer>
</CollapseBtn> <nav>
</LogoArea> <NavMenu>
{NAV_ITEMS.map((item) => (
<Nav> <NavItem key={item.href}>
{MENU_ITEMS.map((item) => ( <NavLink href={item.href} $active={isActive(item.href)}>
<NavItem key={item.href} href={item.href} $active={pathname === item.href}> {item.label}
<NavIcon>{item.icon}</NavIcon> </NavLink>
{!collapsed && item.label}
</NavItem> </NavItem>
))} ))}
</Nav> </NavMenu>
</nav>
</SidebarWrapper>
<Divider /> <BottomTabBar>
<AdminSection> {NAV_ITEMS.map((item) => (
{ADMIN_ITEMS.map((item) => ( <TabItem key={item.href} href={item.href} $active={isActive(item.href)}>
<NavItem key={item.href} href={item.href} $active={pathname === item.href}> {item.label}
<NavIcon>{item.icon}</NavIcon> </TabItem>
{!collapsed && item.label}
</NavItem>
))} ))}
</AdminSection> </BottomTabBar>
</Wrapper> </>
); );
} }

View File

@@ -18,10 +18,10 @@ const statusLabels: Record<Status, string> = {
}; };
const statusColors: Record<Status, string> = { const statusColors: Record<Status, string> = {
online: theme.colors.online, online: '#00FF00',
offline: theme.colors.offline, offline: '#FF1744',
working: theme.colors.working, working: '#2979FF',
unknown: theme.colors.textSecondary, unknown: 'var(--text-secondary)',
}; };
const BadgeWrapper = styled.span<{ $status: Status }>` const BadgeWrapper = styled.span<{ $status: Status }>`

View File

@@ -19,7 +19,7 @@ interface TabNavProps {
const Nav = styled.div` const Nav = styled.div`
display: flex; display: flex;
gap: 4px; gap: 4px;
border-bottom: 1px solid ${theme.colors.border}; border-bottom: 1px solid var(--border-color);
margin-bottom: 20px; margin-bottom: 20px;
`; `;
@@ -27,8 +27,8 @@ const TabBtn = styled.button<{ $active: boolean }>`
padding: 10px 16px; padding: 10px 16px;
background: none; background: none;
border: none; border: none;
border-bottom: 2px solid ${({ $active }) => $active ? theme.colors.accent : 'transparent'}; border-bottom: 2px solid ${({ $active }) => $active ? '#58A6FF' : 'transparent'};
color: ${({ $active }) => $active ? theme.colors.textPrimary : theme.colors.textSecondary}; color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
font-size: 14px; font-size: 14px;
font-weight: ${({ $active }) => $active ? '600' : '400'}; font-weight: ${({ $active }) => $active ? '600' : '400'};
cursor: pointer; cursor: pointer;
@@ -38,7 +38,7 @@ const TabBtn = styled.button<{ $active: boolean }>`
gap: 6px; gap: 6px;
&:hover { &:hover {
color: ${theme.colors.textPrimary}; color: var(--text-primary);
} }
`; `;

View File

@@ -11,11 +11,11 @@ interface TaskBadgeProps {
} }
const statusConfig: Record<TaskStatus, { label: string; color: string; bg: string }> = { const statusConfig: Record<TaskStatus, { label: string; color: string; bg: string }> = {
pending: { label: '대기', color: theme.colors.textSecondary, bg: 'rgba(139,148,158,0.1)' }, pending: { label: '대기', color: 'var(--text-secondary)', bg: 'rgba(139,148,158,0.1)' },
in_progress: { label: '진행중', color: theme.colors.working, bg: 'rgba(41,121,255,0.12)' }, in_progress: { label: '진행중', color: '#2979FF', bg: 'rgba(41,121,255,0.12)' },
review: { label: '리뷰', color: '#FF9800', bg: 'rgba(255,152,0,0.12)' }, review: { label: '리뷰', color: '#FF9800', bg: 'rgba(255,152,0,0.12)' },
done: { label: '완료', color: theme.colors.online, bg: 'rgba(0,230,118,0.12)' }, done: { label: '완료', color: '#00FF00', bg: 'rgba(0,230,118,0.12)' },
failed: { label: '실패', color: theme.colors.offline, bg: 'rgba(255,23,68,0.12)' }, failed: { label: '실패', color: '#FF1744', bg: 'rgba(255,23,68,0.12)' },
blocked: { label: '블로킹', color: '#FF5722', bg: 'rgba(255,87,34,0.12)' }, blocked: { label: '블로킹', color: '#FF5722', bg: 'rgba(255,87,34,0.12)' },
escalated: { label: '에스컬레이션', color: '#E040FB', bg: 'rgba(224,64,251,0.12)' }, escalated: { label: '에스컬레이션', color: '#E040FB', bg: 'rgba(224,64,251,0.12)' },
}; };

View File

@@ -45,7 +45,7 @@ const Item = styled.li`
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 10px;
padding: 10px 0; padding: 10px 0;
border-bottom: 1px solid ${theme.colors.border}; border-bottom: 1px solid var(--border-color);
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
@@ -65,13 +65,13 @@ const Body = styled.div`
const Action = styled.div` const Action = styled.div`
font-size: 13px; font-size: 13px;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
margin-bottom: 2px; margin-bottom: 2px;
`; `;
const Detail = styled.div` const Detail = styled.div`
font-size: 12px; font-size: 12px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -79,7 +79,7 @@ const Detail = styled.div`
const Time = styled.span` const Time = styled.span`
font-size: 11px; font-size: 11px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
flex-shrink: 0; flex-shrink: 0;
margin-top: 2px; margin-top: 2px;
`; `;
@@ -87,7 +87,7 @@ const Time = styled.span`
const Empty = styled.div` const Empty = styled.div`
padding: 24px; padding: 24px;
text-align: center; text-align: center;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
font-size: 13px; font-size: 13px;
`; `;

View File

@@ -17,10 +17,10 @@ interface SisterCardProps {
} }
const statusBorderColors: Record<Status, string> = { const statusBorderColors: Record<Status, string> = {
online: theme.colors.online, online: '#00FF00',
offline: theme.colors.offline, offline: '#FF1744',
working: theme.colors.working, working: '#2979FF',
unknown: theme.colors.textSecondary, unknown: 'var(--text-secondary)',
}; };
const sisterEmojis: Record<string, string> = { const sisterEmojis: Record<string, string> = {

View File

@@ -34,10 +34,10 @@ const sisterDisplayNames: Record<string, string> = {
}; };
const statusColors: Record<Status, string> = { const statusColors: Record<Status, string> = {
online: theme.colors.online, online: '#00FF00',
offline: theme.colors.offline, offline: '#FF1744',
working: theme.colors.working, working: '#2979FF',
unknown: theme.colors.textSecondary, unknown: 'var(--text-secondary)',
}; };
// ─── 조직도 레이아웃 ─── // ─── 조직도 레이아웃 ───
@@ -61,7 +61,7 @@ const Level = styled.div`
const ConnectorV = styled.div` const ConnectorV = styled.div`
width: 2px; width: 2px;
height: 32px; height: 32px;
background: linear-gradient(to bottom, ${theme.colors.border}, rgba(88,166,255,0.4)); background: linear-gradient(to bottom, var(--border-color), rgba(88,166,255,0.4));
margin: 0 auto; margin: 0 auto;
`; `;
@@ -83,10 +83,10 @@ const BranchWrap = styled.div`
`; `;
const NodeCard = styled.div<{ $status?: Status; $isOwner?: boolean }>` const NodeCard = styled.div<{ $status?: Status; $isOwner?: boolean }>`
background: ${theme.colors.cardBg}; background: var(--bg-surface);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
border: 1px solid ${({ $status }) => border: 1px solid ${({ $status }) =>
$status ? `${statusColors[$status]}66` : theme.colors.border}; $status ? `${statusColors[$status]}66` : 'var(--border-color)'};
border-radius: 12px; border-radius: 12px;
padding: ${({ $isOwner }) => $isOwner ? '14px 24px' : '12px 18px'}; padding: ${({ $isOwner }) => $isOwner ? '14px 24px' : '12px 18px'};
min-width: ${({ $isOwner }) => $isOwner ? '140px' : '130px'}; min-width: ${({ $isOwner }) => $isOwner ? '140px' : '130px'};
@@ -98,7 +98,7 @@ const NodeCard = styled.div<{ $status?: Status; $isOwner?: boolean }>`
&:hover { &:hover {
transform: translateY(-3px); transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0,0,0,0.4); box-shadow: 0 8px 24px rgba(0,0,0,0.4);
border-color: ${({ $status }) => $status ? statusColors[$status] : theme.colors.accent}; border-color: ${({ $status }) => $status ? statusColors[$status] : '#58A6FF'};
} }
`; `;
@@ -110,13 +110,13 @@ const NodeEmoji = styled.div`
const NodeName = styled.div` const NodeName = styled.div`
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
margin-bottom: 2px; margin-bottom: 2px;
`; `;
const NodeRole = styled.div` const NodeRole = styled.div`
font-size: 11px; font-size: 11px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
`; `;
const StatusDot = styled.div<{ $status: Status }>` const StatusDot = styled.div<{ $status: Status }>`
@@ -128,7 +128,7 @@ const StatusDot = styled.div<{ $status: Status }>`
top: 8px; top: 8px;
right: 8px; right: 8px;
${({ $status }) => $status === 'online' && ` ${({ $status }) => $status === 'online' && `
box-shadow: 0 0 6px ${theme.colors.online}; box-shadow: 0 0 6px #00FF00;
`} `}
`; `;
@@ -146,7 +146,7 @@ const Popup = styled.div<{ $x: number; $y: number }>`
top: ${({ $y }) => $y}px; top: ${({ $y }) => $y}px;
z-index: 200; z-index: 200;
background: rgba(22,27,34,0.97); background: rgba(22,27,34,0.97);
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
border-radius: 10px; border-radius: 10px;
padding: 14px 16px; padding: 14px 16px;
min-width: 200px; min-width: 200px;
@@ -158,19 +158,19 @@ const Popup = styled.div<{ $x: number; $y: number }>`
const PopupName = styled.div` const PopupName = styled.div`
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
margin-bottom: 4px; margin-bottom: 4px;
`; `;
const PopupRole = styled.div` const PopupRole = styled.div`
font-size: 12px; font-size: 12px;
color: ${theme.colors.accent}; color: #58A6FF;
margin-bottom: 8px; margin-bottom: 8px;
`; `;
const PopupDesc = styled.div` const PopupDesc = styled.div`
font-size: 12px; font-size: 12px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
line-height: 1.5; line-height: 1.5;
`; `;

View File

@@ -20,9 +20,9 @@ interface ProjectCardProps {
const Card = styled(Link)` const Card = styled(Link)`
display: block; display: block;
background: ${theme.colors.cardBg}; background: var(--bg-surface);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 20px;
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
@@ -46,12 +46,12 @@ const Header = styled.div`
const Name = styled.span` const Name = styled.span`
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
`; `;
const Meta = styled.div` const Meta = styled.div`
font-size: 12px; font-size: 12px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
margin-bottom: 12px; margin-bottom: 12px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -66,7 +66,7 @@ const Stats = styled.div`
const Stat = styled.div` const Stat = styled.div`
font-size: 12px; font-size: 12px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
@@ -80,7 +80,7 @@ const ProgressLabel = styled.div`
const ProgressText = styled.span` const ProgressText = styled.span`
font-size: 11px; font-size: 11px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
`; `;
const PRBadge = styled.span<{ $count: number }>` const PRBadge = styled.span<{ $count: number }>`
@@ -88,7 +88,7 @@ const PRBadge = styled.span<{ $count: number }>`
padding: 2px 7px; padding: 2px 7px;
border-radius: 4px; border-radius: 4px;
background: ${({ $count }) => $count > 0 ? 'rgba(88,166,255,0.12)' : 'rgba(139,148,158,0.08)'}; background: ${({ $count }) => $count > 0 ? 'rgba(88,166,255,0.12)' : 'rgba(139,148,158,0.08)'};
color: ${({ $count }) => $count > 0 ? theme.colors.accent : theme.colors.textSecondary}; color: ${({ $count }) => $count > 0 ? '#58A6FF' : 'var(--text-secondary)'};
font-weight: 600; font-weight: 600;
`; `;

View File

@@ -33,11 +33,11 @@ const sprintStatusIcon: Record<string, string> = {
}; };
const sprintStatusColor: Record<string, string> = { const sprintStatusColor: Record<string, string> = {
pending: theme.colors.textSecondary, pending: 'var(--text-secondary)',
in_progress: theme.colors.working, in_progress: '#2979FF',
review: '#FF9800', review: '#FF9800',
done: theme.colors.online, done: '#00FF00',
failed: theme.colors.offline, failed: '#FF1744',
}; };
const assigneeEmojis: Record<string, string> = { const assigneeEmojis: Record<string, string> = {
@@ -51,8 +51,8 @@ const Wrapper = styled.div`
`; `;
const AccordionItem = styled.div<{ $open: boolean }>` const AccordionItem = styled.div<{ $open: boolean }>`
background: ${theme.colors.cardBg}; background: var(--bg-surface);
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
transition: border-color 0.2s; transition: border-color 0.2s;
@@ -69,7 +69,7 @@ const Header = styled.button<{ $statusColor: string }>`
border: none; border: none;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
transition: background 0.15s; transition: background 0.15s;
&:hover { &:hover {
@@ -96,14 +96,14 @@ const ProgressWrap = styled.div`
const Chevron = styled.span<{ $open: boolean }>` const Chevron = styled.span<{ $open: boolean }>`
font-size: 12px; font-size: 12px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
transform: ${({ $open }) => $open ? 'rotate(90deg)' : 'none'}; transform: ${({ $open }) => $open ? 'rotate(90deg)' : 'none'};
transition: transform 0.2s; transition: transform 0.2s;
`; `;
const Body = styled.div<{ $open: boolean }>` const Body = styled.div<{ $open: boolean }>`
display: ${({ $open }) => $open ? 'block' : 'none'}; display: ${({ $open }) => $open ? 'block' : 'none'};
border-top: 1px solid ${theme.colors.border}; border-top: 1px solid var(--border-color);
`; `;
const TaskRow = styled.div` const TaskRow = styled.div`
@@ -111,7 +111,7 @@ const TaskRow = styled.div`
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px 16px; padding: 10px 16px;
border-bottom: 1px solid ${theme.colors.border}; border-bottom: 1px solid var(--border-color);
font-size: 13px; font-size: 13px;
&:last-child { border-bottom: none; } &:last-child { border-bottom: none; }
@@ -120,14 +120,14 @@ const TaskRow = styled.div`
const TaskId = styled.code` const TaskId = styled.code`
font-size: 11px; font-size: 11px;
color: ${theme.colors.accent}; color: #58A6FF;
min-width: 70px; min-width: 70px;
font-family: monospace; font-family: monospace;
`; `;
const TaskTitle = styled.span` const TaskTitle = styled.span`
flex: 1; flex: 1;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -135,13 +135,13 @@ const TaskTitle = styled.span`
const Assignee = styled.span` const Assignee = styled.span`
font-size: 12px; font-size: 12px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
min-width: 60px; min-width: 60px;
`; `;
const Iteration = styled.span<{ $count: number }>` const Iteration = styled.span<{ $count: number }>`
font-size: 11px; font-size: 11px;
color: ${({ $count }) => $count > 0 ? '#FF9800' : theme.colors.textSecondary}; color: ${({ $count }) => $count > 0 ? '#FF9800' : 'var(--text-secondary)'};
min-width: 28px; min-width: 28px;
text-align: right; text-align: right;
`; `;
@@ -163,7 +163,7 @@ export default function SprintAccordion({ sprints }: { sprints: Sprint[] }) {
<Wrapper> <Wrapper>
{sprints.map((sprint) => { {sprints.map((sprint) => {
const open = openIds.has(sprint.id); const open = openIds.has(sprint.id);
const color = sprintStatusColor[sprint.status] ?? theme.colors.textSecondary; const color = sprintStatusColor[sprint.status] ?? 'var(--text-secondary)';
return ( return (
<AccordionItem key={sprint.id} $open={open}> <AccordionItem key={sprint.id} $open={open}>
<Header $statusColor={color} onClick={() => toggle(sprint.id)}> <Header $statusColor={color} onClick={() => toggle(sprint.id)}>
@@ -178,7 +178,7 @@ export default function SprintAccordion({ sprints }: { sprints: Sprint[] }) {
</Header> </Header>
<Body $open={open}> <Body $open={open}>
{sprint.tasks.length === 0 ? ( {sprint.tasks.length === 0 ? (
<TaskRow><TaskTitle style={{ color: theme.colors.textSecondary }}> </TaskTitle></TaskRow> <TaskRow><TaskTitle style={{ color: 'var(--text-secondary)' }}> </TaskTitle></TaskRow>
) : ( ) : (
sprint.tasks.map((task) => ( sprint.tasks.map((task) => (
<TaskRow key={task.id}> <TaskRow key={task.id}>

View File

@@ -29,14 +29,14 @@ const Filters = styled.div`
const FilterBtn = styled.button<{ $active: boolean }>` const FilterBtn = styled.button<{ $active: boolean }>`
padding: 4px 10px; padding: 4px 10px;
border-radius: 6px; border-radius: 6px;
border: 1px solid ${({ $active }) => $active ? theme.colors.accent : theme.colors.border}; border: 1px solid ${({ $active }) => $active ? '#58A6FF' : 'var(--border-color)'};
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'}; background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'};
color: ${({ $active }) => $active ? theme.colors.accent : theme.colors.textSecondary}; color: ${({ $active }) => $active ? '#58A6FF' : 'var(--text-secondary)'};
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
&:hover { border-color: ${theme.colors.accent}; color: ${theme.colors.accent}; } &:hover { border-color: #58A6FF; color: #58A6FF; }
`; `;
const Table = styled.table` const Table = styled.table`
@@ -49,8 +49,8 @@ const Th = styled.th`
padding: 8px 12px; padding: 8px 12px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
border-bottom: 1px solid ${theme.colors.border}; border-bottom: 1px solid var(--border-color);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
`; `;
@@ -58,8 +58,8 @@ const Th = styled.th`
const Td = styled.td` const Td = styled.td`
padding: 10px 12px; padding: 10px 12px;
font-size: 13px; font-size: 13px;
border-bottom: 1px solid ${theme.colors.border}; border-bottom: 1px solid var(--border-color);
color: ${theme.colors.textPrimary}; color: var(--text-primary);
`; `;
const Tr = styled.tr` const Tr = styled.tr`
@@ -68,19 +68,19 @@ const Tr = styled.tr`
`; `;
const TaskIdCell = styled.code` const TaskIdCell = styled.code`
color: ${theme.colors.accent}; color: #58A6FF;
font-family: monospace; font-family: monospace;
font-size: 12px; font-size: 12px;
`; `;
const IterationCell = styled.span<{ $count: number }>` const IterationCell = styled.span<{ $count: number }>`
color: ${({ $count }) => $count > 0 ? '#FF9800' : theme.colors.textSecondary}; color: ${({ $count }) => $count > 0 ? '#FF9800' : 'var(--text-secondary)'};
`; `;
const Empty = styled.div` const Empty = styled.div`
padding: 32px; padding: 32px;
text-align: center; text-align: center;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
font-size: 13px; font-size: 13px;
`; `;

View File

@@ -6,7 +6,7 @@ import { theme } from '@/styles/theme';
const Pre = styled.pre` const Pre = styled.pre`
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
font-size: 12px; font-size: 12px;
@@ -26,7 +26,7 @@ const Pre = styled.pre`
const Empty = styled.div` const Empty = styled.div`
padding: 32px; padding: 32px;
text-align: center; text-align: center;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
font-size: 13px; font-size: 13px;
`; `;

View File

@@ -20,8 +20,8 @@ const Th = styled.th`
padding: 8px 12px; padding: 8px 12px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
border-bottom: 1px solid ${theme.colors.border}; border-bottom: 1px solid var(--border-color);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
`; `;
@@ -29,8 +29,8 @@ const Th = styled.th`
const Td = styled.td` const Td = styled.td`
padding: 10px 12px; padding: 10px 12px;
font-size: 13px; font-size: 13px;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
border-bottom: 1px solid ${theme.colors.border}; border-bottom: 1px solid var(--border-color);
`; `;
const Tr = styled.tr` const Tr = styled.tr`
@@ -39,7 +39,7 @@ const Tr = styled.tr`
`; `;
const SessionName = styled.code` const SessionName = styled.code`
color: ${theme.colors.accent}; color: #58A6FF;
font-family: monospace; font-family: monospace;
font-size: 12px; font-size: 12px;
`; `;
@@ -47,7 +47,7 @@ const SessionName = styled.code`
const Empty = styled.div` const Empty = styled.div`
padding: 32px; padding: 32px;
text-align: center; text-align: center;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
font-size: 13px; font-size: 13px;
`; `;

View File

@@ -39,12 +39,12 @@ const Avatar = styled.div<{ $status: Status }>`
width: 56px; width: 56px;
height: 56px; height: 56px;
border-radius: 50%; border-radius: 50%;
background: ${theme.colors.cardBg}; background: var(--bg-surface);
border: 2px solid ${({ $status }) => border: 2px solid ${({ $status }) =>
$status === 'online' ? theme.colors.online : $status === 'online' ? '#00FF00' :
$status === 'working' ? theme.colors.working : $status === 'working' ? '#2979FF' :
$status === 'offline' ? theme.colors.offline : $status === 'offline' ? '#FF1744' :
theme.colors.border}; 'var(--border-color)'};
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -59,7 +59,7 @@ const Info = styled.div`
const Name = styled.h1` const Name = styled.h1`
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
margin-bottom: 4px; margin-bottom: 4px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -68,14 +68,14 @@ const Name = styled.h1`
const Role = styled.span` const Role = styled.span`
font-size: 13px; font-size: 13px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
display: block; display: block;
margin-bottom: 6px; margin-bottom: 6px;
`; `;
const Description = styled.p` const Description = styled.p`
font-size: 13px; font-size: 13px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
line-height: 1.6; line-height: 1.6;
max-width: 600px; max-width: 600px;
`; `;
@@ -89,7 +89,7 @@ const MetaRow = styled.div`
const MetaItem = styled.span` const MetaItem = styled.span`
font-size: 12px; font-size: 12px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;

View File

@@ -17,7 +17,7 @@ const Grid = styled.div`
const Card = styled.div` const Card = styled.div`
background: rgba(0,0,0,0.2); background: rgba(0,0,0,0.2);
border: 1px solid ${theme.colors.border}; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
padding: 14px 16px; padding: 14px 16px;
display: flex; display: flex;
@@ -34,20 +34,20 @@ const Info = styled.div``;
const Name = styled.div` const Name = styled.div`
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: ${theme.colors.textPrimary}; color: var(--text-primary);
margin-bottom: 2px; margin-bottom: 2px;
`; `;
const File = styled.div` const File = styled.div`
font-size: 11px; font-size: 11px;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
font-family: monospace; font-family: monospace;
`; `;
const Empty = styled.div` const Empty = styled.div`
padding: 32px; padding: 32px;
text-align: center; text-align: center;
color: ${theme.colors.textSecondary}; color: var(--text-secondary);
font-size: 13px; font-size: 13px;
`; `;

View File

@@ -0,0 +1,222 @@
'use client';
import styled, { css } from 'styled-components';
// ─── Label Meta ───
export const LabelMeta = styled.span`
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-primary);
span {
color: var(--text-secondary);
margin-right: var(--space-sm);
}
`;
// ─── Bracket Value ───
export const BracketValue = styled.div<{ $dimmed?: boolean }>`
font-size: 32px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
color: ${({ $dimmed }) => $dimmed ? 'var(--text-secondary)' : 'var(--text-primary)'};
@media (max-width: 767px) {
font-size: 24px;
}
`;
// ─── Card ───
export const Card = styled.div`
border: 1px solid var(--border-color);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
background: var(--bg-surface);
transition: border-color 0.2s;
&:hover {
border-color: var(--border-hover);
}
`;
// ─── Tech Bar ───
export const TechBar = styled.div`
height: 2px;
width: 100%;
background-color: var(--border-color);
margin-top: auto;
position: relative;
`;
export const TechBarFill = styled.div<{ $width: number }>`
position: absolute;
top: 0;
left: 0;
height: 100%;
width: ${({ $width }) => $width}%;
background-color: var(--text-primary);
transition: width 0.4s ease;
`;
// ─── Section Title ───
export const SectionTitle = styled.div`
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-sm);
margin-bottom: var(--space-lg);
display: flex;
justify-content: space-between;
align-items: center;
`;
// ─── Timeline ───
export const Timeline = styled.div`
display: flex;
flex-direction: column;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 3px;
width: 1px;
background-color: var(--border-color);
}
`;
export const TimelineItem = styled.div`
position: relative;
padding-left: var(--space-lg);
padding-bottom: var(--space-lg);
&::before {
content: '';
position: absolute;
left: 0;
top: 6px;
width: 7px;
height: 7px;
background-color: var(--bg-main);
border: 1px solid var(--text-primary);
border-radius: 50%;
}
`;
export const TimeStamp = styled.span`
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
margin-bottom: var(--space-xs);
display: block;
`;
export const TimelineContent = styled.div`
font-size: 13px;
color: var(--text-primary);
line-height: 1.5;
strong {
font-weight: 600;
}
`;
// ─── Page Container ───
export const PageContainer = styled.div`
display: flex;
width: 100%;
max-width: 1600px;
margin: 0 auto;
min-height: 100vh;
`;
// ─── Main Content ───
export const MainContent = styled.main`
flex-grow: 1;
padding: var(--space-xl) var(--space-xxl);
display: flex;
flex-direction: column;
gap: var(--space-xl);
min-width: 0;
@media (max-width: 767px) {
padding: var(--space-lg) var(--space-md);
padding-bottom: 72px; /* bottom tab bar */
}
@media (min-width: 768px) and (max-width: 1199px) {
padding: var(--space-xl) var(--space-xl);
}
`;
// ─── Page Header ───
export const PageHeader = styled.header`
margin-bottom: var(--space-md);
`;
export const PageTitle = styled.h1`
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text-primary);
`;
// ─── Btn ───
const btnBase = css`
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: var(--space-sm) var(--space-md);
cursor: pointer;
transition: all 0.15s;
font-family: var(--font-sans);
&:hover {
border-color: var(--border-hover);
color: var(--text-primary);
}
`;
export const Btn = styled.button`${btnBase}`;
export const BtnPrimary = styled.button`
${btnBase}
background: var(--text-primary);
color: #000;
border-color: var(--text-primary);
&:hover {
background: transparent;
color: var(--text-primary);
}
`;
export const BtnToggle = styled.button<{ $active?: boolean }>`
border: 1px solid var(--border-color);
background: ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
color: ${({ $active }) => $active ? '#000' : 'var(--text-secondary)'};
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
padding: 4px var(--space-md);
cursor: pointer;
transition: all 0.15s;
font-family: var(--font-mono);
&:hover {
border-color: var(--border-hover);
color: ${({ $active }) => $active ? '#000' : 'var(--text-primary)'};
}
`;

View File

@@ -1,6 +1,26 @@
import { createGlobalStyle } from 'styled-components'; import { createGlobalStyle } from 'styled-components';
const GlobalStyle = createGlobalStyle` const GlobalStyle = createGlobalStyle`
:root {
--bg-main: #151515;
--bg-surface: #151515;
--bg-input: #1a1a1a;
--bg-code: #0d0d0d;
--text-primary: #F5F5F5;
--text-secondary: #8A8A8A;
--border-color: #333333;
--border-hover: #555555;
--accent-hover: #FFFFFF;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--space-xxl: 64px;
}
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@@ -9,10 +29,11 @@ const GlobalStyle = createGlobalStyle`
html, body { html, body {
height: 100%; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, font-family: var(--font-sans);
Ubuntu, Cantarell, sans-serif; background-color: var(--bg-main);
background-color: #0D1117; color: var(--text-primary);
color: #E6EDF3; line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
@@ -22,22 +43,31 @@ const GlobalStyle = createGlobalStyle`
text-decoration: none; text-decoration: none;
} }
button {
font-family: inherit;
cursor: pointer;
}
input, textarea, select {
font-family: inherit;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 4px;
height: 6px; height: 4px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #0D1117; background: var(--bg-main);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(240, 246, 252, 0.1); background: var(--border-color);
border-radius: 3px; border-radius: 2px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(240, 246, 252, 0.2); background: var(--border-hover);
} }
`; `;

View File

@@ -1,15 +1,38 @@
export const theme = { export const theme = {
colors: { colors: {
bg: '#0D1117', bgMain: '#151515',
cardBg: 'rgba(22, 27, 34, 0.8)', bgSurface: '#151515',
textPrimary: '#E6EDF3', bgInput: '#1a1a1a',
textSecondary: '#8B949E', bgCode: '#0d0d0d',
accent: '#58A6FF', textPrimary: '#F5F5F5',
border: 'rgba(240, 246, 252, 0.1)', textSecondary: '#8A8A8A',
online: '#00E676', borderColor: '#333333',
offline: '#FF1744', borderHover: '#555555',
working: '#2979FF', accentHover: '#FFFFFF',
sidebarBg: 'rgba(13, 17, 23, 0.95)', // status (org tree)
levelHigh: '#f43f5e',
levelMid: '#3b82f6',
levelLow: '#10b981',
levelNone: '#525252',
// log tags
tagSystem: '#888',
tagSecurity: '#ff5f5f',
tagOrg: '#5fafff',
tagBackup: '#5fff8a',
// status indicators
statusPass: '#00FF00',
},
fonts: {
sans: '-apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif',
mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
},
space: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '40px',
xxl: '64px',
}, },
} as const; } as const;